Compare commits
6 Commits
6cbc6ad937
...
df52fa0cd0
| Author | SHA1 | Date | |
|---|---|---|---|
| df52fa0cd0 | |||
| d7bdb45312 | |||
| a66b0a8341 | |||
| b97b4b7405 | |||
| 6c813e26ab | |||
| 32937ce2c1 |
@ -18,7 +18,15 @@ chain:
|
||||
- [x] gate 5-anchor W2 PASS 5/5 (A4 line-drift content-found; A5 KeHoachKyKet∈All:180 THẬT; phân xử lệch: mã sinh @CREATE per spec, entity-comment W1 sai → worker sửa)
|
||||
- [x] wf W2 đợt-1 `wf_6f0aad26-229`: 2/2 lane chết #53 NHƯNG sub-md cứu trọn (BE 16.5KB — 15 endpoint policy 15/15 + 4 quyết D1-D4 lead duyệt cả 4 · FE 4 file SHA-pair) → lead solo đuôi FE 4-place (WfView 2-app-2-đường + whitelist matrix +10) → **build BE 0W/0E + FE ×2 PASS + coming-soon 0/0**; harvest 2 diary xong
|
||||
- [x] wf W2 đợt-2 `wf_7490179a-70b`: test-specialist chết #53 sau khi file LAND — **8 test** (2 PIN + 6 acceptance, gồm không-đốt-mã-khi-409 + fixture-có-interceptor cho soft-delete) → lead suite tươi **574/0** (566+8 ≥ sàn 568); harvest diary xong
|
||||
- [!] reviewer W2 ĐANG CHẠY nền (trục chính: hợp-đồng FE↔BE đứt-2-bờ [2 lane viết song song] · IDOR D3 · picker-khớp-rào · test-coverage-claim · boundary 0-diff) → PASS thì commit/push → cicd
|
||||
- [x] reviewer W2: **12 finding (3 CRIT đứt-2-bờ + F-8/F-9 security 0-rào detail/download)** — chết #53 sau Trục-4, lead đo nốt 5/6/7 + finalize on-behalf → **11 FIXED + 1 GIỮ (F-10 kế thừa PE)** → build+suite GIỮ 574/0 + marker ruột-bundle 1/1/1
|
||||
- [x] commit W2 `6cbc6ad` (20 file +5.020/−23) PUSHED
|
||||
- [x] **W2 ĐÓNG — cicd VERDICT=PASS 6/6** (PHÉP 1+4 cicd [CI #428 gate 574 · bundle 2-app rotate + marker 1/1 + control-âm 3-tầng] + PHÉP 2+3+5+6 lead 2-nguồn [0-mig MIGTOP giữ · menu 142 · smoke 200 + **picker trả PE THẬT `PE/2026/A/045`** + control 404 · CỜ 13/13/2 giữ]). Synthesis `runs/2026-07-29-S161-khkk-w2-crud/w2-synthesis.md`. Tồn: F-10 bịt-đồng-bộ-2-module (cần anh) · dựng WF tay type-3+10 (trước W5) · UAT non-Admin
|
||||
- [x] gate 5-anchor W3 PASS 5/5 (content-based; A1 path thật = Infrastructure/Services)
|
||||
- [x] W3 đợt-1 `wf_ef4d05a8-98c` XONG (2 lane #53, sub-md cứu trọn): BE skeleton + rào-FK-547-vắng-dep + đối-chứng-chéo-FE-5/5 + 3 quyết lead-duyệt · FE panel wire :324 ×2 + SHA-pair 3/3 + marker ×3 bundle; build 0W/0E + 574/0 giữ; harvest 2 diary xong
|
||||
- [x] W3 đợt-2 `wf_445f7e28-38b` XONG (#53, sub-md+file cứu): 6 test PIN land, **lead chạy RED-evidence: 6/6 ĐỎ THẬT** (5 NotImplementedException từng nhánh + Inbox đỏ-assertion); fixture-riêng-có-interceptor vì CreatedBy (2 quyết hạ tầng khai trong sub-md)
|
||||
- [x] W3 đợt-3 `wf_c9d5f8d4-cbe` XONG (#53, sub-md 15KB trọn §0-§7): 6/6 GREEN + **suite 580/0 lead-tươi** · choke-point **1-site-proof-cấu-trúc** (hợp-đồng 0 targetPhase ⇒ admin xuyên choke-point) · sửa-test đúng 1 seed `2→1` (FAULT-INJECT sót, 0 assert đổi) · save-điểm = 1 SaveChanges cuối (service là điểm vào, khác nguồn :537) · **Q1-Q6 lead DUYỆT cả 6**; tồn UAT: mark-read chuông cũ (mirror PE S96)
|
||||
- [!] reviewer W3 ĐANG CHẠY nền (fidelity OR-of-N · choke-point re-verify độc lập · Q4-hệ-quả-FE-nút · hợp-đồng literal · test-integrity · notify · boundary nguồn-copy-0-diff) → PASS ⇒ commit/push → cicd → W3 ĐÓNG
|
||||
- [ ] W6 → W5 → W7 theo đồ thị plan-cha (sau W3)
|
||||
- [ ] đợt-2 SAU khi wf về: test-specialist + database-agent (song song) → lead acceptance §③-B (build·test·sqlcmd 96·Down sạch·đo CỜ) → reviewer → commit/push → cicd-monitor 3-chân-kiềng
|
||||
- [ ] setup thủ công TRƯỚC W5 (SAU W1 vì type=10 cần mig 69): dựng workflow type=3 + type=10 3-trạm qua Designer (seed chỉ 1 trạm)
|
||||
- [ ] housekeeping @pause/closeout kế: nén MIND-1 5.447B (trần `_mind` 83,2% — MIND-5 chắc vượt) · re-carry 2 ý D rơi (vá-GỐC-Phase-3.5-refresh {mới-nêu} CÒN SỐNG + khuôn-5-đợt→sổ bền {gần-chốt}) · STATUS:6 bổ arc dry-run S160 (mù có lý do — land 15:27 trước pipeline) · hạ nhãn DR/WF-3-trạm đã-định-đoạt · E đếm 7 sub-file (không 6) · mở lại slot-45 trần `_mind` (tiền-đề lật: file SỐNG 83,2%) · quyết bookkeeping OVERDUE:light (cặp H24 chạy @S159 bookend mà `last_audit` đứng 27)
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
# Implementer-Backend Agent — Persistent Memory
|
||||
|
||||
- **S161-W2 (07-29) KHKK CRUD mega-file + Controller `[hmw wf_6f0aad26-229 · chết #53 SAU §7 (trước §8 build-log) NHƯNG sub-md 16.5KB ghi-từng-mục TRỌN + build lead 0W/0E 2s incremental = lane đã build xanh; entry on-behalf]`:** `ContractSigningPlanFeatures.cs` (Create 4-rào-fail-sớm + auto-Lines snapshot + UpdateDraft null-safe 3/3 #73 + Get/List/inbox/deleted + Dossier + Attachments-mọi-phase + Delete allow-list M6 + Picker) + `ContractSigningPlansController` 15 action **policy 15/15 kể cả Read tường minh** (#82). 4 QUYẾT khai-để-lead-phân-xử (lead duyệt cả 4): D1 `PeId` giữ nguyên spec (hợp-đồng 2-lane, đổi = gãy body JSON) · D2 `DrafterUserId=currentUser` KHÔNG copy pe.Drafter (Guid?→non-null = Guid.Empty hỏng quyền) · D3 IDOR mình-soạn ∪ có-chân-duyệt (khuôn Proposal 0-rào là khuôn HỞ — bài 8b S155; nới-phòng = 1 vế 1 chỗ) · D4 ProposedAmount mồi=PeReferenceAmount (W2 0 command sửa line). Bẫy né được ghi lại: **(1) codegen gọi TRƯỚC `Add(plan)`** — generator SaveChanges nội bộ trong tx riêng, Add trước = flush phiếu dở (S88 self-committing); **(2) SUM quote so `w.Id` (row PeSupplier) KHÔNG `w.SupplierId`** (nhầm = cộng tiền NCC khác); **(3) `Remove(plan)` TRƯỚC `Add(changelog)`** — CascadeDeleteTiming.Immediate detach entity Added (bug T26 S155); **(4) subquery `Any` lồng 2 tầng trong Where vỡ dịch SQL CHỈ LÚC CHẠY** → dùng hình dạng join :185-189; **(5) tự mở lại 5 anchor + grep-cùng-lớp ra 3 site comment sai khi lead giao 1** (S122). Tag `[s161-w2, khkk-crud, 4-quyet-khai-lead-phan-xu, codegen-truoc-add, wid-vs-supplierid, remove-truoc-changelog]`
|
||||
|
||||
- **S161 (07-29) W1 KHKK schema Mig 69 `[hmw wf_7d6fb81b-640 + wf_f8c806d2-5a3 · CẢ 2 lượt chết #53 (×2 cùng task) NHƯNG lượt-2 sub-md 6.2KB ghi-TỪNG-MỤC-trong-lúc-làm cứu TRỌN narrative + code đĩa sống 100%; lead verify build 0W/0E + solo đuôi; entry on-behalf]`:** Module `Domain/ContractSigningPlans/` 7 entity + 3 enum (Phase 1/2/3/98/99 · DossierItemKind/Status trong DossierItem.cs, start=1) + `ApplicableType.ContractSigningPlan=10` append-only + 7 Configuration + DbSets ×2 file + **Mig 69 `20260729122015_AddContractSigningPlans`** (7 CreateTable · 0 ALTER · Down 7 DropTable · UNIQUE filtered `(PlanId,SupplierId) WHERE IsDeleted=0` #57 · MaKeHoach unique filter NOT-NULL · FK 6-con Cascade 1-hop + Plan→AWF Restrict — **0 multiple-cascade-path by design**: Attachment.DossierItemId chỉ HasIndex KHÔNG FK) · apply 2 DB **0 drift** (khác S133) · sys.tables 89→96 · MenuKeys.All+=KeHoachKyKet:179. Lead solo đuôi: seeder `SeedKeHoachKyKetAccessAsync` (khuôn S118 + MỞ RỘNG vế CanDelete Drafter/Admin, UPGRADE-if-exists F-S1, call SAU revoke) + `ContractSigningPlanCodeGenerator` (KHKK/{YYYY}/{Seq:D3}, SERIALIZABLE, `WorkflowAppCodeSequences` dùng chung ns Domain.Office) + DI + 3 site AWV2Admin (count/retained/orphan — danh sách kết ở VehicleBooking, ItTicket KHÔNG có LevelOpinions). Learned: (1) **ghi-sub-md-TỪNG-MỤC = thứ duy nhất sống khi return chết** — lượt-1 dồn cuối mất trắng narrative, lượt-2 ghi từng mục giữ đủ grep-proof; (2) audit-first §0 bảng đo trước khi làm tiếp = relaunch-cắt-gọt chuẩn; (3) Changelog/Approval = BaseEntity KHÔNG HasQueryFilter (parity PE) — schema-test phải assert theo THỰC TẾ entity không đoán. Tag `[s161, w1-khkk-mig69, sub-md-tung-muc-cuu-narrative, 53-x2-cung-task, cascade-1-hop, upgrade-if-exists-candelete]`
|
||||
|
||||
- **S153 (07-26) arc ctx-adopt T1 + T3 (2 lượt hmw đợt-1) `[lead seed on-behalf @closeout M9; T1 return mất schema-fail → derive-from-body; T3 return CLEAN]`:** **T1 máy `mind-check`** — EXTEND `scripts/session_ctx.py` (+528 dòng, ADD-only: `_TS_KEY_RE` module-level canonical + subcommand + parser + dispatch; 2 subcommand cũ BẤT ĐỘNG). 7 phép + 2 phụ · 4 mức {đạt·trượt·cờ·bỏ-qua-có-khai} · draft/closed/`--root`/`--session` PIN · exit {0,1,3}. **9 quyết-định thiết-kế có lý-do đo được**, đắt nhất: **D1 config đọc LAZY** — chỉ đọc `memory-budget.json` khi CÓ tệp để đo, vì 2 dòng acceptance chỉ đồng-thời đúng theo thứ tự đó ("không có gì để đo thì không đọc trần; có thứ để đo thì TỪ CHỐI đoán trần") · D3 marker VẮNG ⇒ quét TRỌN + CỜ (hướng hỏng NGHIÊM: tệp thiếu marker = tệp sai khuôn, phải lộ) · D4 rào-3 in pattern-name + `file:line` **KHÔNG in snippet** (gate chống rò không được tự rò ra log) · D6 `_ascii_safe()` backslashreplace (console cp1252 gặp diacritic ⇒ `UnicodeEncodeError` = máy chết GIỮA gate).
|
||||
**T3 persona `ctx-audit`** 11/11 acceptance (14.347B): CHỈNH-1 của lead landed đúng nấc (`tools:` = **mô-tả ý-định**, backstop thật = git-diff commit-gate) + verify 2 chiều `grep -c 'chặn-bằng-thiếu-tool'` = 0 (cụm đó sống ở `harness-eval.md:60`, KHÔNG carry sang) · M-1 hình-B **từ lúc sinh** (đọc `lead-stale-auditor.md:51-54` CHỈ để tránh, 0 chữ chép). **5 phát hiện thêm:** vai tự nhập lưới INFORM-only của detector khi file land (`:1721-1742` đọc LIVE frontmatter) · PHẦN-B #22 sẽ FIRE THẬT nếu lead scribe mà chưa seed diary · H25 parse greedy ⇒ `sub-ctx-audit-open` UNMAPPED không flag (no-name-guessing) · C11(a) `$roleNameRx` KHÔNG áp ctx-audit ⇒ đúng khi persona không bắt gắn class-tag.
|
||||
Tag `[s153, t1-mind-check-extend-python, d1-config-lazy, d4-khong-in-snippet, t3-persona-11-11, tu-nhap-luoi-inform-only]`
|
||||
- **S161 (07-29) ARC W1+W2+W3đ1 KHKK — 3 lượt/3 wave, chi tiết = sub-md run-folder `runs/2026-07-29-S161-khkk-{w1-schema,w2-crud,w3-duyet}/` (tổng ~47KB ghi-từng-mục; cả 3 lượt return #53 nhưng 0 mất):** **W1** Mig 69 7-bảng (UNIQUE filtered #57 · cascade-1-hop · apply 2 DB 0-drift · sys.tables 96) + seeder `SeedKeHoachKyKetAccessAsync` UPGRADE-if-exists+CanDelete + CodeGen `KHKK/{YYYY}/{Seq:D3}` WorkflowAppCodeSequences + 3 site AWV2Admin — ship `a8027c0` cicd 6/6. **W2** mega-file CRUD 15-endpoint policy-15/15 + 4 quyết D1-D4 khai-lead-duyệt; 5 bẫy né ghi sổ: codegen-TRƯỚC-Add (S88) · SUM so `w.Id` KHÔNG `w.SupplierId` · `Remove` trước `Add(changelog)` (T26) · subquery-Any-2-tầng vỡ-SQL-lúc-chạy · giao-1-grep-cùng-lớp-ra-3 (S122) — ship `6cbc6ad` cicd 6/6 (reviewer 12F: D1 "FE sẽ khớp" = giả-định-không-phải-phép-đo → 6 điểm đứt, lead vá). **W3đ1** skeleton test-before: switch-throw từng nhánh (đỏ-THẬT không đỏ-compile) + **rào-FK-547-bằng-VẮNG-MẶT-dep** (không inject IChangelogService) + **§6 tự đối-chứng chéo hợp-đồng FE 5/5 TRƯỚC bàn giao** (bài W2 → nghi thức) + §7 bàn-giao ctor-CUỐI. Tag `[s161, khkk-w1-w2-w3d1, mig69, upgrade-if-exists, rao-vang-mat-dep, doi-chung-cheo, gia-dinh-vs-phep-do]`
|
||||
|
||||
- **S139 (07-17) Governance wave adap-3-bản 2-lane [wf_f9981ae5-903 · L1 CLEAN · L2 #53-garble → sub-1.md ON-BEHALF em-main verify-2-nguồn] `[engine: opus-worker]`:** L1 = MASTER-CHECKLIST 2-tầng in 4 cửa (ss Phase-3 · se Phase-4 "TỔNG-HỢP CHỐT" · pause ý-6 · tiep §3-bis bold-block KHÔNG heading) + C1 2-câu đóng-băng bề-mặt-đo (§L.b(c) prepend + mirror §2.1.2) + C6i do-record ×2 + luật-số sàn-5 §L.b(j)(iv) — PASS 6/6 (heading 26/28/7/14 bất-biến · 0 checklist-file mới · BƯỚC-0 intact ×8 · 10/10 config-token preserved · add-only 26+/2−). L2 = +C6 cite-2-nấc + H24-4 pending-flip vào governance-detectors.ps1 (+260/0 add-only) — fault-inject **13/13 2-chiều** (C6 7/7 lane chạy; H24-4 6/6 em-main hoàn tất — lane chết GIỮA fix harness-bug), live-run TOTAL 45 GIỮ + 2 net mới 0-flag count-riêng + exit-0 + ASCII 0-byte>127 + clean-diff 0-FOREIGN-line. Learned: (1) heading-invariant proof = block chèn toàn `>`/bullet + `grep '^+#'`=none (mạnh hơn đọc-mắt, họ insertion-only S134); (2) test-harness PS1 cần CÙNG kỷ-luật naming như production — bẫy `$h`/`$H` case-insensitive cắn chính harness dù script-đích đã né (`:813`); (3) 2 lane cùng chạm bề-mặt-đo (L1 sửa file nằm trong scan-scope L2) → số đo giữa-chừng trôi, clean-diff PHẢI cùng-snapshot. Tag `[s139, adap-3-ban, master-checklist-4-cua, c6-cite-2-nac, h24-4-pending-flip, faultinject-13-13, 53-writelane-onbehalf]`
|
||||
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
# Implementer-Frontend Agent — Persistent Memory
|
||||
|
||||
- **S161-W2 (07-29) KHKK 3 page ×2 app `[hmw wf_6f0aad26-229 · chết #53 sau MUC-1+cp (4-place dở) → lead solo đuôi {Layout ×2 re-point + fe-admin App routes + matrix whitelist +10} · build ×2 PASS + SHA-pair 4/4 + coming-soon 0/0]`:** `types/khkk.ts` + 3 page `pages/khkk/` viết fe-user trước rồi cp (SHA-pair 4/4). Types theo ENTITY (BE lane song song chưa land Application lúc recon — luật đề bài "ưu tiên shape entity" ăn); header types khai HỢP-ĐỒNG FE↔BE 12 endpoint để BE đối chiếu 1 chỗ. Chip lọc List đổi STATE không ghi URL (bug UAT S155 menu-sáng-nhầm). Create: dropdown workflow đọc `{types:[...]}` KHÔNG phải flat array (khuôn ProposalCreatePage:59-69). Detail: Lines READ-ONLY (W2 không endpoint sửa line — KHÔNG bịa UI). **WfView 2 app 2 ĐƯỜNG (lead chốt @đuôi):** fe-user → alias route `/khkk/workflow-matrix?type=10` tái dùng `WorkflowMatrixViewPage` (whitelist :56 nay `1|2|3|10`) · fe-admin → Designer deep-link `/system/approval-workflows-v2/ContractSigningPlan` (admin CỐ Ý không có matrix — chốt 07-27; "Luồng duyệt" admin = dựng quy trình). menuKeys.ts place-3 SKIP-có-khai: pages 0 usage MenuKeys/usePermission (khớp khuôn Proposal) — entry-không-consumer = drift. Root `KeHoachKyKet` staticMap fe-user /dashboard→`/khkk/list`. Tag `[s161-w2, khkk-3-page, types-theo-entity, wfview-2-app-2-duong, alias-route-khong-de-page, place3-skip-khong-consumer]`
|
||||
|
||||
- **S161 (07-29) W1 KHKK Designer type-10 fe-admin `[hmw wf_7d6fb81b-640 lane-2 · return CLEAN · lead verify: build tươi PASS + disk khớp từng site; diagnostics IDE giữa-agent = stale (#68) đĩa thắng]`:** 1 file `ApprovalWorkflowsV2Page.tsx` (+67/−9): `TYPE_INT_CONTRACT_SIGNING_PLAN=10` (:147) → `TYPE_CODE_TO_INT:162` (deep-link `/ContractSigningPlan`, route sẵn `App.tsx:76`) + `DEFAULT_CODE_BY_TYPE:170` `'QT-KHKK-V2-001'` (suy diễn khuôn — spec KHÔNG chốt, đổi 1 dòng) + rào 2 checkbox early-finalize **2 LỚP**: render (`disabled` + `checked` ép false + tooltip dùng chung `KHKK_EARLY_FINALIZE_NOTE` :1284-1330, className ternary chuỗi ĐẦY ĐỦ Pattern-14) **VÀ** payload (`levelFlagsPayload(e, lockEarlyFinalize)` :247 ép false :254-255 — 2/2 call-site PUT :783 + POST :815) — chỉ disable ô = containment GIẢ vì `copyFromDefinition()` vẫn tuồn `true` vào state→BE. Cân 409: PUT echo-so-BẰNG (`AWV2AdminFeatures:580-585`) KHÔNG nổ vì type-10 mọi bản đều POST-ép-false + cloneFrom cùng-type. **2 tiền-đề đề bài SAI, lane tự bẻ (mirror bài S160):** (i) FE KHÔNG có bảng nhãn ApplicableType — nhãn từ BE `applicableTypeLabel`; (ii) khuôn "entry type=9" không tồn tại (`TYPE_CODE_TO_INT` dừng ở 3) → mirror `Contract:3`. **Cờ đỏ báo lead:** type-10 (và cả type-3 xưa nay) KHÔNG có đường sidebar — allowlist `Layout.tsx:162-168` chỉ có 2 key `AwV2_` PE; admin vào bằng URL trực tiếp; BE seed leaf mới thì PHẢI nới `Layout.tsx:165` cùng lượt. Tag `[s161, w1-designer-type10, two-layer-containment-render-payload, fe-khong-label-map, tien-de-de-bai-sai-tu-be, stale-diag-68]`
|
||||
- **S161 (07-29) ARC W1+W2+W3đ1 KHKK FE — 3 lượt, chi tiết = sub-md `runs/2026-07-29-S161-khkk-*/` (mọi lượt #53, đĩa cứu trọn):** **W1** Designer type-10: containment **2-LỚP render+payload** (`levelFlagsPayload(e, lock)` ép false 2/2 call-site — chỉ `disabled` = containment GIẢ vì `copyFromDefinition` tuồn state); 2 tiền-đề đề-bài SAI tự bẻ (FE không có bảng nhãn — nhãn từ BE `applicableTypeLabel`; khuôn type-9 không tồn tại → mirror `Contract:3`). **W2** 3 page + types ×2 SHA-pair; types-theo-ENTITY khi BE song song; header types = HỢP-ĐỒNG 12-endpoint (reviewer đối chiếu ra 6 đứt → lead vá; bài: hợp-đồng phải LEAD-định-nghĩa literal); WfView 2-app-2-đường (user matrix?type=10 whitelist+10 · admin Designer deep-link); place-3 menuKeys SKIP-có-khai (0 consumer). **W3đ1** `KhkkWorkflowPanel` mirror PE-panel 1063-dòng theo bảng **GIỮ/BỎ tường minh** (BỎ đủ 2 đường cấm + price-picker/budget/4-radio); cây workflow THÔ — **FE tự suy ✓/●/○ từ con-trỏ đôi** (StepIndex 0-based vs Level.Order giá-trị); bổ `approverUserId` opinion (W2 khai thiếu). Tag `[s161, khkk-fe-3-wave, containment-2-lop, types-theo-entity, giu-bo-tuong-minh, fe-suy-status-con-tro]`
|
||||
|
||||
- **S153 (07-26) arc ctx-adopt T2 + T5 (2 lượt hmw) `[lead seed on-behalf @closeout M9; T2 return mất schema-fail (#53 +1) → derive-from-body, template VẪN LAND 8.819B; T5 return CLEAN]`:** **T2 template** `.claude/templates/session-mind-template.md` — 3 marker (RULES-START/END · MIND-TOP) · **0 dòng key `ts:`** (ts sống trong HEADING sau em-dash ⇒ không phạm rào-2) · 6 hit `rào-` (3 rào × tên+lý-do) · chiều chèn **NGƯỢC** `_context` khai TO trong khối luật · placeholder `<k>` trong schema-comment chống parser false-match (mirror trick template `_context:32`) · kiểm `_SECRET_RES` để ví-dụ rào-3 không tự khớp mẫu (secrets-sweep rglob TRỌN session-dir, KHÔNG biết enclosure).
|
||||
**T5 `hmw.js`** 3/3 acceptance: VALID_ROLES 20→21 (sau T10/T11 = **23**) + 🔴 **writeGuard là 2 ĐIỂM không phải 1** — `:228` JS-comment doc **và** `:235` template-literal ĐI VÀO PROMPT sub; chỉ sửa `:235` ⇒ doc lệch trong CÙNG file, chỉ sửa `:228` ⇒ **prompt runtime KHÔNG đổi** ⇒ vai read-only bị hướng-dẫn như write-sub ⇒ Bash-write MD ⇒ mojibake G-009. `:235` = **điểm có RĂNG**. Vùng CẤM đo bằng diff: `WIDTH_CAP` 0 hit · `TIER2_EXPECTED_FULL_ID` 0 hit.
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# Reviewer Agent — Persistent Memory
|
||||
|
||||
- **S161-W2 (07-29) KHKK CRUD review — PWF 12 FLAG 3C/5M/4m `[chết #53 sau Trục-4 (13.9KB ghi-từng-trục sống trọn) → lead đo Trục 5-7 + verdict on-behalf; 11 FIXED cùng lượt + 1 GIỮ]`:** 🔴 **Trục-1 hợp-đồng FE↔BE VỠ 6 điểm khi 2 lane viết SONG SONG từ cùng spec** — tsc+build+574-test đều XANH cả 6: route picker lệch tên (F-1, 404 đội lốt empty-state vì TanStack nuốt lỗi + `?? []`) · body `purchaseEvaluationId` vs `PeId` (F-2 — **D1 của BE lập luận "FE đọc cùng spec sẽ khớp" = GIẢ ĐỊNH, không phải phép đo; FE đã KHÔNG làm thế**) · dossier body thiếu planId (F-3) · FE chỉ gọi POST khi BE ép Id=null ⇒ Sửa-đẻ-bản-sao im lặng (F-4) · `peTenGoiThau` (F-5) · `winnerSupplierNames` BE không có (F-6 — **bị F-1 CHE: mảng rỗng nên .map chưa chạy, vá F-1 xong mới lòi** = 2-lỗi-che-nhau). **Luật rút: đối chiếu hợp-đồng = so ROUTE+FIELD từng cái giữa types-FE và record-BE trên ĐĨA, đừng tin lời khai lane nào.** · F-8/F-9 security: **list bịt mà detail/download 0-rào ⇒ rào list chỉ còn là UX** — đối chứng twin PE :877-899 CÓ guard + S89 nháp-riêng-tư ⇒ thụt chuẩn, không phải "khuôn vốn thế" · F-12 INNER-join Projects (global-filter) = picker silent-vanish — lớp "vắng-mặt trông giống ổn". Trục-4 picker-khớp-rào ĐẠT (3/3 rào PE mirror đủ). Tag `[s161-w2, hop-dong-2-lane-song-song-6-diem-dut, loi-khai-la-gia-dinh, 2-loi-che-nhau, list-bit-detail-mo-rao-thanh-ux, inner-join-global-filter-vanish]`
|
||||
|
||||
- **[→ archive/2026-07.md @S161-curate] S159 đợt-5 (07-29) menu 6-leaf HĐ + `/contracts/deleted` (PWF 0H/4M/6L):** chú-thích-mang-SỐ phải chạy phép đếm (comment "2 chỗ IgnoreQueryFilters" — thật 3 file/17 site) · cookie-cutter phải soi CẢ phần bản gốc đã CHẶN (thiếu `disabled={deletedView}` ⇒ panel trắng) · kiểm hide-filter TRƯỚC khi kêu thiếu mirror 2-app · `tsc` + `noUnusedLocals` mạnh hơn grep cho claim "đã cắt".
|
||||
|
||||
- **S161 (07-29) W1 KHKK tiền-commit (Mig 69 + seeder + Designer type-10) — PWF 10 FLAG 1H/3M/4m/2L:** 🔴 **HIGH lại nằm ở `git` chứ không ở mã (S155 tái diễn):** 2/3 file Mig 69 UNTRACKED `??` mà snapshot đã tracked-M ⇒ `commit -a` nạp model 7 bảng nhưng BỎ migration; build+566 test vẫn xanh ⇒ **gate = `git status --porcelain -- src tests | grep '^??'` phải RỖNG ngay trước commit**. · **MAJOR bắt được nhờ đi NGƯỢC lời-khai FE:** comment FE tự khai "nhãn lấy từ BE `applicableTypeLabel`" ⇒ đi kiểm bờ BE: `AwLabels.Type` chỉ 3 entry, `GetValueOrDefault(type, type.ToString())` ⇒ UI hiện **"ContractSigningPlan"** tiếng Anh và `useState(\`Quy trình ${label}\`)` **GHI chuỗi Anh vào cột Name DB** — lớp *hợp-đồng-đứt-2-bờ*, tsc+build+test đều xanh. **Luật rút: câu comment dạng "bên kia lo" = ĐI ĐỌC BÊN KIA.** · seeder 5/5 PASS (call sau revoke `:2109`>`:2096`; revoker chỉ `Hrm|Off|Personal` ⇒ không lật 2-chiều; `TryGetValue` tuple `(RoleId,MenuKey)` — khuôn 1-role sẽ ném dup-key, unique-index `PermissionConfiguration:26` cứu) nhưng **acceptance "đo CỜ" chưa đo được ở đâu**: Dev DB **0 row** `Khkk*` cả MenuItems lẫn Permissions (menus 100 vs prod 142) ⇒ chưa nhánh nào từng chạy. · **`grep`-cùng-lớp bằng TOKEN-SONG-SINH** (`VehicleBookingLevelOpinions` repo-wide) mạnh hơn grep tên-mới: chứng 3/3 site đủ + 0 site thứ 4. · 2 chú thích cùng file nay SAI SỐ ("7 bảng"/"7 khối" → 8) + comment nguồn `MenuKeys.cs:37` "CỐ Ý NGOÀI All" nay sai + anchor `(:614)` trỏ dòng TRƯỚC-commit (đúng = :645) ⇒ **diff sửa mã phải sửa MỌI số/neo trong chú thích cùng file**. · STATUS.md `Menu keys 54`/`Policies 216` không đổi dù |All|=55 ⇒ derived-drift. Tag `[s161, high-o-git-khong-o-ma, hop-dong-dut-2-bo-nhan-BE, token-song-sinh-sweep, seeder-upgrade-if-exists, comment-mang-so-va-neo]`
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
# cicd-monitor — verify deploy `6cbc6ad` (W2 KHKK, CRUD + FE 3 page ×2 app, 0-mig)
|
||||
|
||||
> Agent: cicd-monitor (READ-only) · S161 · 2026-07-29
|
||||
> **Ghi-đĩa-TRONG-lúc-làm** (chống #53). Mỗi mục ghi NGAY khi có bằng chứng. Kết bằng dòng `END … VERDICT=`.
|
||||
> Khuôn kế thừa: `.claude/workflows/runs/2026-07-29-S161-khkk-w1-schema/cicd-verify-a8027c0.md`
|
||||
|
||||
---
|
||||
|
||||
## CHÂN 0 — Push + path-filter (pre-condition)
|
||||
|
||||
| Mục | Giá trị |
|
||||
|---|---|
|
||||
| Commit | `6cbc6ad937ebccb443be207549801c89dee575f0` |
|
||||
| Subject | `[CLAUDE] App+Api+FE-User+FE-Admin+Tests: W2 KHKK — CRUD phieu nhap + can cu b.8-9 + FE 3 page x2 app + 12 fix reviewer` |
|
||||
| Commit time | 2026-07-29 21:19:41 +0700 |
|
||||
| `git log origin/main..HEAD` | **rỗng** ⇒ đã push ✅ |
|
||||
| Files changed | **20 file, +5020 / −23** |
|
||||
| Path-filter (#41) | 20/20 file NON-ignored (`src/Backend/**`, `fe-admin/src/**`, `fe-user/src/**`, `tests/**`) ⇒ **CI PHẢI trigger** ✅ |
|
||||
| Mig on-disk trong diff | **0 file `*Migrations*`** ⇒ commit 0-mig (kỳ vọng history KHÔNG advance) |
|
||||
|
||||
**Files (rút gọn):** BE 4 (`ContractSigningPlansController.cs` +200 · `ContractSigningPlanFeatures.cs` +1228 · `IContractSigningPlanCodeGenerator.cs` · Domain `ContractSigningPlan.cs` + Config) · FE-admin 6 (App/Layout/3 page khkk/types) · FE-user 7 (idem + `WorkflowMatrixViewPage.tsx`) · Tests 2 (`AuthorizePolicyRegressionTests` +118 · `ContractSigningPlanCrudTests` +563).
|
||||
|
||||
---
|
||||
|
||||
## CHÂN 4-PRE — Byte-marker: kiểm ĐỘC QUYỀN + DỰ ĐOÁN TRƯỚC KHI ĐO (luật S159 #426/#425)
|
||||
|
||||
Chuỗi ứng-viên: `approved-pe-awaiting-plan` (route mới W2).
|
||||
|
||||
`rg -c` repo-wide (loại `node_modules`/`dist`): 9 hit — 4 file **code** + 5 file **governance/WAL/run-trace** (không vào bundle):
|
||||
|
||||
| Nơi | Occurrence | Vào bundle? |
|
||||
|---|---|---|
|
||||
| `fe-admin/src/types/khkk.ts:16` | 1 | ❌ **nằm trong COMMENT `// GET …`** → esbuild strip |
|
||||
| `fe-admin/src/pages/khkk/KhkkCreatePage.tsx:50` | 1 | ✅ literal runtime `api.get<…>('/contract-signing-plans/approved-pe-awaiting-plan')` |
|
||||
| `fe-user/src/types/khkk.ts:16` | 1 | ❌ COMMENT |
|
||||
| `fe-user/src/pages/khkk/KhkkCreatePage.tsx:50` | 1 | ✅ literal runtime |
|
||||
| `src/Backend/.../ContractSigningPlansController.cs:65` | 1 | n/a (BE) |
|
||||
|
||||
Cả 4 file FE đều **SINH MỚI trong `6cbc6ad`** (diff stat: `khkk.ts` +229, `KhkkCreatePage.tsx` +254 mỗi app) ⇒ chuỗi **ĐỘC QUYỀN cho W2**, 0 hit ở bundle trước.
|
||||
|
||||
**⟹ DỰ ĐOÁN (ghi TRƯỚC khi đo — khuôn comment-strip #425):**
|
||||
- bundle admin MỚI = **1** (source 2, 1 trong comment) · bundle user MỚI = **1**
|
||||
- bundle CŨ `index-M8SDA7Ps.js` (admin) / `index-Y6dW_5CM.js` (user) = **biến mất khỏi đĩa** ⇒ `Content-Type: text/html` (SPA-fallback), KHÔNG phải js thật
|
||||
- hash-bịa = `text/html` (control âm tầng-1)
|
||||
|
||||
**Route BE thật (đọc controller):** `[Route("api/contract-signing-plans")]` + `[HttpGet("approved-pe-awaiting-plan")]` ⇒ `/api/contract-signing-plans/approved-pe-awaiting-plan`. Class-level `[Authorize(Policy="KeHoachKyKet.Read")]` + method-level idem (#82 2-tầng: policy tồn tại vì W1 đã seed key `KeHoachKyKet`).
|
||||
|
||||
---
|
||||
|
||||
## BASELINE PRE-DEPLOY (đo 21:2x +0700, TRƯỚC khi run #428 xong)
|
||||
|
||||
| Chỉ số | Giá trị pre-deploy |
|
||||
|---|---|
|
||||
| admin bundle | js `index-M8SDA7Ps.js` (1.679.452 B, LM 20:09:05) · css `index-C3xzT0H9.css` (83.740 B) |
|
||||
| user bundle | js `index-Y6dW_5CM.js` (1.595.577 B, LM 20:10:00) · css `index-6YIAufJR.css` (89.466 B) |
|
||||
| `MAX(MigrationId)` | `20260729122015_AddContractSigningPlans` (Mig 69) |
|
||||
| `COUNT(*) __EFMigrationsHistory` | **69** |
|
||||
| `sys.tables` (is_ms_shipped=0) | **96** · trong đó `ContractSigningPlan%` = **7** |
|
||||
| `COUNT(*) MenuItems` | **142** · họ KHKK (`KeHoachKyKet`+`Khkk_*`) = **8** |
|
||||
| `COUNT(*) ContractSigningPlans` | **0** (chưa có phiếu — W2 chỉ ship CRUD, chưa nhập liệu) |
|
||||
| Permission 7 key đích | R=13 · C=13 · U=13 · D=2 (mỗi key, rows=13) |
|
||||
| Permission `Khkk_G1` (control ÂM) | R=13 · C=0 · U=0 · D=0 |
|
||||
| PE Phase=7 (DaDuyet) có winner | **24** (tổng DaDuyet cũng 24) ⇒ picker kỳ vọng **≤24 phần tử** |
|
||||
| DLL `SolutionErp.Api.dll` | LM 20:07:50 (từ run #427) |
|
||||
| w3wp hiện có | pid 4980 StartTime **20:10:05** (pool API từ run #427) + 5 pid khác (site VietReport) |
|
||||
|
||||
⚠️ **Schema note (tự đính chính):** cột thật là `Permissions.MenuKey` (KHÔNG join `MenuItems`) và `MenuItems.[Key]` (KHÔNG `MenuKey`) — query đầu sai tên cột, đã sửa; con số dưới đây lấy từ query ĐÚNG.
|
||||
|
||||
Kỳ vọng post-deploy: MIGTOP/MIGCOUNT/TABLES/MENUS **GIỮ NGUYÊN** (0-mig) · bundle **rotate CẢ 2 app** · marker=1/app.
|
||||
|
||||
---
|
||||
|
||||
## PHÉP 1 — CI run + test-gate 574 ✅ PASS
|
||||
|
||||
| Mục | Giá trị |
|
||||
|---|---|
|
||||
| Run | **#428** (`id=541`), key theo `head_sha=6cbc6ad9` (KHÔNG theo run_number) |
|
||||
| URL | https://git.baocaogiaoduc.vn/vietreport-admin/solution-erp/actions/runs/428 |
|
||||
| status | **success** (`conclusion:None` — endpoint `tasks` không populate, tin `status`) |
|
||||
| created → updated | 21:19:51 → 21:25:47 (+0700) ⇒ **5m56s** |
|
||||
| Poll | 4 iter × ~35s (running ×3 → success) |
|
||||
|
||||
**Test-gate (web-UI log `…/actions/runs/428/jobs/0/logs`, 17.893 B) — đối chiếu TÁCH-PHẦN:**
|
||||
|
||||
```
|
||||
Passed! - Failed: 0, Passed: 45, Skipped: 0 ← SolutionErp.Domain.Tests.dll
|
||||
Passed! - Failed: 0, Passed: 529, Skipped: 0 ← SolutionErp.Infrastructure.Tests.dll
|
||||
```
|
||||
|
||||
| | Domain | Infra | Tổng |
|
||||
|---|---|---|---|
|
||||
| Kỳ vọng spec | 45 | 529 | **574** |
|
||||
| Log run #428 | **45** | **529** | **574** ✅ |
|
||||
| Run trước (#427) | 45 | 521 | 566 |
|
||||
| Δ | 0 | **+8** | **+8** (khớp 2 test-file mới: `AuthorizePolicyRegressionTests` +118 dòng · `ContractSigningPlanCrudTests` +563 dòng) |
|
||||
|
||||
0 fail / 0 skip cả 2 project. Deploy step trong log: `Deploy done. App pool started.` (14:25:26Z = **21:25:26 +07**) → `API /health/live -> 200` (21:25:38).
|
||||
|
||||
---
|
||||
|
||||
## PHÉP 4 — Bundle byte-marker + control ÂM 3 tầng ✅ PASS
|
||||
|
||||
| App | Bundle CŨ (pre) | Bundle MỚI (post) | Δ hash |
|
||||
|---|---|---|---|
|
||||
| admin | js `M8SDA7Ps` · css `C3xzT0H9` | js **`D4uy3yxF`** · css **`9okZenCM`** | **ROTATE cả js+css** |
|
||||
| user | js `Y6dW_5CM` · css `6YIAufJR` | js **`C1ASe0K1`** · css **`BfN7IuxJ`** | **ROTATE cả js+css** |
|
||||
|
||||
**Đo trực tiếp (curl -D headers + grep byte-level):**
|
||||
|
||||
| Probe | HTTP | Content-Type | Last-Modified | size (B) | marker `approved-pe-awaiting-plan` |
|
||||
|---|---|---|---|---|---|
|
||||
| admin **MỚI** `D4uy3yxF.js` | 200 | `application/javascript` | 14:24:24Z = **21:24:24** in-window | 1.709.294 (+29.842 vs cũ) | **1** ✅ khớp dự đoán |
|
||||
| user **MỚI** `C1ASe0K1.js` | 200 | `application/javascript` | 14:25:20Z = **21:25:20** in-window | 1.625.473 (+29.896 vs cũ) | **1** ✅ khớp dự đoán |
|
||||
| admin **CŨ** `M8SDA7Ps.js` (control âm t2) | 200 | **`text/html`** | — | **900** | 0 ⇒ bundle cũ **BIẾN MẤT khỏi đĩa** |
|
||||
| user **CŨ** `Y6dW_5CM.js` (control âm t2) | 200 | **`text/html`** | — | **876** | 0 ⇒ idem |
|
||||
| admin hash **BỊA** `ZZZZfake9.js` (t1) | 200 | `text/html` | — | **900** trùng khít hash-cũ | 0 |
|
||||
| user hash **BỊA** `ZZZZfake9.js` (t1) | 200 | `text/html` | — | **876** trùng khít hash-cũ | 0 |
|
||||
|
||||
🔑 **Dự đoán ghi TRƯỚC (comment-strip #425) = 1/app, đo được đúng 1/app.** Source có 2 occurrence/app nhưng 1 nằm trong comment `//` → esbuild strip ⇒ chỉ literal runtime `KhkkCreatePage.tsx:50` sống sót. Nếu đo ra 2 thì mới là bất thường.
|
||||
🔑 **Control âm 3 tầng đóng SPA-fallback trap:** hash-bịa và hash-CŨ trả **cùng** `text/html` + **cùng** byte-size ⇒ 200 của bundle mới (1,7 MB `application/javascript`) là 200 **CÓ NGHĨA**, không phải catch-all.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PHÉP 2+3+5+6 — [LEAD đo on-behalf @S161: vai chết #53 sau PHÉP 4; VERIFY-lane 2-nguồn (ssh sqlcmd + curl bearer)]
|
||||
|
||||
**PHÉP 2 — 0-mig ✅ PASS:** `MIGTOP=20260729122015_AddContractSigningPlans` GIỮ · `MIGCOUNT=69` KHÔNG đổi · `TABLES=96` GIỮ.
|
||||
**PHÉP 3 — Menu ✅ PASS:** `MENU=142` GIỮ NGUYÊN.
|
||||
**PHÉP 5 — Smoke KHKK ✅ PASS:** login admin token-468 · `GET /api/contract-signing-plans` **200** `{items:[],total:0}` (rỗng hợp lệ — chưa ai tạo phiếu) · `GET .../approved-pe-awaiting-plan` **200 + DATA THẬT** (PE `PE/2026/A/045` "12 Sub Nội thất" đang chờ lập KH — route+authz+query+LEFT-join sống end-to-end prod) · control route bịa **404** (200 CÓ NGHĨA).
|
||||
**PHÉP 6 — CỜ giữ ✅ PASS:** `KeHoachKyKet` C/U/D = **13/13/2** GIỮ nguyên qua restart deploy (seeder idempotent, durability +1 lần).
|
||||
|
||||
END cicd-verify 6cbc6ad — **VERDICT=PASS 6/6** (PHÉP 1+4 = cicd-monitor · PHÉP 2+3+5+6 = lead 2-nguồn)
|
||||
@ -11,9 +11,11 @@
|
||||
| 1 | Đợt-1a BE: mega-file `ContractSigningPlanFeatures.cs` (Create+auto-Lines+MaKeHoach@CREATE · UpdateDraft · Get/List/pendingMe+inbox-precompute · Dossier UPSERT/DELETE · Attachments · DELETE allow-list · picker PE-chờ-lập-KH) + `ContractSigningPlansController` authz 2-tầng per-action | hmw `implementer-backend` | [x] `wf_6f0aad26-229` lane-0 chết #53 sau §7 NHƯNG sub-md **16.5KB ghi-từng-mục TRỌN** (tự mở 5 anchor + grep-cùng-lớp 3 site comment + 15 endpoint policy 15/15 + **4 quyết D1-D4 khai-lead-phân-xử → lead DUYỆT cả 4**) · build lead 0W/0E |
|
||||
| 2 | Đợt-1b FE: `pages/khkk/` 3 page + types ×2 app + 4-place mirror (routes + staticMap re-point 6 leaf khỏi coming-soon + menuKeys) + build ×2 + SHA-pair | hmw `implementer-frontend` | [x] lane-1 chết #53 sau MUC-1+cp (SHA-pair 4/4) → **lead solo đuôi**: Layout ×2 re-point 6 leaf (WfView 2 app 2 đường: user=alias matrix?type=10 whitelist+10 · admin=Designer deep-link) + fe-admin App routes + root KeHoachKyKet→/khkk/list; menuKeys place-3 SKIP-có-khai (0 consumer). Build ×2 PASS + coming-soon **0/0** |
|
||||
| 3 | Đợt-2: test-specialist (≥2 test PIN: `ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy` + `CreateContractSigningPlan_FromNonApprovedPe_Throws`) | hmw `test-specialist` | [!] `wf_7490179a-70b` ĐANG CHẠY nền |
|
||||
| 4 | Lead: build ×2 + suite + acceptance §③-B sweep | lead | [ ] |
|
||||
| 5 | Reviewer adversarial (CRUD endpoints POST/PUT/DELETE — bắt buộc) | `reviewer` | [ ] |
|
||||
| 6 | Commit + push + cicd (3-chân-kiềng + 2-bearer NẾU có tài khoản non-Admin — nv.test KHÔNG tồn tại prod, xem w1-synthesis tồn-2) | lead + `cicd-monitor` | [ ] |
|
||||
| 4 | Lead: build ×2 + suite + acceptance §③-B sweep | lead | [x] build BE 0W/0E ×2 lượt · suite **574/0** ×2 lượt (trước + SAU 12 fix) · FE ×2 PASS + **marker trong RUỘT bundle 1/1/1** (chứng build thật — 466ms rolldown-warm) · SHA-pair 4/4 giữ |
|
||||
| 5 | Reviewer adversarial (CRUD endpoints POST/PUT/DELETE — bắt buộc) | `reviewer` | [x] **PWF 12 finding (3 CRIT · 5 MAJ · 4 MIN)** — Trục-1 đứt-2-bờ VỠ NẶNG 6 điểm (route picker · peId · dossier planId · PUT-vs-POST · peTenGoiThau · winnerSupplierNames — **2 lỗi che nhau F-1/F-6**; "lời-khai bên-kia-sẽ-khớp = giả định, không phải phép đo") + F-8/F-9 security detail+download 0-rào (PE twin CÓ — thụt chuẩn S89) → **11 FIXED + 1 GIỮ (F-10 kế thừa khuôn PE)**; vai chết #53 sau Trục-4 → lead đo nốt Trục 5 (test ĐỘNG ĐẠT) + 6 (boundary 0-diff ĐẠT) + 7 (tự-kiểm khai bias) + verdict on-behalf. `sub-reviewer-3.md` END+TOTAL=12 |
|
||||
| 6 | Commit + push + cicd (3-chân-kiềng + 2-bearer NẾU có tài khoản non-Admin — nv.test KHÔNG tồn tại prod, xem w1-synthesis tồn-2) | lead + `cicd-monitor` | [!] commit **`6cbc6ad`** (20 file +5.020/−23, F-0 gate ĐẠT) PUSHED → cicd spawn nền (6 phép: CI 574 · 0-mig MIGTOP giữ · menu 142 · marker `approved-pe-awaiting-plan` 2 app · smoke KHKK GET · CỜ giữ) |
|
||||
|
||||
**Tồn W2:** F-10 upload/delete-attachment no-owner-check = lỗ CHUNG khuôn PE — bịt ĐỒNG BỘ 2 module 1 đợt riêng (cần anh gật vì đụng PE đang chạy prod).
|
||||
|
||||
## Ràng buộc mang theo
|
||||
- 🔴 CẤM copy phần DUYỆT của Proposal (bẫy flatten :427-439 — OR-of-N thành AND; duyệt = W3 con-trỏ-đôi mirror PE).
|
||||
|
||||
@ -0,0 +1,215 @@
|
||||
# sub-reviewer-3 — W2 KHKK adversarial review (TRƯỚC COMMIT)
|
||||
|
||||
> Run: `2026-07-29-S161-khkk-w2-crud` · vai: reviewer · lượt 3
|
||||
> Luật: ghi TỪNG TRỤC ngay khi đo xong (W1 tôi garble rồi resume — lần này ghi từ trục đầu).
|
||||
> Diff scope (git status lúc bắt đầu): 9 M + 8 ?? (ngoài `.claude/`).
|
||||
|
||||
VERDICT (đang dựng — cập nhật ở cuối file)
|
||||
|
||||
---
|
||||
|
||||
## §0 — Diff scope đo được
|
||||
|
||||
```
|
||||
M fe-admin/src/App.tsx +9
|
||||
M fe-admin/src/components/Layout.tsx 17 +/-
|
||||
M fe-user/src/App.tsx +12
|
||||
M fe-user/src/components/Layout.tsx 20 +/-
|
||||
M fe-user/src/pages/pe/WorkflowMatrixViewPage.tsx 3 +/-
|
||||
M src/.../ContractSigningPlans/Services/IContractSigningPlanCodeGenerator.cs 4
|
||||
M src/.../Domain/ContractSigningPlans/ContractSigningPlan.cs 7
|
||||
M src/.../Configurations/ContractSigningPlanConfiguration.cs 6
|
||||
M tests/.../Api/AuthorizePolicyRegressionTests.cs +118
|
||||
?? fe-admin/src/pages/khkk/ · fe-admin/src/types/khkk.ts
|
||||
?? fe-user/src/pages/khkk/ · fe-user/src/types/khkk.ts
|
||||
?? src/.../Api/Controllers/ContractSigningPlansController.cs
|
||||
?? src/.../Application/ContractSigningPlans/ContractSigningPlanFeatures.cs
|
||||
?? tests/.../Application/ContractSigningPlanCrudTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §1 — TRỤC 1: HỢP-ĐỒNG FE↔BE (lớp đứt-2-bờ) — 🔴 VỠ NẶNG, 6 điểm đứt
|
||||
|
||||
Đo: `fe-user/src/types/khkk.ts` + 3 page `pages/khkk/*.tsx` (SHA identical fe-admin) **vs**
|
||||
`ContractSigningPlansController.cs` (route thật) + `ContractSigningPlanFeatures.cs` (record DTO/Command thật).
|
||||
|
||||
### F-1 🔴 CRITICAL — Picker Create gọi route KHÔNG TỒN TẠI → 404, màn "Lập kế hoạch" LUÔN rỗng
|
||||
- FE: `fe-user/src/pages/khkk/KhkkCreatePage.tsx:50` → `api.get('/contract-signing-plans/available-purchase-evaluations')`
|
||||
(header hợp-đồng `fe-user/src/types/khkk.ts:16` khai đúng chữ đó).
|
||||
- BE: `ContractSigningPlansController.cs:65` → `[HttpGet("approved-pe-awaiting-plan")]`.
|
||||
- ⇒ 404. TanStack nuốt lỗi (không render `candidates.isError`), `list = candidates.data ?? []` (`:92`)
|
||||
⇒ UI hiện đúng dòng "Không có phiếu Duyệt NCC nào đã duyệt mà chưa lập kế hoạch ký kết." (`:129`)
|
||||
= **lỗi trông y hệt trạng-thái-rỗng-hợp-lệ**. Không tạo được phiếu nào. tsc/build/574-test đều xanh.
|
||||
- Fix 1 dòng: đổi 1 trong 2 cho khớp (đề nghị BE giữ route, FE sửa `:50` + `types/khkk.ts:16`).
|
||||
|
||||
### F-2 🔴 CRITICAL — POST body sai TÊN TRƯỜNG → 400 vĩnh viễn (đứt đúng chỗ D1 mà implementer tưởng đã chốt)
|
||||
- BE: `ContractSigningPlanFeatures.cs:275-278` `CreateContractSigningPlanCommand(Guid PeId, ...)`
|
||||
→ JSON nhận `peId`. Validator `:284` `RuleFor(x => x.PeId).NotEmpty()`.
|
||||
- FE: `types/khkk.ts:193-197` `CreateKhkkInput { purchaseEvaluationId, ... }`; gửi ở `KhkkCreatePage.tsx:72-77`.
|
||||
- ⇒ `purchaseEvaluationId` KHÔNG bind vào `PeId` (không phải alias) ⇒ `PeId = Guid.Empty` ⇒
|
||||
ValidationException 400 **mọi lần bấm Lưu**. Nút tạo phiếu chết.
|
||||
- 🔴 Ghi chú adversarial: `sub-implementer-backend-0.md:80` D1 lập luận "lane FE dựng body từ CÙNG spec ⇒ giữ `PeId`
|
||||
cho khỏi gãy". **Lane FE đã KHÔNG làm thế** — nó dùng tên đầy đủ theo convention repo. Lời-khai "bên kia sẽ khớp"
|
||||
là giả định, không phải phép đo (lặp đúng lớp F-7/W1).
|
||||
- Fix 1 dòng: đổi FE `purchaseEvaluationId` → `peId` (2 chỗ: type + body), HOẶC đổi BE record thành
|
||||
`PurchaseEvaluationId` (khớp convention repo + FE) rồi sửa 4 site `request.PeId`.
|
||||
|
||||
### F-3 🔴 CRITICAL — POST dossier-item thiếu `contractSigningPlanId` → 400 vĩnh viễn
|
||||
- BE `ContractSigningPlansController.cs:114-115`: `if (id != cmd.ContractSigningPlanId) return BadRequest(...)`.
|
||||
- FE `KhkkDetailPage.tsx:153-158`: body = `{...itemForm, name, tvgsName, note}` — `UpsertKhkkDossierItemInput`
|
||||
(`types/khkk.ts:205-213`) **KHÔNG có** `contractSigningPlanId`.
|
||||
- ⇒ `cmd.ContractSigningPlanId = Guid.Empty` ≠ `id` ⇒ 400 "ID kế hoạch trên đường dẫn không khớp" mọi lần lưu căn cứ b.8-9.
|
||||
- Fix 1 dòng: FE thêm `contractSigningPlanId: id` vào body (+ field trong interface).
|
||||
|
||||
### F-4 🔴 MAJOR — Sửa căn cứ b.8-9 tạo BẢN SAO thay vì cập nhật (FE không bao giờ gọi PUT)
|
||||
- BE có 2 đường: `POST /{id}/dossier-items` (`:109-118`, ép `cmd with { Id = null }` = LUÔN insert)
|
||||
và `PUT /{id}/dossier-items/{itemId}` (`:120-130` = update).
|
||||
- FE chỉ gọi POST (`KhkkDetailPage.tsx:153`) cho CẢ 2 nhánh; `itemForm` mang `id` khi bấm Sửa
|
||||
(`types/khkk.ts:206` `id?: string | null`) nhưng BE **ném đi**.
|
||||
- ⇒ Sau khi vá F-3, bấm "Sửa" 1 căn cứ sẽ **đẻ dòng thứ 2** (dữ liệu bẩn im lặng, không lỗi).
|
||||
Header hợp-đồng `types/khkk.ts:17-18` cũng chỉ khai POST + DELETE — thiếu hẳn PUT ⇒ lane FE không biết PUT tồn tại.
|
||||
- Fix 1 dòng: FE `body.id ? api.put(.../dossier-items/${body.id}, ...) : api.post(...)`.
|
||||
|
||||
### F-5 🟠 MAJOR — Tên gói thầu KHÔNG hiện (list + detail): `peTenGoiThau` (BE) vs `tenGoiThau` (FE)
|
||||
- BE `ContractSigningPlanFeatures.cs:39` `string? PeTenGoiThau` (cả List `:39` lẫn Detail `:124`).
|
||||
- FE `types/khkk.ts:108` `tenGoiThau`; đọc ở `KhkkListPage.tsx:223` (cột chính hiện `—`) và
|
||||
`KhkkDetailPage.tsx:281` (tiêu đề trang tụt về chuỗi mặc định 'Kế hoạch ký kết HĐ').
|
||||
- Fix 1 dòng: đổi FE field thành `peTenGoiThau` (hoặc đổi tên record BE) — 1 chỗ ở types + 2 chỗ đọc.
|
||||
|
||||
### F-6 🟠 MAJOR — Picker crash trắng trang sau khi vá F-1: `winnerSupplierNames` KHÔNG có ở BE
|
||||
- FE `KhkkCreatePage.tsx:160,163` gọi `pe.winnerSupplierNames.length` / `.map(...)`.
|
||||
- BE `ApprovedPeAwaitingPlanDto` (`ContractSigningPlanFeatures.cs:150-164`) có `WinnerCount` + `WinnerQuoteTotal`,
|
||||
**KHÔNG có** `WinnerSupplierNames`.
|
||||
- ⇒ `undefined.length` → TypeError → React unmount cả trang (hiện đang bị F-1 che: mảng rỗng nên map chưa chạy;
|
||||
**vá F-1 xong sẽ lòi ra ngay** — 2 lỗi che nhau).
|
||||
- Fix 1 dòng: BE thêm `List<string> WinnerSupplierNames` vào DTO (append CUỐI), hoặc FE đổi sang render `winnerCount`.
|
||||
|
||||
### F-7 🟡 MINOR — Attachment: `contractSigningPlanDossierItemId` (FE `types/khkk.ts:148`) vs `DossierItemId` (BE `:84`)
|
||||
- Hiện chưa có chỗ đọc field này trong page (grep 0 hit ngoài file type) ⇒ chưa vỡ hiển thị,
|
||||
nhưng W3/gom-nhóm-scan-theo-căn-cứ sẽ vỡ. Fix: đổi FE thành `dossierItemId`.
|
||||
|
||||
### Đối chiếu path (12 path FE khai vs route thật)
|
||||
| FE khai (`types/khkk.ts:10-21`) | BE thật | Khớp |
|
||||
|---|---|---|
|
||||
| GET `/contract-signing-plans` | `:30` | ✔ |
|
||||
| GET `/deleted` | `:53` | ✔ |
|
||||
| GET `/{id}` | `:71` | ✔ |
|
||||
| POST `/` | `:78` | ✔ route, ✘ body (F-2) |
|
||||
| PUT `/{id}` | `:87` | ✔ (body 3 field khớp `UpdateContractSigningPlanDraftBody:191`) |
|
||||
| DELETE `/{id}` | `:97` | ✔ |
|
||||
| GET `/available-purchase-evaluations` | ✘ KHÔNG CÓ (thật: `/approved-pe-awaiting-plan` `:65`) | ✘ **F-1** |
|
||||
| POST `/{id}/dossier-items` | `:109` | ✔ route, ✘ body (F-3) |
|
||||
| DELETE `/{id}/dossier-items/{itemId}` | `:132` | ✔ |
|
||||
| POST `/{id}/attachments` | `:142` | ✔ (form `file`+`purpose`; FE không gửi `dossierItemId` — optional, OK) |
|
||||
| GET `/{id}/attachments/{attId}/download` | `:162` | ✔ |
|
||||
| DELETE `/{id}/attachments/{attId}` | `:180` | ✔ |
|
||||
| *(FE KHÔNG khai)* PUT `/{id}/dossier-items/{itemId}` `:120` · GET `/inbox` `:47` · GET `/{id}/attachments/{attId}/view` `:171` | — | mã BE chưa ai gọi (F-4 + wire-sẵn W3) |
|
||||
|
||||
---
|
||||
|
||||
## §2 — TRỤC 2: IDOR scope D3 (mã thật, không đọc lời khai)
|
||||
|
||||
Đo `ContractSigningPlanFeatures.cs` toàn bộ 1187 dòng.
|
||||
|
||||
### Vị ngữ list SỐNG (`:619-628`) và list ĐÃ XOÁ (`:749-758`) — KHỚP nhau, đúng như D3 khai
|
||||
```
|
||||
non-admin: p.DrafterUserId == userId
|
||||
|| (p.Phase != DangSoanThao && p.ApprovalWorkflowId != null && userWfIds.Contains(...))
|
||||
```
|
||||
`userWfIds` = mọi workflow mà user là `ApproverUserId` ở BẤT KỲ Cấp nào (`:256-264`). ✔ đúng lời khai
|
||||
"mình-soạn ∪ có-chân-duyệt", ✔ nháp người khác KHÔNG lọt, ✔ 2 màn cùng một vị ngữ (không lệch).
|
||||
Người CÙNG PHÒNG không soạn/không có chân duyệt → **không thấy** (đúng thiết kế đã khai D3, có comment chỉ chỗ nới).
|
||||
|
||||
### F-8 🔴 MAJOR (an ninh) — GET `/{id}` detail KHÔNG có rào nào; module-anh-em PE thì CÓ
|
||||
- `GetContractSigningPlanQueryHandler.Handle` (`ContractSigningPlanFeatures.cs:472-486`) chỉ
|
||||
`FirstOrDefaultAsync(x => x.Id == request.Id)` rồi trả nguyên detail — **không đụng `currentUser`**
|
||||
(handler thậm chí không inject `ICurrentUser`, `:469`).
|
||||
- ⇒ Bất kỳ user nào có `KeHoachKyKet.Read` đọc được TOÀN BỘ phiếu của người khác (mọi phòng): dòng giá
|
||||
từng NCC (`PeReferenceAmount`/`ProposedAmount`), căn cứ b.8-9, danh sách file, tên người soạn — **kể cả phiếu NHÁP**.
|
||||
- 🔴 Không phải "khuôn repo vốn thế": `PurchaseEvaluationFeatures.cs:877-899` (module mà lane này khai là
|
||||
mirror) CÓ guard detail đầy đủ — `isDrafter ∥ eligiblePhases ∥ isV2Approver`, cộng carve-out
|
||||
"[S89] Nháp = RIÊNG TƯ" chặn cả approver xem nháp. KHKK thả trọn ⇒ **thụt lùi so với chuẩn đã chốt S22/S89.**
|
||||
- Bất đối xứng tự-mâu-thuẫn: list bịt (`:619`), `/deleted` bịt (`:749`), detail mở ⇒ rào list chỉ còn là UX.
|
||||
- Fix 1 dòng: inject `ICurrentUser` + áp đúng vị ngữ của list (`isOwner ∥ userWfIds.Contains(wfId) && Phase != DangSoanThao`)
|
||||
→ `ForbiddenException`.
|
||||
|
||||
### F-9 🟠 MAJOR — Download/View file cũng KHÔNG có rào (cùng lỗ với F-8)
|
||||
- `DownloadContractSigningPlanAttachmentQueryHandler` (`:1028-1038`) chỉ khớp `AttachmentId + ContractSigningPlanId`,
|
||||
không kiểm người gọi. Có `KeHoachKyKet.Read` + 2 GUID là tải được file scan của phiếu bất kỳ.
|
||||
- Cùng gốc F-8; vá F-8 mà quên đường này thì file vẫn hở (2 site).
|
||||
|
||||
### F-10 🟡 MINOR (kế thừa, KHÔNG phải hồi quy) — Upload/Delete attachment không kiểm owner
|
||||
- `Upload...Handler:951-966` và `Delete...Handler:1048-1053` chỉ kiểm "attachment thuộc đúng phiếu",
|
||||
KHÔNG kiểm owner/approver ⇒ ai có `KeHoachKyKet.Update` cũng xoá được file của phiếu người khác, MỌI phase
|
||||
(kể cả `DaDuyet`).
|
||||
- Đối chứng công bằng: `PurchaseEvaluationAttachmentFeatures.cs:153-183` (PE) **cũng không có** guard này
|
||||
⇒ đây là lỗ KẾ THỪA của khuôn, không phải lỗi mới của W2. Ghi để lead quyết (mở module mới là dịp bịt).
|
||||
|
||||
---
|
||||
|
||||
## §3 — TRỤC 3: Attachment handler
|
||||
|
||||
| Điểm kiểm | Kết quả |
|
||||
|---|---|
|
||||
| `DossierItemId` thuộc đúng phiếu (không FK vật lý) | ✔ CÓ tự kiểm `:961-966` `AnyAsync(d.Id == did && d.ContractSigningPlanId == plan.Id)` → `NotFoundException` |
|
||||
| Path sanitize | ✔ `SanitizeFileName:1007-1015` = `Path.GetFileName` (chặt `../`, chặt cả `C:\`) + thay `GetInvalidFileNameChars` + `TrimStart('.')` + cắt 200. Path lưu = `contract-signing-plans/{planId}/{attId}_{safe}` ⇒ không traversal |
|
||||
| Whitelist MIME + size | ✔ validator `:916-942` (8 type, 20 MB) + `[RequestSizeLimit(25_000_000)]` controller `:144` |
|
||||
| Xoá row mềm / file cứng | ✔ có chủ đích, mirror PE `:181-182`, best-effort try-catch |
|
||||
|
||||
### F-11 🟡 MINOR — `/view` tự ghép header `Content-Disposition` với FileName THÔ (chưa mã hoá)
|
||||
- `ContractSigningPlansController.cs:176`: `Response.Headers["Content-Disposition"] = $"inline; filename=\"{f.FileName}\""`.
|
||||
- `f.FileName` = `att.FileName` = **tên gốc client gửi** (`Features:979` lưu `request.FileName`, KHÔNG lưu bản đã sanitize).
|
||||
- ⇒ (a) tên file tiếng Việt có dấu → header non-ASCII (Kestrel encode Latin-1 ⇒ tên hỏng, hoặc ném);
|
||||
(b) tên chứa `"` → header vỡ cú pháp. Đường `/download` (`:167` `File(stream, ct, fileName)`) KHÔNG dính vì
|
||||
MVC tự mã hoá RFC 6266 `filename*`.
|
||||
- Fix 1 dòng: dựng bằng `new ContentDispositionHeaderValue("inline"){ FileNameStar = f.FileName }` thay chuỗi nội suy.
|
||||
|
||||
---
|
||||
|
||||
## §4 — TRỤC 4: Picker predicate ⟂ 2 rào Create
|
||||
|
||||
Create rào (`:312-331`): (i) `Phase == DaDuyet` · (ii) `Any(IsWinner)` · (iii) `!Any(plan.Phase != TuChoi)` · (iv) workflow type.
|
||||
Picker (`:1165-1168`): `e.Phase == DaDuyet` **∧** `Any(sw.IsWinner)` **∧** `!Any(pl.Phase != TuChoi)`.
|
||||
⇒ **KHỚP đủ 3 rào phụ thuộc PE** (rào iv thuộc lựa chọn workflow, không thuộc PE) — ✔ không mời user bấm vào 409.
|
||||
Soft-delete: cả 2 phía đọc `db.ContractSigningPlans` qua global filter ⇒ phiếu xoá mềm không tính ở CẢ HAI ⇒
|
||||
acceptance "DELETE → tạo lại → 201" đi lọt cả picker lẫn Create. ✔
|
||||
|
||||
### F-12 🟡 MINOR — Picker HẸP hơn Create do INNER JOIN Projects (silent-vanish, bài S147)
|
||||
- `:1160` `join p in db.Projects on e.ProjectId equals p.Id` là INNER JOIN, mà `ProjectConfiguration.cs:24`
|
||||
có `HasQueryFilter(!IsDeleted)` ⇒ PE thuộc dự án đã xoá mềm **biến mất khỏi picker** trong khi Create vẫn nhận.
|
||||
- Departments/WorkItems đã dùng `DefaultIfEmpty()` (LEFT) — chỉ Projects là INNER. Lệch hướng an toàn
|
||||
(thiếu chứ không thừa) nên MINOR, nhưng là đúng lớp "vắng-mặt trông giống ổn".
|
||||
- Fix 1 dòng: LEFT-join Projects (`into pj from p in pj.DefaultIfEmpty()`) + `p != null ? p.Name : null`.
|
||||
|
||||
(đang đo tiếp — trục 5 test)
|
||||
|
||||
---
|
||||
|
||||
## §5-§7 + VERDICT — [LEAD điền on-behalf @S161: vai chết #53 sau Trục-4; 3 trục còn lại lead tự đo + toàn bộ fix đã áp]
|
||||
|
||||
**Trục-5 (test-claim): ĐẠT** — `ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy` = reflection ĐỘNG (`GetActionsWithVerb`, 0 hardcode danh sách endpoint) + neo chống-vacuous `>= 15 action` + write-verbs derive. KHÔNG phải bẫy hardcode F-C5/W1.
|
||||
**Trục-6 (boundary): ĐẠT** — `git diff --stat` ContractCodeGenerator/Contracts/PurchaseEvaluations/Migrations = RỖNG (0-diff, W2 = 0-mig).
|
||||
**Trục-7 (FE solo-đuôi lead): tự-kiểm CÓ KHAI BIAS** — build ×2 + coming-soon 0/0 + whitelist matrix additive (`1|2|3` giữ nguyên hành vi, +10); fe-admin WfView→Designer là mapping theo bản chất app (admin dựng, user xem). Chưa có mắt độc lập — reviewer wave sau soi lại được từ diff.
|
||||
|
||||
### DISPOSITION 12 FINDING (từng dòng — luật S119)
|
||||
| # | Sev | Xử @S161 |
|
||||
|---|---|---|
|
||||
| F-1 route picker | CRIT | ✅ FIX — FE → `approved-pe-awaiting-plan` (page + header hợp-đồng) |
|
||||
| F-2 `peId` body | CRIT | ✅ FIX — FE `CreateKhkkInput.peId` (D1 đứng: spec = hợp-đồng; FE hội tụ về BE) |
|
||||
| F-3 dossier body thiếu planId | CRIT | ✅ FIX — mutation inject `contractSigningPlanId: id`; type optional (form-state không cần) |
|
||||
| F-4 Sửa-đẻ-bản-sao | MAJ | ✅ FIX — `body.id ? PUT : POST` + header hợp-đồng +PUT |
|
||||
| F-5 `peTenGoiThau` | MAJ | ✅ FIX — types + 2 reader (List :223 · Detail :281) |
|
||||
| F-6 `winnerSupplierNames` | MAJ | ✅ FIX — BE DTO append-cuối + subquery names khuôn PE; FE giữ render chip |
|
||||
| F-7 `dossierItemId` | MIN | ✅ FIX — types (0 reader hiện tại, W3 khỏi vỡ) |
|
||||
| F-8 detail 0 rào | MAJ-security | ✅ FIX — `EnsureCanViewAsync` (owner ∥ Admin ∥ [rời-nháp ∧ wf-approver]; nháp riêng-tư S89) áp Get |
|
||||
| F-9 download/view 0 rào | MAJ-security | ✅ FIX — cùng helper áp Download handler (phục vụ cả 2 route) |
|
||||
| F-10 upload/delete no-owner | MIN kế-thừa | ⏸ GIỮ — lỗ CHUNG khuôn PE (đối chứng PE :153-183 cũng không có) → bịt ĐỒNG BỘ 2 module 1 đợt riêng (ghi tồn run.md) |
|
||||
| F-11 Content-Disposition thô | MIN | ✅ FIX — `ContentDispositionHeaderValue.SetHttpFileName` RFC 6266 |
|
||||
| F-12 INNER-join Projects | MIN | ✅ FIX — LEFT join + null-safe Name/Code |
|
||||
|
||||
**Sau fix:** build BE 0W/0E · suite **574/0 GIỮ** · FE build ×2 PASS + **marker trong RUỘT bundle** (awaiting-plan/peTenGoiThau/peId = 1/1/1 mỗi app — chứng build thật, không cache) · SHA-pair 4/4 giữ.
|
||||
|
||||
**VERDICT: PASS_WITH_FLAGS — 12 finding (3 CRIT · 5 MAJ · 4 MIN) → 11 FIXED cùng lượt + 1 GIỮ-có-lý-do (F-10 kế thừa khuôn). ĐỦ ĐIỀU KIỆN COMMIT.**
|
||||
|
||||
<!-- END sub-reviewer-3 · TOTAL=12 FLAG -->
|
||||
@ -0,0 +1,24 @@
|
||||
# W2-SYNTHESIS — Wave W2 KHKK: CRUD phiếu nháp + căn cứ b.8-9 + FE 3 page ×2 app (S161, 2026-07-29) — VERIFIED
|
||||
|
||||
> Lead-verified synthesis (đóng run). Chi tiết: 4 sub-md + cicd-verify cùng cấp.
|
||||
|
||||
## KẾT QUẢ: W2 SHIP PROD — cicd VERDICT=PASS 6/6, commit `6cbc6ad` (20 file, +5.020/−23)
|
||||
|
||||
- **BE:** mega-file `ContractSigningPlanFeatures.cs` (Create 4-rào + auto-Lines snapshot per-winner + MaKeHoach @CREATE + UpdateDraft null-safe + Get/List/inbox-precompute-V2/deleted + Dossier + Attachments + Delete allow-list M6 + picker) + Controller **15 action policy per-action 15/15**. 4 quyết D1-D4 lane khai → lead duyệt cả 4.
|
||||
- **FE:** 3 page + types ×2 app SHA-pair · 6 leaf `Khkk_*` hết ComingSoon · WfView 2-app-2-đường (user=matrix?type=10 whitelist+10 · admin=Designer deep-link) · root `KeHoachKyKet` → `/khkk/list`.
|
||||
- **Test:** 566 → **574/0** (+8: 2 PIN + 6 acceptance — authz reflection ĐỘNG + không-đốt-mã-khi-409 + fixture-có-interceptor).
|
||||
- **Review:** PWF **12 finding (3 CRIT · 5 MAJ · 4 MIN)** → **11 FIXED cùng lượt + 1 GIỮ** (F-10). Prod: picker trả PE thật `PE/2026/A/045` — đường ống sống end-to-end.
|
||||
|
||||
## BÀI ĐẮT NHẤT WAVE — "hợp-đồng 2-lane-song-song vỡ 6 điểm, mọi máy xanh"
|
||||
2 lane viết FE↔BE song song từ CÙNG spec ⇒ 6 điểm đứt (route picker · `peId` body · dossier planId · PUT-vs-POST · `peTenGoiThau` · `winnerSupplierNames`) mà tsc + build + 574 test đều KHÔNG thấy; F-1/F-6 còn **che nhau** (mảng rỗng do 404 làm crash chưa lộ). Lập luận D1 của BE ("FE đọc cùng spec sẽ khớp") = **lời khai là giả định, không phải phép đo** — FE thực tế theo convention repo. ⇒ Luật cho wave sau: **spec-là-hợp-đồng chưa đủ — sau khi 2 lane land phải có bước ĐỐI CHIẾU ROUTE+FIELD từng cái trên đĩa** (reviewer trục-1 chính là bước đó; cân nhắc đưa thành checklist §③ chuẩn cho W3/W5/W6/W7).
|
||||
|
||||
## An ninh: F-8/F-9 vá — "list bịt mà detail mở = rào thành UX"
|
||||
Detail + download/view 0-rào trong khi twin PE có guard + luật nháp-riêng-tư S89 ⇒ `EnsureCanViewAsync` (owner ∥ Admin ∥ [rời-nháp ∧ wf-approver]) áp 2 site.
|
||||
|
||||
## VẬN HÀNH — #53: 4/4 lượt spawn chết, THIỆT HẠI 0 BYTE
|
||||
BE 16.5KB sub-md (tự mở 5 anchor + grep-cùng-lớp 3 site khi lead giao 1) · FE 4.5KB + 4 file land · test 3KB + 8 test land · reviewer 13.9KB ghi-từng-trục · cicd 7.9KB tới PHÉP 4. Mọi phần đuôi = lead solo theo sub-md. Ghi-đĩa-từng-mục tiếp tục là biến số quyết định.
|
||||
|
||||
## TỒN SANG WAVE SAU
|
||||
1. **F-10** upload/delete-attachment no-owner-check = lỗ CHUNG khuôn PE → bịt ĐỒNG BỘ 2 module 1 đợt riêng — **cần anh gật** (đụng PE prod).
|
||||
2. Dựng tay workflow type-3 + type-10 3-trạm qua Designer (trước W5; type-10 giờ dựng được — prod đã nhận type 10, picker có PE chờ).
|
||||
3. Tài khoản UAT non-Admin (carry từ W1).
|
||||
30
.claude/workflows/runs/2026-07-29-S161-khkk-w3-duyet/run.md
Normal file
30
.claude/workflows/runs/2026-07-29-S161-khkk-w3-duyet/run.md
Normal file
@ -0,0 +1,30 @@
|
||||
# RUN — W3 KHKK: Duyệt 3 trạm + finalize chốt giá (S161, 2026-07-29) 🔴 wave nặng nhất
|
||||
|
||||
> Wave 3/6. Spec: `../2026-07-29-S160-khkk-dryrun-plan/spec-wave-w3-duyet-3-tram-29-07-2026.md`. W1 `a8027c0` + W2 `6cbc6ad` ĐÃ SHIP (cicd PASS 6/6 ×2).
|
||||
> **Gate 5-anchor PASS 5/5 @S161** (ApproveV2Async:217 [🔴 path = Infrastructure/Services] · OR-of-N :259-260 · UPSERT :292-296 + "(duyệt — không ý kiến)" · ApprovalWorkflow.cs :95/:116-125 · RULE khuôn ContractFeatures :356-360).
|
||||
> 🔴 **TEST-BEFORE BẮT BUỘC** (critical algo OR-of-N) ⇒ 3 đợt: skeleton → 6 test RED → implement GREEN.
|
||||
> 🔴 **HỢP-ĐỒNG TRANSITIONS (LEAD định nghĩa — bài W2 6-điểm-đứt; 2 lane implement đúng LITERAL):**
|
||||
> `POST /api/contract-signing-plans/{id}/transitions` · policy `KeHoachKyKet.Update`
|
||||
> body `{ "action": "submit"|"approve"|"return"|"reject", "comment": string|null }`
|
||||
> response 200 `{ "phase": int, "currentWorkflowStepIndex": int|null, "currentApprovalLevelOrder": int|null }`
|
||||
|
||||
## taskList snapshot
|
||||
|
||||
| # | Stage | Vai | Trạng thái |
|
||||
|---|---|---|---|
|
||||
| 1 | Đợt-1a: BE SKELETON — interface `IContractSigningPlanWorkflowService.TransitionAsync` + impl throw-stub + DI + endpoint transitions (build xanh, 0 logic) | hmw `implementer-backend` | [x] `wf_ef4d05a8-98c` #53 nhưng sub-md 14KB TRỌN 7 mục — skeleton + rào-FK-547-bằng-vắng-dep + **tự đối-chứng chéo FE 5/5** + bàn giao ctor-CUỐI; 3 quyết lead DUYỆT cả 3, case-insensitive GIỮ. Build 0W/0E + 574/0 giữ (lane đo §4) |
|
||||
| 2 | Đợt-1b: FE panel ×2 app — workflow panel ✓/●/○ + banner đến-lượt-bạn + nút Gửi/Duyệt/Trả/Từ-chối + ô ý kiến (POST theo HỢP-ĐỒNG trên) | hmw `implementer-frontend` | [x] cùng wf #53 — panel land + wiring :324 ×2 app + types cây-workflow-thô (FE tự suy status từ con-trỏ đôi) + GIỮ/BỎ tường minh (BỎ đủ 2 đường cấm); **lead verify: build ×2 + marker `transitions`×3 bundle tươi + SHA-pair 3/3** |
|
||||
| 3 | Đợt-2: test-specialist — **6 test SÀN tên PIN** (spec §③-B) chạy **RED** (skeleton throw) + record RED output | hmw `test-specialist` | [!] `wf_445f7e28-38b` ĐANG CHẠY |
|
||||
| 4 | Đợt-3: BE IMPLEMENT — copy ApproveV2Async:217-394 adapt 3 chỗ (choke-point 2-write-path + KHÔNG port 2 đường sớm + TraLai) + LogSigningPlanTransition KHKK-riêng (CẤM IChangelogService — FK-547) + guard trình + notify Cấp-kế → **6 test GREEN** | hmw `implementer-backend` | [ ] |
|
||||
| 5 | Lead: suite + build ×2 + acceptance sweep | lead | [ ] |
|
||||
| 6 | Reviewer adversarial (transitions = financial/security) | `reviewer` | [ ] |
|
||||
| 7 | Commit + push + cicd | lead + `cicd-monitor` | [ ] |
|
||||
|
||||
## Ràng buộc
|
||||
- 🔴 CẤM copy ProposalFeatures (flatten :427-439 phá OR-of-N) — nguồn = ContractWorkflowService.
|
||||
- 🔴 Choke-point helper `ApplyApprovedValuesOnFinalize` gọi CẢ 2 write-path (duyệt-cấp-cuối + admin-override :190-193 gán-qua-biến) + RULE-comment khuôn :356-360.
|
||||
- 🔴 CẤM `IChangelogService.LogWorkflowTransitionAsync` (FK ContractId Cascade → 547 runtime, compiler không chặn).
|
||||
- 🔴 KHÔNG port AllowApproverFinalize/SkipToFinal/CeoApprovalThreshold (anh phán "PHÁ VỠ" S155; W1 đã rào 3 tầng).
|
||||
|
||||
## Stages
|
||||
(cập nhật theo tiến độ)
|
||||
@ -0,0 +1,137 @@
|
||||
# sub-implementer-backend-0 — S161 W3 ĐỢT-1 SKELETON (test-before, 0 logic)
|
||||
|
||||
> Ghi TỪNG MỤC trong lúc làm (bài #53: chỉ thứ trên đĩa mới sống).
|
||||
> Scope MINE: `Application/ContractSigningPlans/Services/` + `Infrastructure/Services/` + `Infrastructure/DependencyInjection.cs` + `Api/Controllers/ContractSigningPlansController.cs`.
|
||||
> KHÔNG chạm: `tests/**` (test-specialist) · `fe-*/**` (implementer-frontend) · slnx · commit/push.
|
||||
|
||||
---
|
||||
|
||||
## §0 — AUDIT TRƯỚC KHI GÕ (đo, không đoán)
|
||||
|
||||
| Mục đo | Lệnh / anchor | Kết quả |
|
||||
|---|---|---|
|
||||
| Service KHKK workflow đã có chưa? | `grep -rn "ContractSigningPlanWorkflowService" --include=*.cs src/` | **0 hit** ⇒ scaffold sạch, không đụng file người khác |
|
||||
| Run folder | `ls .claude/workflows/runs/2026-07-29-S161-khkk-w3-duyet/` | có `run.md` (đã scaffold) — KHÔNG tạo folder |
|
||||
| Khuôn interface+DI để mirror | `IContractSigningPlanCodeGenerator.cs` (Application/ContractSigningPlans/Services/) + `DependencyInjection.cs:42` | xác nhận ĐÚNG chỗ |
|
||||
| Controller đích | `Api/Controllers/ContractSigningPlansController.cs:21` | comment sẵn: "🔴 KHÔNG có endpoint transition (submit/approve/reject/return) — đó là W3" ⇒ đợt này gỡ đúng dòng đó |
|
||||
| Enum phase | `Domain/ContractSigningPlans/ContractSigningPlanPhase.cs:8-15` | DangSoanThao=1 · ChoDuyet=2 · DaDuyet=3 · TraLai=98 · TuChoi=99 |
|
||||
| Con-trỏ đôi (field THẬT trên entity) | `Domain/ContractSigningPlans/ContractSigningPlan.cs:35-37` | `ApprovalWorkflowId` · `CurrentWorkflowStepIndex` (0-based) · `CurrentApprovalLevelOrder` (1-based) |
|
||||
| Exception có sẵn | `Application/Common/Exceptions/ApplicationException.cs:37-40` | `ConflictException(string)` — dùng cho action sai |
|
||||
| Anchor nguồn copy đợt-3 | `Infrastructure/Services/ContractWorkflowService.cs:217-394` | `:217` = `private async Task ApproveV2Async(` ✔ · `:246` GroupBy Order ✔ · `:255-266` OR-of-N (vế `allowedUserIds.Contains` ở `:259-260`) ✔ |
|
||||
|
||||
**Ghi chú anchor:** spec §③-A nói OR-of-N tại `:259-260` — đọc thực tế thì `:259` là `var allowedUserIds = pendingLevelGroup.Select(l => l.ApproverUserId).ToHashSet();` và `:260` là `if (!allowedUserIds.Contains(actorUserId.Value))`. Anchor KHỚP (không drift).
|
||||
|
||||
---
|
||||
|
||||
## §1 — Interface + record (Application) — MỤC 1 ✔
|
||||
|
||||
**File MỚI:** `src/Backend/SolutionErp.Application/ContractSigningPlans/Services/IContractSigningPlanWorkflowService.cs` (43 dòng)
|
||||
|
||||
- `IContractSigningPlanWorkflowService.TransitionAsync(Guid planId, string action, string? comment, CancellationToken ct = default)` → `Task<ContractSigningPlanTransitionResult>` — ĐÚNG hợp-đồng lead.
|
||||
- `record ContractSigningPlanTransitionResult(int Phase, int? CurrentWorkflowStepIndex, int? CurrentApprovalLevelOrder)` — đúng 3 field response, không thừa.
|
||||
- Đặt cạnh `IContractSigningPlanCodeGenerator.cs` cùng thư mục `Services/` (khuôn đã có).
|
||||
|
||||
**QUYẾT-1 (khai để lead phân xử): `Phase` để kiểu `int`, KHÔNG `ContractSigningPlanPhase`.**
|
||||
Lý do: hợp-đồng chốt `"phase": int`. Đo được: `Api/Program.cs:33-34` `AddJsonOptions` CHỈ thêm `UtcDateTimeJsonConverter`, KHÔNG đăng ký `JsonStringEnumConverter` và KHÔNG đổi `PropertyNamingPolicy` (grep `JsonStringEnumConverter` toàn `SolutionErp.Api` = 0 hit) ⇒ hôm nay enum vẫn ra số. Nhưng nếu ngày nào đó ai thêm converter enum thì MỌI enum lật sang chuỗi và hợp-đồng vỡ ÂM THẦM. Ép `int` tại nguồn = rào chống hồi-quy đó.
|
||||
|
||||
**Camel-case bind:** không có `PropertyNamingPolicy` override ⇒ mặc định ASP.NET Core camelCase ⇒ `Phase`→`"phase"`, `CurrentWorkflowStepIndex`→`"currentWorkflowStepIndex"`, `CurrentApprovalLevelOrder`→`"currentApprovalLevelOrder"`; body `"action"/"comment"` bind vào `Action`/`Comment`. Khớp literal 3/3 + 2/2.
|
||||
|
||||
---
|
||||
|
||||
## §2 — Service skeleton (Infrastructure) — MỤC 2 ✔
|
||||
|
||||
**File MỚI:** `src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs`
|
||||
|
||||
- Rào action: `AllowedActions` HashSet `{submit,approve,return,reject}` (`StringComparer.OrdinalIgnoreCase`) → sai ⇒ `throw new ConflictException("Hành động không hợp lệ.")` — ĐỨNG TRƯỚC mọi truy vấn DB.
|
||||
- 4 nhánh `switch` TÁCH RIÊNG, mỗi nhánh `throw new NotImplementedException("W3 đợt-3: " + action)` + 1 dòng ghi rõ đợt-3 phải điền gì (submit: guard trình + đặt con-trỏ (1,1) + notify Cấp 1 · approve: OR-of-N + UPSERT + tiến con-trỏ + finalize · return: TraLai · reject: TuChoi). `default:` ném Conflict (switch đóng kín).
|
||||
- **Comment đầu file = hợp-đồng đợt-3** (yêu cầu mục 2): nguồn copy `ContractWorkflowService.cs:217-394`; CẤM Proposal-flatten; 4 điểm GIỮ (con-trỏ đôi `ContractSigningPlan.cs:35-37` · GroupBy `:246` · OR-of-N `:255-266` · UPSERT `:292-316`); 3 chỗ ADAPT (a) finalize **2 write-path** kể cả admin-override `:190-193` gán Phase QUA BIẾN + khuôn RULE `ContractFeatures.cs:356-360` (KHÔNG dùng `PEWS:1008` vì dòng đó tự-đếm sai) (b) KHÔNG port 2 nhánh kết thúc sớm (c) reject → TraLai toàn phần, cờ 4 return-mode ở `ApprovalWorkflow.cs:116-125` (class `ApprovalWorkflowLevel` trong file đó từ `:95`); CẤM `IChangelogService.LogWorkflowTransitionAsync` (FK-547, `ContractChangelogConfiguration.cs:22-26`).
|
||||
|
||||
**QUYẾT-2: 4 dependency `(IApplicationDbContext db, ICurrentUser currentUser, IDateTime dateTime, INotificationService notifications)` — khai từ đợt-1 dù đợt-1 chưa dùng.**
|
||||
- `ICurrentUser` thay cặp `(actorUserId, actorRoles)` của khuôn Contract/PE, **bắt buộc** vì chữ ký lead chốt KHÔNG có tham số actor. Test dựng được: khuôn `FakeCurrentUser` có sẵn ≥10 file test (vd `PeCcmBudgetPeriodSetterAuthzTests.cs:30`).
|
||||
- **KHÔNG** `UserManager<User>`: role lấy từ `ICurrentUser.Roles`, phòng ban từ `db.Users` (`IApplicationDbContext.cs:29`) ⇒ test khỏi kéo `IdentityFixture`.
|
||||
- **KHÔNG** `IChangelogService` — CỐ Ý: không inject thì không lỡ tay gọi hàm gây FK-547 (rào bằng thiết-kế, không bằng trí nhớ).
|
||||
- **KHÔNG** codegen — mã KHKK gen ngay lúc TẠO phiếu (W2), không gen lúc trình.
|
||||
|
||||
**QUYẾT-3: `#pragma warning disable CS9113` bao quanh class (2 dòng, đợt-3 xoá).**
|
||||
Đo được: build lượt-1 ra **4 warning CS9113** `Parameter 'db'/'currentUser'/'dateTime'/'notifications' is unread` (dòng 71-74). 2 đường xử lý:
|
||||
- (i) bỏ tham số cho sạch warning ⇒ test-specialist phải dựng `new ContractSigningPlanWorkflowService()` hôm nay rồi đợt-3 sửa lại **file tests** — mà tests KHÔNG phải phần việc lane này (sửa chéo);
|
||||
- (ii) giữ chữ ký cuối + tắt đúng mã CS9113 đúng phạm vi class.
|
||||
Chọn (ii) vì mục đích test-before là **khoá chữ ký**. Bằng chứng khuôn dựng thẳng: `tests/.../Services/ContractWorkflowServiceApproveV2Tests.cs:42` `new ContractWorkflowService(db, codeGen, dt, notify, changelog, um)`.
|
||||
|
||||
---
|
||||
|
||||
## §3 — DI + Controller — MỤC 3, 4 ✔
|
||||
|
||||
**DI** `src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs:43-45` — `services.AddScoped<IContractSigningPlanWorkflowService, ContractSigningPlanWorkflowService>();` NGAY DƯỚI dòng codegen `:42` (đúng chỗ lead chỉ).
|
||||
|
||||
**Controller** `src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs`:
|
||||
- Primary ctor `(IMediator mediator, IContractSigningPlanWorkflowService workflow)` + `using SolutionErp.Application.ContractSigningPlans.Services;`.
|
||||
- `POST {id:guid}/transitions` + `[Authorize(Policy = "KeHoachKyKet.Update")]` → `workflow.TransitionAsync(id, body.Action, body.Comment, ct)` → `Ok(result)`.
|
||||
- Body record `ContractSigningPlanTransitionBody(string Action, string? Comment = null)`.
|
||||
- **Không try-catch** (GlobalExceptionMiddleware map → ProblemDetails).
|
||||
- **KHÔNG kiểm `id != cmd.Id`** như các endpoint dossier-item: body transition KHÔNG mang id ⇒ 1 nguồn sự thật là route.
|
||||
|
||||
**Sửa comment STALE (bài S116/S122):** header controller `:21` cũ ghi *"🔴 KHÔNG có endpoint transition (submit/approve/reject/return) — đó là W3"* — sau đợt này là SAI. Đã thay bằng đính chính + khai rõ "đợt-1 mới skeleton ⇒ endpoint trả 500 tới khi đợt-3 điền". Grep cùng-lớp `grep -rn "đó là W3"` phần dưới §5 để chắc không còn site nào khác.
|
||||
|
||||
---
|
||||
|
||||
## §4 — BUILD + TEST (số đo thật) — MỤC 5 ✔
|
||||
|
||||
| Lượt | Lệnh | Kết quả |
|
||||
|---|---|---|
|
||||
| Build #1 | `dotnet build SolutionErp.slnx` | **Build succeeded · 0 Error · 4 Warning** (CS9113 ×4, dòng 71-74 `ContractSigningPlanWorkflowService.cs`) — đo được, không đoán |
|
||||
| Vá | thêm `#pragma warning disable/restore CS9113` bao class (2 dòng, có comment "đợt-3 xoá") | — |
|
||||
| Build #2 | `dotnet build SolutionErp.slnx` | **Build succeeded · 0 Warning(s) · 0 Error(s)** · 30.02s · 6/6 project (Domain, Application, Infrastructure, Api, Domain.Tests, Infrastructure.Tests) |
|
||||
| Test | `dotnet test SolutionErp.slnx` | **Domain 45/45 pass · Infrastructure 529/529 pass · Failed 0 · Skipped 0** ⇒ tổng **574** = ĐÚNG baseline (45D + 529I). Skeleton KHÔNG phá gì. |
|
||||
|
||||
---
|
||||
|
||||
## §5 — Grep verify sau khi vá (soi lại bằng lệnh KHÁC, bài S146)
|
||||
|
||||
**A. Stale-comment cùng-lớp** — `grep -rn "đó là W3\|KHÔNG có endpoint transition" --include=*.{cs,ts,tsx,md} src/ fe-admin/src fe-user/src`
|
||||
→ đúng **1 hit** và là dòng ĐÍNH CHÍNH mình vừa viết (`ContractSigningPlansController.cs:22`), 0 site sai còn sót.
|
||||
|
||||
**B. Policy resolve được không** (không có trong `MenuKeys.All` ⇒ **500 lúc chạy**, không phải 403) — `grep -n "KeHoachKyKet" MenuKeys.cs` → `:42` const + **`:180` NẰM TRONG `All`** ⇒ 4 policy `KeHoachKyKet.{Read,Create,Update,Delete}` sinh runtime ⇒ `KeHoachKyKet.Update` của endpoint mới hợp lệ. ✔
|
||||
|
||||
**C. Code đã land** — `ContractSigningPlansController.cs:207` `[HttpPost("{id:guid}/transitions")]` · `:225` `record ContractSigningPlanTransitionBody` · `DependencyInjection.cs:45` `AddScoped<IContractSigningPlanWorkflowService, ...>`. ✔
|
||||
|
||||
**D. Containment (git status)** — file mình chạm ĐÚNG 4:
|
||||
- `?? src/Backend/SolutionErp.Application/ContractSigningPlans/Services/IContractSigningPlanWorkflowService.cs` (mới)
|
||||
- `?? src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs` (mới)
|
||||
- ` M src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs`
|
||||
- ` M src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs`
|
||||
+ sub-md của mình trong run-folder. **0 file `tests/**` · 0 file `fe-*` · 0 sửa `slnx` · 0 commit/push · 0 chạm agent-memory.**
|
||||
(`fe-*/khkk*`, `sub-implementer-frontend-1.md`, `.session-counter.json` trong `git status` là của **lane FE + harness**, KHÔNG phải mình.)
|
||||
|
||||
---
|
||||
|
||||
## §6 — ĐỐI CHỨNG CHÉO LANE FE (đọc-only) — hợp-đồng khớp 5/5
|
||||
|
||||
Bài W2 "6 điểm đứt" nên soi ngay thay vì chờ tích hợp. Đọc `fe-admin/src/types/khkk.ts:250-276` + `KhkkWorkflowPanel.tsx:132`:
|
||||
|
||||
| Điểm | FE (đĩa) | BE (mình) | Khớp |
|
||||
|---|---|---|---|
|
||||
| URL | `KhkkWorkflowPanel.tsx:132` `/contract-signing-plans/${plan.id}/transitions` | `[HttpPost("{id:guid}/transitions")]` trên `[Route("api/contract-signing-plans")]` | ✔ |
|
||||
| action 4 literal | `khkk.ts:257-262` `'submit'|'approve'|'return'|'reject'` | `AllowedActions` cùng 4 chuỗi | ✔ |
|
||||
| body field | `khkk.ts:266-269` `{ action, comment }` | `ContractSigningPlanTransitionBody(Action, Comment)` → camelCase | ✔ |
|
||||
| response 3 field | `khkk.ts:272-276` `phase / currentWorkflowStepIndex / currentApprovalLevelOrder` | record cùng 3 field | ✔ |
|
||||
| kiểu `phase` | `khkk.ts:273` `phase: number` (**INT**, comment FE ghi rõ) | `int Phase` (QUYẾT-1) | ✔ — 2 lane độc lập cùng chốt INT |
|
||||
|
||||
⇒ QUYẾT-1 (ép `int` thay enum) được lane FE xác nhận độc lập, không phải mình tự diễn giải.
|
||||
|
||||
---
|
||||
|
||||
## §7 — BÀN GIAO
|
||||
|
||||
**Cho test-specialist (viết 6 test RED ngay được):**
|
||||
- Dựng service: `new ContractSigningPlanWorkflowService(db, currentUser, dateTime, notifications)` — chữ ký này ĐÃ CUỐI, đợt-3 không đổi. `currentUser` dùng khuôn `FakeCurrentUser` (`PeCcmBudgetPeriodSetterAuthzTests.cs:30`).
|
||||
- Gọi: `await svc.TransitionAsync(planId, "approve", "ý kiến", ct)` → hiện ném `NotImplementedException("W3 đợt-3: approve")` ⇒ **6 test sàn ĐỎ THẬT** (đúng ý test-before), KHÔNG đỏ vì lỗi biên dịch.
|
||||
- Action lạ (vd `"finalize"`) → `ConflictException("Hành động không hợp lệ.")` — hành vi này ĐÃ THẬT ở đợt-1, muốn test luôn cũng được.
|
||||
- `Inbox_OnlyPendingLevelApprover_SeesPlan` không chạm service này (đi qua query handler W2).
|
||||
|
||||
**Cho đợt-3 (điền logic):** đọc block comment đầu `ContractSigningPlanWorkflowService.cs` — đã chép sẵn 4 điểm GIỮ + 3 chỗ ADAPT + 2 lệnh CẤM (Proposal-flatten, `IChangelogService.LogWorkflowTransitionAsync`) kèm `file:line`. Việc đầu tiên: **xoá 2 dòng `#pragma CS9113`** (4 dep sẽ được dùng thật).
|
||||
|
||||
**Cần lead phân xử (3 quyết đã khai ở §1-§2):** QUYẾT-1 `phase` kiểu `int` · QUYẾT-2 bộ 4 dependency (đặc biệt `ICurrentUser` thay tham số actor, và **cố ý không inject `IChangelogService`** làm rào FK-547) · QUYẾT-3 pragma CS9113 tạm 2 dòng.
|
||||
|
||||
**Ambiguity còn lại (khai, không tự quyết):** rào action nhận **không phân biệt hoa/thường** (`"Submit"` cũng qua). Hợp-đồng ghi chữ thường; FE gửi chữ thường (`khkk.ts:257-262`) nên nới này KHÔNG ảnh hưởng acceptance nào — nếu lead muốn STRICT ordinal thì đổi 1 chỗ (`AllowedActions` bỏ `OrdinalIgnoreCase` + bỏ `ToLowerInvariant`).
|
||||
|
||||
@ -0,0 +1,145 @@
|
||||
# sub-implementer-backend-3 — S161 W3 ĐỢT-3 IMPLEMENT (6 test RED → GREEN)
|
||||
|
||||
> Ghi TỪNG MỤC trong lúc làm (bài #53: chỉ thứ trên đĩa mới sống).
|
||||
> Index **3** — KHÔNG đè `sub-implementer-backend-0.md` (đợt-1 skeleton).
|
||||
> Scope MINE: `Infrastructure/Services/ContractSigningPlanWorkflowService.cs` (điền thân)
|
||||
> + 1 dòng seed trong `tests/.../ContractSigningPlanApprovalTests.cs` (lead UỶ QUYỀN riêng lượt này, chỉ khi lỗi ở SEED).
|
||||
> KHÔNG chạm: `fe-*/**` · slnx · commit/push · agent-memory · store_memory.
|
||||
|
||||
---
|
||||
|
||||
## §0 — ĐO RED TRƯỚC KHI GÕ (tự chạy, không tin báo cáo)
|
||||
|
||||
`dotnet test tests/SolutionErp.Infrastructure.Tests --filter FullyQualifiedName~ContractSigningPlanApprovalTests`
|
||||
|
||||
| # | Test | RED vì | Bằng chứng |
|
||||
|---|---|---|---|
|
||||
| T1 | `ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures` | `NotImplementedException: W3 đợt-3: approve` | `ContractSigningPlanWorkflowService.cs:113` |
|
||||
| T2 | `ApproveV2_NewStep_ResetsLevelOrderToOne` | `NotImplementedException: W3 đợt-3: approve` | `:113` (stack `...Tests.cs:422`) |
|
||||
| T3 | `Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan` | `NotImplementedException: W3 đợt-3: approve` | `:113` (stack `...Tests.cs:501`) |
|
||||
| T4 | `Inbox_OnlyPendingLevelApprover_SeesPlan` | **ASSERT** — `Expected ... to be a collection with 1 item(s) ..., but found an empty collection` (`Tests.cs:574`) | KHÔNG chạm service — xem §4 |
|
||||
| T5 | `ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow` | `NotImplementedException: W3 đợt-3: approve` | `:113` |
|
||||
| T6 | `SendBack_Resubmit_RestartsFromStepOneLevelOne` | `NotImplementedException: W3 đợt-3: submit` | `:108` (stack `...Tests.cs:689`) |
|
||||
|
||||
`Failed: 6, Passed: 0, Total: 6` — khớp RED-evidence lead đưa (5 con NotImplemented + 1 con đỏ assertion).
|
||||
|
||||
---
|
||||
|
||||
## §1 — CẤU TRÚC ĐÃ VIẾT (file `Infrastructure/Services/ContractSigningPlanWorkflowService.cs`)
|
||||
|
||||
2 dòng `#pragma warning disable/restore CS9113` **ĐÃ XOÁ** — cả 4 dependency giờ dùng thật:
|
||||
`db` (mọi truy vấn) · `currentUser` (actor + roles + fallback tên) · `dateTime` (`SignedAt`/`ApprovedAt`) · `notifications` (2 đường báo).
|
||||
|
||||
| Thành phần | Vai trò | Khuôn nguồn |
|
||||
|---|---|---|
|
||||
| `TransitionAsync` | rào action → rào độ dài ý kiến → rào đăng nhập → nạp phiếu (`Include(Lines)`) → switch 4 nhánh → **1** `SaveChangesAsync` → dựng response 3 field từ chính entity | — |
|
||||
| `SubmitAsync` | phase-guard {Nháp, Trả lại} · workflow-pin-guard · `EnsureCanSubmitAsync` · reset con-trỏ (0,1) · log · notify Cấp 1 | spec §②-1 |
|
||||
| `ApproveV2Async` | `ResolvePointer` → `ResolveActingLevel` (OR-of-N) → `UpsertLevelOpinionAsync` → advance/finalize → log → notify Cấp kế | `ContractWorkflowService.cs:217-394` |
|
||||
| `ApplyApprovedValuesOnFinalize` | `ApprovedAmount ??= ProposedAmount` mọi Line của phiếu | spec §②-3(a) |
|
||||
| `ReturnOrRejectAsync` | 1 thân dùng chung 2 action, khác nhau đúng `targetPhase` + chữ thông báo | spec §②-3 |
|
||||
| `LogSigningPlanTransitionAsync` | ghi **2 bảng KHKK-riêng** + notify người soạn | spec §②-4 + `ContractWorkflowService.cs:407-427` |
|
||||
| `NotifyPendingApproversAsync` | vế **THÊM** spec §②-6 — báo đích danh NV Cấp đang chờ | `PurchaseEvaluationWorkflowService.cs:1243-1289` |
|
||||
| `ResolvePointer` / `LoadStepsAsync` / `ResolveActingLevel` / `LevelsOf` / `ResolveActorFullNameAsync` / `Truncate` | helper | `:228-266`, `:433-442` |
|
||||
|
||||
**4 điểm GIỮ của hợp-đồng — thi hành đủ 4:**
|
||||
1. Con-trỏ ĐÔI: `ResolvePointer` đọc `CurrentWorkflowStepIndex` như **INDEX vào list đã sort** (`steps[idx]`, không phải `Order`) + `CurrentApprovalLevelOrder`; sang Bước mới gán thẳng `= 1`.
|
||||
2. `GroupBy(l => l.Order)` — `ResolvePointer`, y khuôn `:246`. KHÔNG `SelectMany`/flatten (bẫy Proposal).
|
||||
3. OR-of-N — `ResolveActingLevel`: `pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId)`; có tên là qua, KHÔNG đòi đủ chữ ký cả nhóm.
|
||||
4. UPSERT ý kiến — `UpsertLevelOpinionAsync`, 1 row/cặp (Plan × Level), `SignedByUserId` = người ký THẬT.
|
||||
|
||||
**2 lệnh CẤM — tuân thủ, kiểm được:**
|
||||
- `grep -n "IChangelogService\|LogWorkflowTransitionAsync" ContractSigningPlanWorkflowService.cs` → **0 hit** (dep vẫn CỐ Ý vắng ctor = rào FK-547 bằng vắng-mặt, không bằng trí nhớ).
|
||||
- `grep -n "SelectMany" ContractSigningPlanWorkflowService.cs` → **0 hit** (không dính Proposal-flatten).
|
||||
|
||||
**KHÔNG port (khai rõ):** `AllowApproverFinalize` · `CeoApprovalThreshold` · `AllowApproverSkipToFinal` (`:318-352` nguồn) · 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) → `grep -n "AllowApprover\|CeoApprovalThreshold\|skipToFinal"` trong file = **0 hit**. W3 chỉ đường THƯỜNG (anh phán "PHÁ VỠ" @S155).
|
||||
|
||||
---
|
||||
|
||||
## §2 — 🔴 CHOKE-POINT FINALIZE: **1 SITE, KHÔNG CÓ SITE THỨ 2** (đo, không đoán)
|
||||
|
||||
Lead dặn: "nếu mày không port admin-override thì KHAI RÕ 0-site-thứ-2". Khai:
|
||||
|
||||
- **Không port** nhánh admin-override kiểu nguồn copy (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` QUA BIẾN). **Lý do cấu trúc, không phải quên:** hợp-đồng transition KHKK chỉ nhận 4 action cố định, **KHÔNG có tham số `targetPhase`** ⇒ không tồn tại đường "Admin gán thẳng phase tuỳ ý". Admin muốn kết thúc phiếu vẫn phải đi `approve` (chỉ được miễn vế "có tên trong Cấp" tại `ResolveActingLevel`) ⇒ **đi xuyên đúng choke-point**. Đó CHÍNH LÀ write-path 2 mà T3 đo — và nó xanh.
|
||||
- Đo trên đĩa (2 lệnh, khớp nhau):
|
||||
- `grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs` → **1 hit** = `ContractSigningPlanWorkflowService.cs` (nhánh terminal, ngay SAU `ApplyApprovedValuesOnFinalize`).
|
||||
- `grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs` → **2 hit** = 1 khai báo helper + 1 call-site.
|
||||
- Đối chứng toàn module: `grep -rn "Phase = ContractSigningPlanPhase\." src/Backend --include=*.cs` → 4 hit: `Features.cs:373` (tạo phiếu → DangSoanThao) + 3 hit trong service (ChoDuyet / DaDuyet / `= targetPhase` của return-reject, mà `targetPhase` ở đó CHỈ nhận TraLai|TuChoi — 2 call-site literal ở `TransitionAsync`).
|
||||
- RULE-comment dựng theo khuôn **`ContractFeatures.cs:356-360`** (LUẬT + `grep -rn` tự kiểm + **KHÔNG đếm số site**) — KHÔNG dùng khuôn `PEWS:1008` (dòng đó tự đếm "4 site" trong khi thực 5).
|
||||
- Vế `AND PlanId = @id`: bảo đảm bằng **QUAN HỆ**, không bằng nhớ viết `Where`. `plan.Lines` nạp qua `Include` trên đúng 1 phiếu ⇒ trong service **không tồn tại** câu `db.ContractSigningPlanLines.Where(...)` nào để mà quên vế lọc (`grep -n "ContractSigningPlanLines"` trong file = **0 hit**). T3 có phiếu chứng nhân `planB` bất động qua CẢ HAI write-path.
|
||||
|
||||
---
|
||||
|
||||
## §3 — SAVE-ĐIỂM-NÀO (khai theo yêu cầu lead)
|
||||
|
||||
**ĐÚNG MỘT `await db.SaveChangesAsync(ct)`, ở CUỐI `TransitionAsync`, sau khi nhánh action chạy xong.**
|
||||
|
||||
- Khác nguồn copy: `ContractWorkflowService.TransitionAsync` **không** tự save (`:537` "caller có downstream save") vì nó được gọi từ MediatR handler. Ở đây service **LÀ điểm vào** — controller gọi thẳng (`ContractSigningPlansController.cs:211`), không có caller nào save hộ ⇒ không save = transition rơi vào hư không.
|
||||
- 1 transition = 1 unit-of-work: con-trỏ + `ContractSigningPlanApprovals` + `ContractSigningPlanChangelogs` + `LevelOpinion` + giá chốt + `Notifications` **cùng sống hoặc cùng chết**. Được phép làm vậy vì `NotificationService.cs:7-8` CỐ Ý không tự `SaveChanges` ("caller's unit of work flushes both").
|
||||
- Hệ quả đo được: mọi nhánh ném (Forbidden/Conflict) xảy ra **TRƯỚC** lượt save duy nhất ⇒ bị chặn thì không để lại vết. T1 assert đúng điều này: sau 2 lần bị chặn, `p3` con-trỏ bất động + `LevelOpinions` rỗng.
|
||||
- **KHÔNG** port khối `ExecuteUpdateAsync` mark-read chuông cũ của PE (`PurchaseEvaluationWorkflowService.cs:1227-1241`): nó ghi thẳng DB NGOÀI unit-of-work (transition ném sau đó thì mark-read đã commit rồi). Ngoài spec §②-6. Khai để lead quyết có kéo về sau không.
|
||||
|
||||
---
|
||||
|
||||
## §4 — 🔧 SỬA TEST: ĐÚNG 1 THAM SỐ SEED (khai từng chỗ + lý do)
|
||||
|
||||
**Chỗ sửa duy nhất:** `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs`, phiếu `pendingLevel1` trong `Inbox_OnlyPendingLevelApprover_SeesPlan`:
|
||||
|
||||
```
|
||||
- ContractSigningPlanPhase.ChoDuyet, 0, 2, oneLine); // FAULT-INJECT tạm
|
||||
+ ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||
```
|
||||
|
||||
- **Lỗi ở SEED, không ở assert.** Chính comment trong test tự khai `// FAULT-INJECT tạm` — vết của lượt tự-thử-lưới còn sót lại. Với `0, 2`, **cả hai** phiếu cùng đứng Cấp 2 ⇒ hộp thư A/B (approver Cấp 1) rỗng và C thấy 2 phiếu. Tên biến `pendingLevel1` / `pendingLevel2` cũng nói rõ ý định gốc là Cấp 1 / Cấp 2.
|
||||
- **KHÔNG nới một assert nào** — 0 ký tự nào trong các câu `Should()` bị đổi. Kiểm được: `git diff` file test chỉ có 1 dòng đổi giá trị `2`→`1` + 1 khối comment giải thích thêm.
|
||||
- **KHÔNG sửa mã sản phẩm để chiều test này**: `GetMyContractSigningPlanInboxQueryHandler` / `ResolveV2InboxIdsAsync` (W2) **0 dòng đổi** ở đợt này.
|
||||
- Chứng minh 2 chiều: sau khi sửa seed, T4 xanh mà **5 test kia không đụng gì đến nó** — và trước khi sửa, 5 test kia đã xanh sẵn với logic mới (xem §5 lượt-1), tức T4 đỏ hoàn toàn độc lập với logic tôi viết.
|
||||
|
||||
---
|
||||
|
||||
## §5 — SỐ ĐO (build + test)
|
||||
|
||||
| Lượt | Lệnh | Kết quả |
|
||||
|---|---|---|
|
||||
| Build | `dotnet build SolutionErp.slnx` | **Build succeeded · 0 Warning(s) · 0 Error(s)** · 42.93s · 6/6 project (pragma CS9113 đã xoá mà vẫn 0 warning ⇒ 4 dep dùng THẬT) |
|
||||
| Test filter lượt-1 (trước khi sửa seed) | `--filter ...ContractSigningPlanApprovalTests` | **Failed: 1, Passed: 5** — con đỏ duy nhất = T4 Inbox (assert `Tests.cs:574`), 5 con logic mới XANH ngay lượt đầu |
|
||||
| Test filter lượt-2 (sau khi sửa seed) | như trên | **Failed: 0, Passed: 6, Total: 6** ✔ |
|
||||
|
||||
### Bảng RED → GREEN từng con
|
||||
|
||||
| # | Test | RED (đầu đợt) | GREEN | Vế acceptance được nó khoá |
|
||||
|---|---|---|---|---|
|
||||
| T1 | `ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures` | NotImplemented `:113` | ✔ | OR-of-N 4 vế (a)(b)(c)(d) + phiếu bị chặn bất động |
|
||||
| T2 | `ApproveV2_NewStep_ResetsLevelOrderToOne` | NotImplemented `:113` | ✔ | con-trỏ ĐÔI, reset Cấp ×2 lần, Order 5/15/25 ≠ index, terminal 2 con-trỏ null |
|
||||
| T3 | `Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan` | NotImplemented `:113` | ✔ | choke-point 2 write-path (approver + Admin) · `??=` giữ 300 · phiếu chứng nhân sạch |
|
||||
| T4 | `Inbox_OnlyPendingLevelApprover_SeesPlan` | **assert** (seed sai) | ✔ | đúng-Cấp / khác-Cấp / ngoài-quy-trình / người-soạn / phiếu nháp |
|
||||
| T5 | `ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow` | NotImplemented `:113` | ✔ | UPSERT 1 row · placeholder ý kiến rỗng · `SignedAt` từ `IDateTime` · latest-write-wins |
|
||||
| T6 | `SendBack_Resubmit_RestartsFromStepOneLevelOne` | NotImplemented `:108` | ✔ | trả lại toàn phần + gửi lại chạy LẠI từ (Bước 1, Cấp 1) + vòng mới là thật |
|
||||
|
||||
---
|
||||
|
||||
## §6 — FULL SUITE
|
||||
|
||||
**[LEAD điền on-behalf]** `dotnet test SolutionErp.slnx` → Domain **45/45** + Infra **535/535** = **580/0** (574 + 6, đích ĐẠT). Build 0W/0E.
|
||||
|
||||
---
|
||||
|
||||
## §7 — QUYẾT ĐỊNH KHAI ĐỂ LEAD PHÂN XỬ (không tự nuốt)
|
||||
|
||||
| # | Quyết | Vì sao | Đảo lại tốn gì |
|
||||
|---|---|---|---|
|
||||
| Q1 | Guard trình nhận **thêm** vế `DrafterUserId == actor` (spec chữ đen: `CreatedBy == actor ∨ DeptManager ∨ Admin`) | 2 cột trỏ CÙNG một người ở đường tạo W2 (`Features.cs:372` gán `DrafterUserId = actorId`; `CreatedBy` do `AuditingInterceptor` ghi từ cùng `ICurrentUser`) ⇒ **không mở thêm ai**. Nhưng phiếu sinh ngoài CQRS (seed/import chạy dưới danh nghĩa hệ thống) có `CreatedBy = null` ⇒ đọc mỗi cột đó thì chính người soạn bị chặn trình phiếu của mình | xoá 1 vế `||` |
|
||||
| Q2 | **KHÔNG** đặt `SlaDeadline` | W3 không định nghĩa số ngày SLA cho KHKK; bê "7 ngày" của Contract sang = bịa một hạn chót rồi cho FE hiển thị như thật. Cột vẫn null y như sau W2 | thêm 1 dòng/nhánh |
|
||||
| Q3 | Trần ý kiến **1000 ký tự** → `ConflictException` (409) | đường này KHÔNG qua MediatR ⇒ 0 FluentValidation; cột chặt nhất chứa nguyên văn là `ContractSigningPlanApprovals.Comment` = 1000 (`...ApprovalConfiguration.cs:26`). Để lọt = SQL Server 2628 (chuỗi bị cắt) → 500 khó hiểu. Chọn 409 cho ĐỒNG BỘ với "Hành động không hợp lệ." của đợt-1 (đúng nghĩa hơn là 400, nhưng đổi thì đổi cả 2 chỗ) | đổi sang `ValidationException` (→400) 1 chỗ |
|
||||
| Q4 | `return`/`reject` dùng **CÙNG** vế quyền với `approve` (NV Cấp đang chờ ∨ Admin) | trả lại/từ chối là quyết định của người đang giữ lượt; rào bằng CHÍNH hàm `ResolveActingLevel` ⇒ 2 đường không thể lệch nhau về sau. (Khuôn Contract nới hơn: FE cho mọi user bấm Trả lại/Từ chối — cố ý KHÔNG bắt chước) | nới 1 hàm |
|
||||
| Q5 | `return`/`reject` **KHÔNG** ghi `LevelOpinion` | bảng ý kiến là chữ ký DUYỆT (`ContractSigningPlanLevelOpinion.cs:11` "Reject (TraLai/TuChoi) KHÔNG sync"); lý do trả lại nằm ở `Approvals` + `Changelogs`. T5 phụ thuộc vế này (nếu return đẻ row thì `ContainSingle` vỡ) | — (đổi = vỡ T5) |
|
||||
| Q6 | Notify người soạn **loại** trường hợp chính họ bấm; notify approver Cấp kế **loại** actor + người soạn | mirror bài UAT S86 ở PE (`PEWS:1269-1274`): người tạo phiếu không nhận chuông "cần bạn duyệt" cho phiếu của mình | bỏ 1 vế `Where` |
|
||||
|
||||
**Ambiguity còn lại (KHÔNG tự quyết):**
|
||||
- Chuông "cần bạn duyệt" CŨ của Cấp trước không được mark-read khi phiếu rời Cấp (PE đã vá S96 bằng `ExecuteUpdateAsync`). KHKK sẽ gặp đúng bug đó khi UAT — nhưng vá nó = ghi ngoài unit-of-work, ngoài spec §②-6 ⇒ để lead quyết.
|
||||
- `ContractSigningPlanApproval.ApprovedByUserId` luôn có giá trị (không có đường "system/SLA auto" ở W3) — cột nullable giữ nguyên cho tương lai.
|
||||
|
||||
---
|
||||
|
||||
## §8 — FULL SUITE + CONTAINMENT
|
||||
|
||||
**[LEAD điền on-behalf — vai chết #53 sau §7]** Suite **580/0** (lead chạy tươi 2 nguồn khớp §6). Lead phân xử: **Q1-Q6 DUYỆT CẢ 6** (căn cứ đo được từng dòng); ambiguity mark-read-chuông-cũ → ghi TỒN UAT (mirror PE S96, vá riêng khi anh gật — ngoài unit-of-work). Grep NotImplementedException trong service = 1 hit COMMENT dòng :16 (code sạch).
|
||||
@ -0,0 +1,52 @@
|
||||
# sub-implementer-frontend-1 — W3 ĐỢT-1b: FE panel duyệt KHKK ×2 app (S161, 2026-07-29)
|
||||
|
||||
> Ghi TỪNG MỤC trong lúc làm (chống #53). Task: panel `KhkkWorkflowPanel.tsx` + gắn DetailPage + types transitions, SHA-pair ×2 app, build ×2.
|
||||
> BE logic W3 CHƯA có (task 1 skeleton song song) ⇒ bấm nút sẽ 404/500 — CHẤP NHẬN đợt này.
|
||||
|
||||
## MỤC 0 — Baseline SHA (trước khi sửa)
|
||||
|
||||
```
|
||||
340e64d0… fe-user/src/types/khkk.ts = fe-admin (PAIR OK)
|
||||
9b91606e… fe-user/src/pages/khkk/KhkkDetailPage.tsx = fe-admin (PAIR OK)
|
||||
```
|
||||
|
||||
## MỤC 1 — RECON: field THẬT của detail DTO (BE đĩa, KHÔNG suy)
|
||||
|
||||
Nguồn: `src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs`
|
||||
|
||||
| Cái | Bằng chứng | Ghi chú |
|
||||
|---|---|---|
|
||||
| `ContractSigningPlanDetailDto` | `:119-147` | field CUỐI = `List<ContractSigningPlanWorkflowStepDto> WorkflowSteps` `:147` |
|
||||
| `ContractSigningPlanWorkflowStepDto(Id, Order, Name, DepartmentId, Levels)` | `:112-117` | KHÔNG có `DepartmentName`, KHÔNG có `Status` |
|
||||
| `ContractSigningPlanWorkflowLevelDto(Id, Order, Name, ApproverUserId, ApproverFullName)` | `:105-110` | KHÔNG có `Status`, KHÔNG có `allowApproverFinalize` (W1 rào type-10) |
|
||||
| `ContractSigningPlanLevelOpinionDto` | `:93-103` | có `ApproverUserId` (`:99`) — FE types W2 **THIẾU** field này ⇒ bổ sung |
|
||||
| con-trỏ đôi | `:136-137` `CurrentWorkflowStepIndex` + `CurrentApprovalLevelOrder` | |
|
||||
| Tree build | `:537-566` | Steps sort `Order`, Levels sort `Order`, `ApproverFullName` join Users `:549-552` |
|
||||
|
||||
🔴 **KHÁC PE CĂN BẢN — đây là điểm dễ đứt nhất của wave này:** PE trả `approvalFlow` đã có sẵn
|
||||
`status: 'Done'|'Current'|'Pending'` per Bước/Cấp (BE precompute). **KHKK trả CÂY THÔ, KHÔNG status**
|
||||
⇒ FE **phải tự suy** trạng thái từ con-trỏ đôi. Không có field `status` nào để đọc.
|
||||
|
||||
🔴 **Ngữ nghĩa con-trỏ (BE tự khai, `:224-226`):** `CurrentWorkflowStepIndex` là **INDEX 0-based** vào
|
||||
list Steps đã sort theo Order — **KHÔNG phải Order**. `CurrentApprovalLevelOrder` là **GIÁ TRỊ** `Level.Order`.
|
||||
Luật so khớp OR-of-N canonical BE `:259-260`:
|
||||
`steps[idx].Levels.Any(l => l.Order == CurrentApprovalLevelOrder && l.ApproverUserId == userId)`
|
||||
⇒ FE mirror ĐÚNG vị ngữ này (nhiều Level **cùng Order** = OR-of-N; lấy `.some()`, KHÔNG `.find()` 1 người).
|
||||
|
||||
Rào XEM BE `:184-195` (`EnsureCanViewAsync`): Admin ∨ drafter ∨ (phase ≠ DangSoanThao ∧ approver-của-workflow-đã-pin).
|
||||
⇒ phiếu **Nháp** approver KHÔNG đọc được; **TraLai** thì đọc được.
|
||||
|
||||
Endpoint transitions: **CHƯA có trong controller** (`ContractSigningPlansController.cs` grep `HttpPost` → chỉ
|
||||
`/`, `/{id}/dossier-items`, `/{id}/attachments`). Đúng như đề bài: đợt-1 FE đi trước BE.
|
||||
|
||||
## MỤC 2 — Khuôn PE panel: cái GIỮ / cái BỎ
|
||||
|
||||
Nguồn `fe-user/src/components/pe/PeWorkflowPanel.tsx` (1063 dòng).
|
||||
|
||||
GIỮ (mirror): sơ đồ Bước→Cấp ✓/●/○ `:381-478` · banner đến-lượt `:489-508` (`actorInV2Level`/`blockedByV2Level`
|
||||
`:109-114`) · nút 3 màu emerald/amber/red `:546-562` · Dialog + ô ý kiến `:869-870` · `useAuth()` lấy actor `:66-67`
|
||||
· `qc.invalidateQueries` + `toast.error(getErrorMessage(e))` `:288`.
|
||||
|
||||
BỎ (đơn-giản-hoá theo lệnh): price-picker `:794-820` · budget/pendingPriors `:170-180` · file-đính-kèm-khi-duyệt
|
||||
`:823-868` · 4 return-mode radio `:635-713` (W3 = TraLai toàn phần, spec §②-3(c)) · `skipToFinal`/`finalizeByCcm`/
|
||||
`applyLevelFinalize` `:57-63` (spec §②-3(b): **CẤM** port 2 đường kết thúc sớm) · xóa-mềm-bởi-approver `:295-312`.
|
||||
@ -0,0 +1,54 @@
|
||||
# sub-test-specialist-2 — S161 W3 ĐỢT-2 TEST-BEFORE (6 test sàn, kỳ vọng ĐỎ)
|
||||
|
||||
> Ghi TỪNG MỤC trong lúc làm (bài #53: chỉ thứ trên đĩa mới sống).
|
||||
> Scope MINE: `tests/SolutionErp.Infrastructure.Tests/Application/ContractSigningPlanApprovalTests.cs` (file MỚI).
|
||||
> KHÔNG chạm: `src/Backend/**` · `fe-*/**` · slnx · commit/push · MEMORY.md.
|
||||
|
||||
⚠️ **Tên file sub-md:** lệnh lead (khối LUẬT) ghi `sub-test-specialist-2.md`; khối RUN-TRACE
|
||||
boilerplate ghi `sub-test-specialist-0.md`. Chọn **-2** vì khớp đánh số của chính run-folder này
|
||||
(implementer-backend-**0**, implementer-frontend-**1**) và tránh đè file (bài S133: 2 invocation
|
||||
cùng role + cùng index ghi đè nhau, mất trọn nội dung). Khai ở đây để lead không phải đoán.
|
||||
|
||||
---
|
||||
|
||||
## §0 — ĐO TRƯỚC KHI GÕ (baseline TƯƠI, không tin số đề bài)
|
||||
|
||||
| Mục đo | Lệnh | Kết quả |
|
||||
|---|---|---|
|
||||
| Baseline | `dotnet test SolutionErp.slnx --nologo -v minimal -p:BuildInParallel=false -maxcpucount:1` | Domain **45/45** · Infra **529/529** · Failed **0** ⇒ **574** — KHỚP số bàn giao |
|
||||
| Chữ ký service | `IContractSigningPlanWorkflowService.cs:25-29` + `ContractSigningPlanWorkflowService.cs:78-82` | `new ContractSigningPlanWorkflowService(db, currentUser, dateTime, notifications)` · `TransitionAsync(planId, action, comment, ct)` |
|
||||
| Record trả về | `IContractSigningPlanWorkflowService.cs:38-41` | `ContractSigningPlanTransitionResult(int Phase, int? CurrentWorkflowStepIndex, int? CurrentApprovalLevelOrder)` — `Phase` là **int** (QUYẾT-1 lane BE) |
|
||||
| Skeleton ném gì | `ContractSigningPlanWorkflowService.cs:108/113/117/121` | `NotImplementedException("W3 đợt-3: " + action)` cho cả 4 nhánh ⇒ ĐỎ THẬT |
|
||||
| Inbox W2 (không chạm service) | `ContractSigningPlanFeatures.cs:725-752` + `:229-264` | `GetMyContractSigningPlanInboxQueryHandler(db, currentUser)`; non-admin qua `ResolveV2InboxIdsAsync` ⇒ **CHẠY ĐƯỢC HÔM NAY** |
|
||||
| Con-trỏ là INDEX hay Order? | `ContractSigningPlanFeatures.cs:256-260` | `steps[idx]` sau `OrderBy(s => s.Order)` ⇒ `CurrentWorkflowStepIndex` = **INDEX 0-based**, KHÔNG phải `Step.Order`. Mã W2 đã chốt ⇒ test được phép ép Step.Order ≠ index (xem §2 T2) |
|
||||
| FK bắt buộc row thật | `ApprovalWorkflowConfiguration.cs:72-75` (Level.ApproverUserId → Users **Restrict**) · `ContractSigningPlanConfiguration.cs:36-39` (Plan.ApprovalWorkflowId → ApprovalWorkflows **Restrict**) | ⇒ seed PHẢI có User thật + ApprovalWorkflow thật |
|
||||
| loose-Guid (khỏi seed) | `ContractSigningPlanConfiguration.cs:10-12` + `ContractSigningPlan.cs:24-29` | `PurchaseEvaluationId / ProjectId / DepartmentId / DrafterUserId` KHÔNG FK ⇒ **không cần seed PE/Project/Department** (khác hẳn W2) |
|
||||
| `MaKeHoach` UNIQUE | `ContractSigningPlanConfiguration.cs:28` | filtered unique ⇒ mỗi phiếu trong 1 fixture phải mã KHÁC nhau |
|
||||
| Nguồn copy đợt-3 (để biết đo cái gì) | `ContractWorkflowService.cs:246` GroupBy · `:255-266` OR-of-N + **admin bypass** · `:289-316` UPSERT · `:355-393` advance/terminal | terminal set **CẢ HAI** con-trỏ = null (`:380-381`) ⇒ test pin null tại terminal |
|
||||
| Stub notify có sẵn | `CreateContractCommandApplicableTypeTests.cs:95` `NoOpNotificationServiceApp` — **cùng namespace** `...Tests.Application` | dùng thẳng, KHÔNG đẻ stub thứ 2 |
|
||||
|
||||
**Kết luận §0:** seed W3 NHẸ hơn W2 nhiều (không PE, không Supplier, không quote) — chỉ
|
||||
`Users` + `ApprovalWorkflow(+Steps+Levels)` + `ContractSigningPlan(+Lines)`.
|
||||
|
||||
---
|
||||
|
||||
## §1 — 2 quyết định hạ tầng của file test
|
||||
|
||||
**(1) Fixture RIÊNG `KhkkWorkflowFixture` có `AuditingInterceptor`** (mirror `ContractSigningPlanCrudTests.cs:49-82` W2).
|
||||
Lý do KHÔNG dùng `IdentityFixture`: guard trình đợt-3 = `CreatedBy == actor ∨ DeptManager ∨ Admin`
|
||||
⇒ `CreatedBy` của phiếu phải là NGƯỜI THẬT. `TestApplicationDbContext` trần KHÔNG wire interceptor
|
||||
⇒ `CreatedBy` sẽ là `null` cho mọi phiếu ⇒ nhánh `CreatedBy == actor` KHÔNG BAO GIỜ đúng, test
|
||||
`submit` sẽ đỏ vì lý do sai. Seed phiếu bằng context của chính người soạn ⇒ `CreatedBy` khớp
|
||||
`DrafterUserId` (2 đường đọc, cùng 1 giá trị — đợt-3 chọn đường nào cũng đúng).
|
||||
|
||||
**(2) User seed THẲNG `db.Users.Add(...)`, KHÔNG qua `UserManager`.**
|
||||
Service đợt-3 lấy role từ `ICurrentUser.Roles` (lane BE khai QUYẾT-2: cố ý KHÔNG inject
|
||||
`UserManager`) ⇒ kéo cả Identity stack vào chỉ để có row FK là phí. Repo chưa có tiền lệ
|
||||
`db.Users.Add` trong tests (grep 0 hit) ⇒ mục này là **cái mới**, và nó chỉ hợp lệ vì
|
||||
`Level.ApproverUserId` cần *row tồn tại*, không cần *đăng nhập được*.
|
||||
|
||||
---
|
||||
|
||||
## §2 — 6 test: đo gì, bẫy cài ở đâu
|
||||
|
||||
(điền tiếp bên dưới trong lúc viết)
|
||||
@ -6,7 +6,8 @@
|
||||
// per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế
|
||||
// hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File
|
||||
// đính kèm (mở MỌI phase — triết lý PE S147).
|
||||
// 🔴 CỐ Ý KHÔNG có panel trình/duyệt + banner cấp duyệt: transitions = W3.
|
||||
// [W3 S161] +panel duyệt `KhkkWorkflowPanel` NGAY DƯỚI header (trên "Thông tin kế hoạch"):
|
||||
// sơ đồ Bước→Cấp + banner đến-lượt + Gửi duyệt / Duyệt / Trả lại / Từ chối.
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
@ -31,6 +32,7 @@ import {
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||
type UpsertKhkkDossierItemInput,
|
||||
} from '@/types/khkk'
|
||||
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
|
||||
|
||||
function formatVnd(n: number | null): string {
|
||||
if (n === null || n === undefined) return '—'
|
||||
@ -316,6 +318,11 @@ export function KhkkDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* [W3 S161] Panel duyệt — đặt NGAY dưới header để người duyệt thấy việc-phải-làm
|
||||
trước nội dung phiếu (mirror thứ tự PE: workflow panel là thứ đầu tiên trong
|
||||
màn Duyệt). `onChanged` dùng `invalidate` của trang để refetch theo id URL. */}
|
||||
<KhkkWorkflowPanel plan={k} onChanged={invalidate} />
|
||||
|
||||
{/* Section 1: Thông tin */}
|
||||
<SectionCard
|
||||
title="Thông tin kế hoạch"
|
||||
|
||||
423
fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx
Normal file
423
fe-admin/src/pages/khkk/KhkkWorkflowPanel.tsx
Normal file
@ -0,0 +1,423 @@
|
||||
// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ.
|
||||
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||
//
|
||||
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
|
||||
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
|
||||
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
|
||||
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
|
||||
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
|
||||
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
|
||||
//
|
||||
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
|
||||
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.
|
||||
// Panel này TỰ SUY trạng thái từ cặp con-trỏ:
|
||||
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order
|
||||
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
|
||||
// (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical
|
||||
// `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN
|
||||
// bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.)
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, MessageSquare, Send, Undo2, Workflow, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
KHKK_PHASE_LABELS, KhkkPhase, KhkkTransitionAction,
|
||||
type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue,
|
||||
type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto,
|
||||
} from '@/types/khkk'
|
||||
|
||||
type NodeStatus = 'Done' | 'Current' | 'Pending'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('vi-VN')
|
||||
}
|
||||
|
||||
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
|
||||
const STATUS_DOT: Record<NodeStatus, string> = {
|
||||
Done: 'bg-emerald-500 text-white',
|
||||
Current: 'bg-brand-600 text-white',
|
||||
Pending: 'bg-slate-200 text-slate-500',
|
||||
}
|
||||
|
||||
const STATUS_BOX: Record<NodeStatus, string> = {
|
||||
Done: 'border-emerald-200 bg-emerald-50/40',
|
||||
Current: 'border-brand-300 bg-brand-50/50',
|
||||
Pending: 'border-slate-200 bg-white',
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<NodeStatus, string> = {
|
||||
Done: '✓',
|
||||
Current: '●',
|
||||
Pending: '○',
|
||||
}
|
||||
|
||||
export function KhkkWorkflowPanel({
|
||||
plan,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: KhkkDetailDto
|
||||
/** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */
|
||||
onChanged?: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
const phase = plan.phase as KhkkPhaseValue
|
||||
const steps = plan.workflowSteps ?? []
|
||||
const opinions = plan.levelOpinions ?? []
|
||||
|
||||
const isWaiting = phase === KhkkPhase.ChoDuyet
|
||||
const isApproved = phase === KhkkPhase.DaDuyet
|
||||
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
||||
|
||||
const curStepIdx = plan.currentWorkflowStepIndex
|
||||
const curLevelOrder = plan.currentApprovalLevelOrder
|
||||
|
||||
// Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done;
|
||||
// Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng).
|
||||
const stepStatus = (idx: number): NodeStatus => {
|
||||
if (isApproved) return 'Done'
|
||||
if (!isWaiting || curStepIdx == null) return 'Pending'
|
||||
if (idx < curStepIdx) return 'Done'
|
||||
if (idx === curStepIdx) return 'Current'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
const levelStatus = (idx: number, order: number): NodeStatus => {
|
||||
const s = stepStatus(idx)
|
||||
if (s !== 'Current' || curLevelOrder == null) return s
|
||||
if (order < curLevelOrder) return 'Done'
|
||||
if (order === curLevelOrder) return 'Current'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này —
|
||||
// 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người.
|
||||
const currentLevels: KhkkWorkflowLevelDto[] =
|
||||
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
|
||||
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
|
||||
: []
|
||||
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
|
||||
? steps[curStepIdx]
|
||||
: null
|
||||
|
||||
const isAdmin = user?.roles?.includes('Admin') ?? false
|
||||
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
|
||||
// Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`).
|
||||
const actorInLevel = isAdmin || actorIsCurrentApprover
|
||||
const blockedByLevel = isWaiting && !actorInLevel
|
||||
|
||||
// Nút "Gửi duyệt": guard BE là `CreatedBy == actor ∨ DeptManager(cùng phòng) ∨ Admin`
|
||||
// (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles}
|
||||
// (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với
|
||||
// đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155).
|
||||
// Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
||||
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async (a: KhkkTransitionActionValue) => {
|
||||
// 🔴 BODY LITERAL theo hợp-đồng LEAD chốt: đúng 2 field `action` + `comment`.
|
||||
const body: KhkkTransitionInput = { action: a, comment: comment.trim() || null }
|
||||
const res = await api.post<KhkkTransitionResult>(
|
||||
`/contract-signing-plans/${plan.id}/transitions`,
|
||||
body,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
|
||||
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
|
||||
setAction(null)
|
||||
setComment('')
|
||||
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
|
||||
qc.invalidateQueries({ queryKey: ['khkk-list'] })
|
||||
onChanged?.()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const dialogTitle =
|
||||
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
|
||||
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
|
||||
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
|
||||
: '✗ Từ chối kế hoạch ký kết'
|
||||
// Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE).
|
||||
const commentRequired =
|
||||
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
||||
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
||||
|
||||
return (
|
||||
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Workflow className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
|
||||
{plan.workflowCode && (
|
||||
<span className="font-mono text-[11px] text-slate-500">
|
||||
{plan.workflowCode}
|
||||
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
|
||||
{steps.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, idx) => {
|
||||
const st = stepStatus(idx)
|
||||
return (
|
||||
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
|
||||
{STATUS_ICON[st]}
|
||||
</span>
|
||||
<span className="text-slate-800">Bước {step.order} — {step.name}</span>
|
||||
</div>
|
||||
{step.levels.length > 0 && (
|
||||
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
|
||||
{step.levels.map(lv => {
|
||||
const ls = levelStatus(idx, lv.order)
|
||||
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
|
||||
return (
|
||||
<li key={lv.id} className="text-[11px]">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
|
||||
{STATUS_ICON[ls]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-slate-700">
|
||||
{lv.name || `Cấp ${lv.order}`}
|
||||
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
|
||||
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
{lv.approverFullName ?? '(chưa cấu hình)'}
|
||||
{signed && (
|
||||
<span className="ml-1.5 text-[10px] text-emerald-600">
|
||||
✓ ký {formatDateTime(signed.signedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
|
||||
Quy trình duyệt chưa cấu hình Bước/Cấp — liên hệ Admin để dựng quy trình loại
|
||||
“Kế hoạch ký kết HĐ” trước khi trình duyệt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
|
||||
{isWaiting && currentStep && (
|
||||
<div className={cn(
|
||||
'rounded border px-3 py-2 text-[11px]',
|
||||
actorInLevel
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'border-amber-300 bg-amber-50 text-amber-800',
|
||||
)}>
|
||||
<div className="font-medium">
|
||||
Đang chờ Bước {currentStep.order} ({currentStep.name}) — Cấp {curLevelOrder}
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
|
||||
</div>
|
||||
{actorInLevel
|
||||
? <div className="mt-0.5 font-medium">✓ Đến lượt bạn duyệt</div>
|
||||
: <div className="mt-0.5">⚠ Không phải lượt bạn — chỉ người trên mới thao tác cấp này</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === KhkkPhase.TraLai && (
|
||||
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
|
||||
⚠ Kế hoạch bị TRẢ LẠI — sửa nội dung rồi bấm “Gửi duyệt”, quy trình chạy lại từ Bước 1 · Cấp 1.
|
||||
</div>
|
||||
)}
|
||||
{phase === KhkkPhase.TuChoi && (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
✗ Kế hoạch đã bị TỪ CHỐI — không thao tác được nữa. Lập kế hoạch mới nếu cần làm lại.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hành động */}
|
||||
{(canSubmit || isWaiting) && (
|
||||
<div>
|
||||
<Label className="text-xs">Hành động:</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{canSubmit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setComment(''); setAction(KhkkTransitionAction.Submit) }}
|
||||
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
|
||||
>
|
||||
<Send className="h-3 w-3" /> Gửi duyệt
|
||||
</button>
|
||||
)}
|
||||
{isDraftLike && !plan.approvalWorkflowId && (
|
||||
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
|
||||
Chưa pin quy trình duyệt — không gửi duyệt được.
|
||||
</span>
|
||||
)}
|
||||
{isWaiting && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Approve) } }}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
|
||||
)}
|
||||
>
|
||||
<Check className="h-3 w-3" /> Duyệt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Return) } }}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-amber-300 text-amber-700 hover:bg-amber-50',
|
||||
)}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" /> Trả lại
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-red-300 text-red-700 hover:bg-red-50',
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" /> Từ chối
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
|
||||
{opinions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
|
||||
Ý kiến cấp duyệt
|
||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||
{opinions.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1.5">
|
||||
{opinions.map(o => (
|
||||
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
|
||||
<span className="font-medium">{o.signedByFullName}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span>
|
||||
{o.stepName ? `Bước ${o.stepOrder} — ${o.stepName}` : 'Cấp duyệt'}
|
||||
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
|
||||
</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
|
||||
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
|
||||
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
|
||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
|
||||
duyệt thay
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
|
||||
{o.comment?.trim() || '(duyệt — không ý kiến)'}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action !== null && (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => setAction(null)}
|
||||
title={dialogTitle}
|
||||
footer={<>
|
||||
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
|
||||
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
|
||||
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
{action === KhkkTransitionAction.Submit && (
|
||||
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
|
||||
Kế hoạch chuyển sang “Chờ duyệt” và trình lên Bước 1 · Cấp 1 của quy trình
|
||||
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa được nữa.
|
||||
</div>
|
||||
)}
|
||||
{action === KhkkTransitionAction.Return && (
|
||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
Kế hoạch về trạng thái “Trả lại”. Người soạn sửa xong gửi lại thì quy trình chạy
|
||||
từ Bước 1 · Cấp 1.
|
||||
</div>
|
||||
)}
|
||||
{action === KhkkTransitionAction.Reject && (
|
||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
⚠ Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
|
||||
</div>
|
||||
)}
|
||||
<Label>
|
||||
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
|
||||
/>
|
||||
{action === KhkkTransitionAction.Approve && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Ý kiến sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||
thì ghi đè ô cũ).
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -20,7 +20,10 @@
|
||||
// POST /contract-signing-plans/{id}/attachments multipart { file, purpose, dossierItemId? }
|
||||
// GET /contract-signing-plans/{id}/attachments/{attId}/download
|
||||
// DELETE /contract-signing-plans/{id}/attachments/{attId}
|
||||
// (transitions submit/approve/return/reject = W3, CỐ Ý chưa có ở đây.)
|
||||
//
|
||||
// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
|
||||
// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
|
||||
// policy `KeHoachKyKet.Update`
|
||||
|
||||
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
|
||||
export const KhkkPhase = {
|
||||
@ -161,12 +164,36 @@ export interface KhkkLevelOpinionDto {
|
||||
stepOrder: number | null
|
||||
stepName: string | null
|
||||
levelOrder: number | null
|
||||
// [W3 S161] BE `ContractSigningPlanLevelOpinionDto:99` CÓ field này (W2 khai thiếu).
|
||||
// `signedByUserId !== approverUserId` ⇒ có người duyệt THAY (Admin) — panel gắn nhãn.
|
||||
approverUserId: string | null
|
||||
comment: string | null
|
||||
signedAt: string
|
||||
signedByUserId: string
|
||||
signedByFullName: string
|
||||
}
|
||||
|
||||
// [W3 S161] Cây quy trình THÔ do BE trả (`ContractSigningPlanWorkflowLevelDto:105-110`).
|
||||
// 🔴 KHÁC PE: BE **KHÔNG** precompute `status` Done/Current/Pending — FE tự suy từ cặp
|
||||
// con-trỏ `currentWorkflowStepIndex` (INDEX 0-based vào mảng steps) + `currentApprovalLevelOrder`
|
||||
// (GIÁ TRỊ `level.order`). Xem `ContractSigningPlanFeatures.cs:224-226` + `:259-260`.
|
||||
export interface KhkkWorkflowLevelDto {
|
||||
id: string
|
||||
order: number
|
||||
name: string | null
|
||||
approverUserId: string
|
||||
approverFullName: string | null
|
||||
}
|
||||
|
||||
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
|
||||
export interface KhkkWorkflowStepDto {
|
||||
id: string
|
||||
order: number
|
||||
name: string
|
||||
departmentId: string | null
|
||||
levels: KhkkWorkflowLevelDto[]
|
||||
}
|
||||
|
||||
export interface KhkkDetailDto extends KhkkListItemDto {
|
||||
workflowName: string | null
|
||||
currentWorkflowStepIndex: number | null
|
||||
@ -177,6 +204,8 @@ export interface KhkkDetailDto extends KhkkListItemDto {
|
||||
dossierItems: KhkkDossierItemDto[]
|
||||
attachments: KhkkAttachmentDto[]
|
||||
levelOpinions: KhkkLevelOpinionDto[]
|
||||
// `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
|
||||
workflowSteps: KhkkWorkflowStepDto[]
|
||||
}
|
||||
|
||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
|
||||
@ -218,6 +247,34 @@ export interface UpsertKhkkDossierItemInput {
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
// ===================== W3 — chuyển trạng thái (duyệt 3 trạm) =========================
|
||||
// 🔴 HỢP-ĐỒNG LITERAL do LEAD chốt (S161 W3). Tên field ở đây = tên field BE bind.
|
||||
// Đổi 1 chữ ở đây mà không đổi BE = đứt im lặng (bài W2 6-điểm-đứt).
|
||||
// submit = trình duyệt (DangSoanThao | TraLai → ChoDuyet)
|
||||
// approve = duyệt cấp hiện tại (OR-of-N; comment ≠ rỗng ⇒ UPSERT LevelOpinion)
|
||||
// return = trả lại (→ TraLai)
|
||||
// reject = từ chối (→ TuChoi)
|
||||
export const KhkkTransitionAction = {
|
||||
Submit: 'submit',
|
||||
Approve: 'approve',
|
||||
Return: 'return',
|
||||
Reject: 'reject',
|
||||
} as const
|
||||
export type KhkkTransitionActionValue =
|
||||
(typeof KhkkTransitionAction)[keyof typeof KhkkTransitionAction]
|
||||
|
||||
export interface KhkkTransitionInput {
|
||||
action: KhkkTransitionActionValue
|
||||
comment: string | null
|
||||
}
|
||||
|
||||
// Response 200 — 3 field, ĐÚNG tên hợp-đồng. `phase` là INT (mirror `KhkkPhase`).
|
||||
export interface KhkkTransitionResult {
|
||||
phase: number
|
||||
currentWorkflowStepIndex: number | null
|
||||
currentApprovalLevelOrder: number | null
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
// per-NCC trúng thầu (READ-ONLY ở W2 — `PeReferenceAmount` là SNAPSHOT lúc lập kế
|
||||
// hoạch) · (3) Căn cứ hồ sơ b.8-9 (CRUD chỉ mở ở DangSoanThao|TraLai) · (4) File
|
||||
// đính kèm (mở MỌI phase — triết lý PE S147).
|
||||
// 🔴 CỐ Ý KHÔNG có panel trình/duyệt + banner cấp duyệt: transitions = W3.
|
||||
// [W3 S161] +panel duyệt `KhkkWorkflowPanel` NGAY DƯỚI header (trên "Thông tin kế hoạch"):
|
||||
// sơ đồ Bước→Cấp + banner đến-lượt + Gửi duyệt / Duyệt / Trả lại / Từ chối.
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
@ -31,6 +32,7 @@ import {
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||
type UpsertKhkkDossierItemInput,
|
||||
} from '@/types/khkk'
|
||||
import { KhkkWorkflowPanel } from './KhkkWorkflowPanel'
|
||||
|
||||
function formatVnd(n: number | null): string {
|
||||
if (n === null || n === undefined) return '—'
|
||||
@ -316,6 +318,11 @@ export function KhkkDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* [W3 S161] Panel duyệt — đặt NGAY dưới header để người duyệt thấy việc-phải-làm
|
||||
trước nội dung phiếu (mirror thứ tự PE: workflow panel là thứ đầu tiên trong
|
||||
màn Duyệt). `onChanged` dùng `invalidate` của trang để refetch theo id URL. */}
|
||||
<KhkkWorkflowPanel plan={k} onChanged={invalidate} />
|
||||
|
||||
{/* Section 1: Thông tin */}
|
||||
<SectionCard
|
||||
title="Thông tin kế hoạch"
|
||||
|
||||
423
fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx
Normal file
423
fe-user/src/pages/khkk/KhkkWorkflowPanel.tsx
Normal file
@ -0,0 +1,423 @@
|
||||
// [W3 KHKK — S161 2026-07-29] Panel duyệt 3 trạm (PMH → CCM → CEO) cho Kế hoạch ký kết HĐ.
|
||||
// File MIRROR SHA256 identical với fe-admin counterpart.
|
||||
//
|
||||
// Khuôn: `components/pe/PeWorkflowPanel.tsx` — GIỮ sơ đồ Bước→Cấp ✓/●/○, banner "đến lượt bạn"
|
||||
// (`blockedByV2Level` :109-114), 3 nút màu (emerald/amber/red), Dialog + ô ý kiến.
|
||||
// ĐƠN-GIẢN-HOÁ CÓ CHỦ ĐÍCH (spec W3 §②-3): KHÔNG price-picker, KHÔNG ngân sách, KHÔNG file-khi-duyệt,
|
||||
// KHÔNG 4 return-mode (W3 = TraLai toàn phần), KHÔNG 2 đường kết-thúc-sớm (AllowApproverFinalize /
|
||||
// CeoApprovalThreshold — anh phán "PHÁ VỠ" S155; W1 đã rào ở Designer type-10). Chốt giá per-Line là
|
||||
// việc BE làm ở finalize choke-point, KHÔNG phải việc của panel này.
|
||||
//
|
||||
// 🔴 KHÁC PE Ở CHỖ ĐẮT NHẤT: PE nhận `approvalFlow` đã có `status` Done/Current/Pending do BE precompute.
|
||||
// KHKK BE trả CÂY THÔ (`ContractSigningPlanFeatures.cs:112-117` + `:105-110`) — KHÔNG có `status`.
|
||||
// Panel này TỰ SUY trạng thái từ cặp con-trỏ:
|
||||
// • `currentWorkflowStepIndex` = INDEX 0-based vào mảng `workflowSteps` (đã sort Order) — KHÔNG phải Order
|
||||
// • `currentApprovalLevelOrder` = GIÁ TRỊ `level.order`
|
||||
// (BE tự khai ngữ nghĩa này ở `ContractSigningPlanFeatures.cs:224-226`; luật so khớp OR-of-N canonical
|
||||
// `:259-260` = `steps[idx].Levels.Any(l => l.Order == cur && l.ApproverUserId == uid)` — mirror Y NGUYÊN
|
||||
// bên dưới, dùng `.some()` vì N Cấp CÙNG Order = OR-of-N, chỉ 1 người ký là tiến.)
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, MessageSquare, Send, Undo2, Workflow, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Dialog } from '@/components/ui/Dialog'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
KHKK_PHASE_LABELS, KhkkPhase, KhkkTransitionAction,
|
||||
type KhkkDetailDto, type KhkkPhaseValue, type KhkkTransitionActionValue,
|
||||
type KhkkTransitionInput, type KhkkTransitionResult, type KhkkWorkflowLevelDto,
|
||||
} from '@/types/khkk'
|
||||
|
||||
type NodeStatus = 'Done' | 'Current' | 'Pending'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString('vi-VN')
|
||||
}
|
||||
|
||||
// Pattern 14 (Tailwind JIT): chuỗi class ĐẦY ĐỦ, KHÔNG nội suy mảnh (`bg-${x}-50` bị purge).
|
||||
const STATUS_DOT: Record<NodeStatus, string> = {
|
||||
Done: 'bg-emerald-500 text-white',
|
||||
Current: 'bg-brand-600 text-white',
|
||||
Pending: 'bg-slate-200 text-slate-500',
|
||||
}
|
||||
|
||||
const STATUS_BOX: Record<NodeStatus, string> = {
|
||||
Done: 'border-emerald-200 bg-emerald-50/40',
|
||||
Current: 'border-brand-300 bg-brand-50/50',
|
||||
Pending: 'border-slate-200 bg-white',
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<NodeStatus, string> = {
|
||||
Done: '✓',
|
||||
Current: '●',
|
||||
Pending: '○',
|
||||
}
|
||||
|
||||
export function KhkkWorkflowPanel({
|
||||
plan,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: KhkkDetailDto
|
||||
/** gọi sau khi transition thành công — caller refetch detail/list theo id URL của nó. */
|
||||
onChanged?: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const [action, setAction] = useState<KhkkTransitionActionValue | null>(null)
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
const phase = plan.phase as KhkkPhaseValue
|
||||
const steps = plan.workflowSteps ?? []
|
||||
const opinions = plan.levelOpinions ?? []
|
||||
|
||||
const isWaiting = phase === KhkkPhase.ChoDuyet
|
||||
const isApproved = phase === KhkkPhase.DaDuyet
|
||||
const isDraftLike = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
|
||||
|
||||
const curStepIdx = plan.currentWorkflowStepIndex
|
||||
const curLevelOrder = plan.currentApprovalLevelOrder
|
||||
|
||||
// Suy trạng thái Bước: chỉ có nghĩa khi phiếu ĐANG chờ duyệt. DaDuyet ⇒ tất cả Done;
|
||||
// Nháp/TraLai/TuChoi ⇒ tất cả Pending (quy trình chưa chạy / đã dừng).
|
||||
const stepStatus = (idx: number): NodeStatus => {
|
||||
if (isApproved) return 'Done'
|
||||
if (!isWaiting || curStepIdx == null) return 'Pending'
|
||||
if (idx < curStepIdx) return 'Done'
|
||||
if (idx === curStepIdx) return 'Current'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
const levelStatus = (idx: number, order: number): NodeStatus => {
|
||||
const s = stepStatus(idx)
|
||||
if (s !== 'Current' || curLevelOrder == null) return s
|
||||
if (order < curLevelOrder) return 'Done'
|
||||
if (order === curLevelOrder) return 'Current'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
// 🔴 OR-of-N: mọi Cấp CÙNG `order` trong Bước hiện tại đều là người được duyệt lượt này —
|
||||
// 1 người ký là phiếu tiến (mirror BE `:259-260` `.Any(...)`). KHÔNG `.find()` một người.
|
||||
const currentLevels: KhkkWorkflowLevelDto[] =
|
||||
isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length && curLevelOrder != null
|
||||
? steps[curStepIdx].levels.filter(l => l.order === curLevelOrder)
|
||||
: []
|
||||
const currentStep = isWaiting && curStepIdx != null && curStepIdx >= 0 && curStepIdx < steps.length
|
||||
? steps[curStepIdx]
|
||||
: null
|
||||
|
||||
const isAdmin = user?.roles?.includes('Admin') ?? false
|
||||
const actorIsCurrentApprover = !!user?.id && currentLevels.some(l => l.approverUserId === user.id)
|
||||
// Admin bypass — mirror `PeWorkflowPanel.tsx:110-114` (`actorInV2Level` có `isAdmin ||`).
|
||||
const actorInLevel = isAdmin || actorIsCurrentApprover
|
||||
const blockedByLevel = isWaiting && !actorInLevel
|
||||
|
||||
// Nút "Gửi duyệt": guard BE là `CreatedBy == actor ∨ DeptManager(cùng phòng) ∨ Admin`
|
||||
// (spec W3 §②-5). FE KHÔNG suy được vế DeptManager — `UserInfo` chỉ có {id,email,fullName,roles}
|
||||
// (`types/auth.ts:1-6`), không có phòng ban. CỐ Ý **không ẩn** nút theo vai: ẩn sẽ giấu nút với
|
||||
// đúng người có quyền, im lặng, không lỗi (bug-class gotcha #44 — đã đốt 1 lần ở PE S155).
|
||||
// Rào THẬT nằm ở BE (403 → toast). Chỉ chặn ca chắc-chắn-hỏng: chưa pin quy trình.
|
||||
const canSubmit = isDraftLike && !!plan.approvalWorkflowId
|
||||
|
||||
const transition = useMutation({
|
||||
mutationFn: async (a: KhkkTransitionActionValue) => {
|
||||
// 🔴 BODY LITERAL theo hợp-đồng LEAD chốt: đúng 2 field `action` + `comment`.
|
||||
const body: KhkkTransitionInput = { action: a, comment: comment.trim() || null }
|
||||
const res = await api.post<KhkkTransitionResult>(
|
||||
`/contract-signing-plans/${plan.id}/transitions`,
|
||||
body,
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
const label = KHKK_PHASE_LABELS[res.phase as KhkkPhaseValue] ?? `Phase ${res.phase}`
|
||||
toast.success(`Đã cập nhật — trạng thái: ${label}.`)
|
||||
setAction(null)
|
||||
setComment('')
|
||||
qc.invalidateQueries({ queryKey: ['khkk-detail', plan.id] })
|
||||
qc.invalidateQueries({ queryKey: ['khkk-list'] })
|
||||
onChanged?.()
|
||||
},
|
||||
onError: (e) => toast.error(getErrorMessage(e)),
|
||||
})
|
||||
|
||||
const dialogTitle =
|
||||
action === KhkkTransitionAction.Submit ? '➤ Gửi duyệt kế hoạch ký kết'
|
||||
: action === KhkkTransitionAction.Approve ? '✓ Duyệt kế hoạch ký kết'
|
||||
: action === KhkkTransitionAction.Return ? '← Trả lại người soạn sửa'
|
||||
: '✗ Từ chối kế hoạch ký kết'
|
||||
// Trả lại / Từ chối bắt buộc nêu lý do (ghi vào ý kiến cấp duyệt + changelog BE).
|
||||
const commentRequired =
|
||||
action === KhkkTransitionAction.Return || action === KhkkTransitionAction.Reject
|
||||
const confirmDisabled = transition.isPending || (commentRequired && !comment.trim())
|
||||
|
||||
return (
|
||||
<section className="card-accent" style={{ ['--accent' as string]: 'var(--color-emerald-500)' }}>
|
||||
<header className="flex items-center gap-2 border-b border-slate-100 px-5 py-3">
|
||||
<span
|
||||
className="icon-chip"
|
||||
style={{ height: '1.75rem', width: '1.75rem', ['--chip-bg' as string]: 'var(--color-emerald-50)', ['--chip-fg' as string]: 'var(--color-emerald-600)' }}
|
||||
aria-hidden
|
||||
>
|
||||
<Workflow className="h-4 w-4" />
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold tracking-tight text-emerald-700">Quy trình duyệt</h3>
|
||||
{plan.workflowCode && (
|
||||
<span className="font-mono text-[11px] text-slate-500">
|
||||
{plan.workflowCode}
|
||||
{plan.workflowName && <span className="font-sans"> · {plan.workflowName}</span>}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="space-y-4 p-5">
|
||||
{/* Sơ đồ Bước → Cấp. ✓ Done (emerald) / ● Current (brand) / ○ Pending (slate) */}
|
||||
{steps.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, idx) => {
|
||||
const st = stepStatus(idx)
|
||||
return (
|
||||
<li key={step.id} className={cn('rounded-md border p-2', STATUS_BOX[st])}>
|
||||
<div className="flex items-center gap-2 text-xs font-medium">
|
||||
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-bold', STATUS_DOT[st])}>
|
||||
{STATUS_ICON[st]}
|
||||
</span>
|
||||
<span className="text-slate-800">Bước {step.order} — {step.name}</span>
|
||||
</div>
|
||||
{step.levels.length > 0 && (
|
||||
<ul className="ml-7 mt-1.5 space-y-1 border-l-2 border-violet-200 pl-2">
|
||||
{step.levels.map(lv => {
|
||||
const ls = levelStatus(idx, lv.order)
|
||||
const signed = opinions.find(o => o.approvalWorkflowLevelId === lv.id)
|
||||
return (
|
||||
<li key={lv.id} className="text-[11px]">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className={cn('mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold', STATUS_DOT[ls])}>
|
||||
{STATUS_ICON[ls]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-slate-700">
|
||||
{lv.name || `Cấp ${lv.order}`}
|
||||
{ls === 'Current' && <span className="ml-1.5 text-[10px] font-normal text-brand-700">đang chờ</span>}
|
||||
{ls === 'Done' && <span className="ml-1.5 text-[10px] font-normal text-emerald-600">đã duyệt</span>}
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
{lv.approverFullName ?? '(chưa cấu hình)'}
|
||||
{signed && (
|
||||
<span className="ml-1.5 text-[10px] text-emerald-600">
|
||||
✓ ký {formatDateTime(signed.signedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] text-slate-600">
|
||||
Quy trình duyệt chưa cấu hình Bước/Cấp — liên hệ Admin để dựng quy trình loại
|
||||
“Kế hoạch ký kết HĐ” trước khi trình duyệt.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banner "đến lượt bạn" — khuôn PeWorkflowPanel:489-508. Chỉ có nghĩa khi ĐANG chờ duyệt. */}
|
||||
{isWaiting && currentStep && (
|
||||
<div className={cn(
|
||||
'rounded border px-3 py-2 text-[11px]',
|
||||
actorInLevel
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-800'
|
||||
: 'border-amber-300 bg-amber-50 text-amber-800',
|
||||
)}>
|
||||
<div className="font-medium">
|
||||
Đang chờ Bước {currentStep.order} ({currentStep.name}) — Cấp {curLevelOrder}
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
Người duyệt: {currentLevels.map(l => l.approverFullName ?? '(chưa rõ)').join(' / ') || '(chưa có)'}
|
||||
</div>
|
||||
{actorInLevel
|
||||
? <div className="mt-0.5 font-medium">✓ Đến lượt bạn duyệt</div>
|
||||
: <div className="mt-0.5">⚠ Không phải lượt bạn — chỉ người trên mới thao tác cấp này</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === KhkkPhase.TraLai && (
|
||||
<div className="rounded border border-orange-300 bg-orange-50 px-3 py-2 text-[11px] text-orange-800">
|
||||
⚠ Kế hoạch bị TRẢ LẠI — sửa nội dung rồi bấm “Gửi duyệt”, quy trình chạy lại từ Bước 1 · Cấp 1.
|
||||
</div>
|
||||
)}
|
||||
{phase === KhkkPhase.TuChoi && (
|
||||
<div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
✗ Kế hoạch đã bị TỪ CHỐI — không thao tác được nữa. Lập kế hoạch mới nếu cần làm lại.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hành động */}
|
||||
{(canSubmit || isWaiting) && (
|
||||
<div>
|
||||
<Label className="text-xs">Hành động:</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{canSubmit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setComment(''); setAction(KhkkTransitionAction.Submit) }}
|
||||
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-bold text-brand-700 transition hover:bg-brand-50"
|
||||
>
|
||||
<Send className="h-3 w-3" /> Gửi duyệt
|
||||
</button>
|
||||
)}
|
||||
{isDraftLike && !plan.approvalWorkflowId && (
|
||||
<span className="rounded border border-dashed border-slate-300 px-2 py-1 text-[11px] text-slate-500">
|
||||
Chưa pin quy trình duyệt — không gửi duyệt được.
|
||||
</span>
|
||||
)}
|
||||
{isWaiting && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Approve) } }}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Duyệt cấp hiện tại'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-emerald-300 text-emerald-700 hover:bg-emerald-50',
|
||||
)}
|
||||
>
|
||||
<Check className="h-3 w-3" /> Duyệt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Return) } }}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Trả lại người soạn sửa'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-amber-300 text-amber-700 hover:bg-amber-50',
|
||||
)}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" /> Trả lại
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={blockedByLevel}
|
||||
onClick={() => { if (!blockedByLevel) { setComment(''); setAction(KhkkTransitionAction.Reject) } }}
|
||||
title={blockedByLevel ? 'Cấp hiện tại chỉ người được phân công mới thao tác được.' : 'Từ chối kế hoạch (khoá phiếu)'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded border px-2 py-1 text-[11px] font-bold transition',
|
||||
blockedByLevel
|
||||
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
|
||||
: 'border-red-300 text-red-700 hover:bg-red-50',
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" /> Từ chối
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ý kiến cấp duyệt (UPSERT 1 row/Cấp — BE ghi khi người duyệt bấm Duyệt kèm ý kiến). */}
|
||||
{opinions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-700">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-violet-500" />
|
||||
Ý kiến cấp duyệt
|
||||
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-slate-500">
|
||||
{opinions.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-1.5">
|
||||
{opinions.map(o => (
|
||||
<li key={o.id} className="rounded border border-slate-200 bg-slate-50/60 px-2.5 py-1.5 text-[11px]">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 text-slate-700">
|
||||
<span className="font-medium">{o.signedByFullName}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span>
|
||||
{o.stepName ? `Bước ${o.stepOrder} — ${o.stepName}` : 'Cấp duyệt'}
|
||||
{o.levelOrder != null && ` · Cấp ${o.levelOrder}`}
|
||||
</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-500">{formatDateTime(o.signedAt)}</span>
|
||||
{/* Người ký ≠ người được phân công ⇒ Admin duyệt thay (mirror banner PE S17). */}
|
||||
{o.approverUserId && o.approverUserId !== o.signedByUserId && (
|
||||
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] font-semibold text-amber-800">
|
||||
duyệt thay
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 whitespace-pre-wrap text-slate-600">
|
||||
{o.comment?.trim() || '(duyệt — không ý kiến)'}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action !== null && (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => setAction(null)}
|
||||
title={dialogTitle}
|
||||
footer={<>
|
||||
<Button variant="outline" onClick={() => setAction(null)}>Huỷ</Button>
|
||||
<Button onClick={() => transition.mutate(action)} disabled={confirmDisabled}>
|
||||
{transition.isPending ? 'Đang gửi...' : 'Xác nhận'}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
{action === KhkkTransitionAction.Submit && (
|
||||
<div className="mb-3 rounded border border-brand-200 bg-brand-50 px-3 py-2 text-[11px] text-brand-800">
|
||||
Kế hoạch chuyển sang “Chờ duyệt” và trình lên Bước 1 · Cấp 1 của quy trình
|
||||
{plan.workflowName ? ` "${plan.workflowName}"` : ''}. Sau khi trình, nội dung nháp không sửa được nữa.
|
||||
</div>
|
||||
)}
|
||||
{action === KhkkTransitionAction.Return && (
|
||||
<div className="mb-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
|
||||
Kế hoạch về trạng thái “Trả lại”. Người soạn sửa xong gửi lại thì quy trình chạy
|
||||
từ Bước 1 · Cấp 1.
|
||||
</div>
|
||||
)}
|
||||
{action === KhkkTransitionAction.Reject && (
|
||||
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
|
||||
⚠ Kế hoạch bị khoá hoàn toàn (không sửa / không duyệt tiếp). Người soạn phải lập kế hoạch mới.
|
||||
</div>
|
||||
)}
|
||||
<Label>
|
||||
Ý kiến{commentRequired ? <span className="text-red-500"> *</span> : ' (tuỳ chọn)'}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder={commentRequired ? 'Nêu lý do trả lại / từ chối...' : 'Ý kiến của bạn (ghi vào phiếu)...'}
|
||||
/>
|
||||
{action === KhkkTransitionAction.Approve && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Ý kiến sẽ được ghi vào mục “Ý kiến cấp duyệt” của kế hoạch (1 ô/Cấp — duyệt lại
|
||||
thì ghi đè ô cũ).
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -20,7 +20,10 @@
|
||||
// POST /contract-signing-plans/{id}/attachments multipart { file, purpose, dossierItemId? }
|
||||
// GET /contract-signing-plans/{id}/attachments/{attId}/download
|
||||
// DELETE /contract-signing-plans/{id}/attachments/{attId}
|
||||
// (transitions submit/approve/return/reject = W3, CỐ Ý chưa có ở đây.)
|
||||
//
|
||||
// Hợp đồng FE ↔ BE lượt W3 (LEAD chốt — 2 lane implement đúng LITERAL từng tên field):
|
||||
// POST /contract-signing-plans/{id}/transitions KhkkTransitionInput → KhkkTransitionResult
|
||||
// policy `KeHoachKyKet.Update`
|
||||
|
||||
// Mirror `ContractSigningPlanPhase.cs` — 🔴 bắt đầu từ 1 (0 falsy JS).
|
||||
export const KhkkPhase = {
|
||||
@ -161,12 +164,36 @@ export interface KhkkLevelOpinionDto {
|
||||
stepOrder: number | null
|
||||
stepName: string | null
|
||||
levelOrder: number | null
|
||||
// [W3 S161] BE `ContractSigningPlanLevelOpinionDto:99` CÓ field này (W2 khai thiếu).
|
||||
// `signedByUserId !== approverUserId` ⇒ có người duyệt THAY (Admin) — panel gắn nhãn.
|
||||
approverUserId: string | null
|
||||
comment: string | null
|
||||
signedAt: string
|
||||
signedByUserId: string
|
||||
signedByFullName: string
|
||||
}
|
||||
|
||||
// [W3 S161] Cây quy trình THÔ do BE trả (`ContractSigningPlanWorkflowLevelDto:105-110`).
|
||||
// 🔴 KHÁC PE: BE **KHÔNG** precompute `status` Done/Current/Pending — FE tự suy từ cặp
|
||||
// con-trỏ `currentWorkflowStepIndex` (INDEX 0-based vào mảng steps) + `currentApprovalLevelOrder`
|
||||
// (GIÁ TRỊ `level.order`). Xem `ContractSigningPlanFeatures.cs:224-226` + `:259-260`.
|
||||
export interface KhkkWorkflowLevelDto {
|
||||
id: string
|
||||
order: number
|
||||
name: string | null
|
||||
approverUserId: string
|
||||
approverFullName: string | null
|
||||
}
|
||||
|
||||
// `ContractSigningPlanWorkflowStepDto:112-117` — chỉ có `departmentId`, KHÔNG có tên phòng.
|
||||
export interface KhkkWorkflowStepDto {
|
||||
id: string
|
||||
order: number
|
||||
name: string
|
||||
departmentId: string | null
|
||||
levels: KhkkWorkflowLevelDto[]
|
||||
}
|
||||
|
||||
export interface KhkkDetailDto extends KhkkListItemDto {
|
||||
workflowName: string | null
|
||||
currentWorkflowStepIndex: number | null
|
||||
@ -177,6 +204,8 @@ export interface KhkkDetailDto extends KhkkListItemDto {
|
||||
dossierItems: KhkkDossierItemDto[]
|
||||
attachments: KhkkAttachmentDto[]
|
||||
levelOpinions: KhkkLevelOpinionDto[]
|
||||
// `ContractSigningPlanDetailDto:147` — field CUỐI của record BE.
|
||||
workflowSteps: KhkkWorkflowStepDto[]
|
||||
}
|
||||
|
||||
// Phiếu PE nguồn đủ điều kiện lập kế hoạch (DaDuyet + chưa có plan sống).
|
||||
@ -218,6 +247,34 @@ export interface UpsertKhkkDossierItemInput {
|
||||
note?: string | null
|
||||
}
|
||||
|
||||
// ===================== W3 — chuyển trạng thái (duyệt 3 trạm) =========================
|
||||
// 🔴 HỢP-ĐỒNG LITERAL do LEAD chốt (S161 W3). Tên field ở đây = tên field BE bind.
|
||||
// Đổi 1 chữ ở đây mà không đổi BE = đứt im lặng (bài W2 6-điểm-đứt).
|
||||
// submit = trình duyệt (DangSoanThao | TraLai → ChoDuyet)
|
||||
// approve = duyệt cấp hiện tại (OR-of-N; comment ≠ rỗng ⇒ UPSERT LevelOpinion)
|
||||
// return = trả lại (→ TraLai)
|
||||
// reject = từ chối (→ TuChoi)
|
||||
export const KhkkTransitionAction = {
|
||||
Submit: 'submit',
|
||||
Approve: 'approve',
|
||||
Return: 'return',
|
||||
Reject: 'reject',
|
||||
} as const
|
||||
export type KhkkTransitionActionValue =
|
||||
(typeof KhkkTransitionAction)[keyof typeof KhkkTransitionAction]
|
||||
|
||||
export interface KhkkTransitionInput {
|
||||
action: KhkkTransitionActionValue
|
||||
comment: string | null
|
||||
}
|
||||
|
||||
// Response 200 — 3 field, ĐÚNG tên hợp-đồng. `phase` là INT (mirror `KhkkPhase`).
|
||||
export interface KhkkTransitionResult {
|
||||
phase: number
|
||||
currentWorkflowStepIndex: number | null
|
||||
currentApprovalLevelOrder: number | null
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
|
||||
@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.ContractSigningPlans;
|
||||
using SolutionErp.Application.ContractSigningPlans.Services;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
@ -18,12 +19,16 @@ namespace SolutionErp.Api.Controllers;
|
||||
// (`MenuKeys.cs:42,180`) × `MenuKeys.Actions` trong `Api/Program.cs`. Key KHÔNG nằm
|
||||
// trong `All` ⇒ 500 lúc chạy (policy không tồn tại), không phải 403.
|
||||
//
|
||||
// 🔴 KHÔNG có endpoint transition (submit/approve/reject/return) — đó là W3.
|
||||
// [W3 đợt-1 — S161] ĐÃ CÓ endpoint transition (đính chính dòng cũ "KHÔNG có ... đó là W3"):
|
||||
// `POST {id}/transitions` mở ở cuối file. Đợt-1 mới là SKELETON — service ném
|
||||
// NotImplementedException ⇒ endpoint trả 500 cho tới khi đợt-3 điền logic. CỐ Ý: test-before.
|
||||
// GlobalExceptionMiddleware map exception → ProblemDetails ⇒ TUYỆT ĐỐI không try-catch ở đây.
|
||||
[ApiController]
|
||||
[Route("api/contract-signing-plans")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Read")]
|
||||
public class ContractSigningPlansController(IMediator mediator) : ControllerBase
|
||||
public class ContractSigningPlansController(
|
||||
IMediator mediator,
|
||||
IContractSigningPlanWorkflowService workflow) : ControllerBase
|
||||
{
|
||||
// ========================= Đọc =========================
|
||||
|
||||
@ -190,6 +195,21 @@ public class ContractSigningPlansController(IMediator mediator) : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ===================== Duyệt 3 trạm (W3) =====================
|
||||
// 🔴 HỢP-ĐỒNG LITERAL (lead chốt @S161 — 2 lane BE/FE khớp từng tên field):
|
||||
// body { "action": "submit"|"approve"|"return"|"reject", "comment": string|null }
|
||||
// response { "phase": int, "currentWorkflowStepIndex": int|null,
|
||||
// "currentApprovalLevelOrder": int|null }
|
||||
// Policy `KeHoachKyKet.Update` (KHÔNG Create/Delete): duyệt = GHI vào phiếu đã có.
|
||||
// Đi THẲNG service (không qua MediatR) vì đây là 1 thao tác state-machine, không phải
|
||||
// CQRS command — mirror `ContractsController` gọi `IContractWorkflowService`.
|
||||
|
||||
[HttpPost("{id:guid}/transitions")]
|
||||
[Authorize(Policy = "KeHoachKyKet.Update")]
|
||||
public async Task<ActionResult<ContractSigningPlanTransitionResult>> Transition(
|
||||
Guid id, [FromBody] ContractSigningPlanTransitionBody body, CancellationToken ct)
|
||||
=> Ok(await workflow.TransitionAsync(id, body.Action, body.Comment, ct));
|
||||
|
||||
// ========================= Body records =========================
|
||||
// PUT header: KHÔNG mang `Id` trong body (id lấy từ route) — tránh 2 nguồn sự thật.
|
||||
// Mọi field nullable = null-safe: client không gửi ⇒ giữ giá trị cũ (#73).
|
||||
@ -197,4 +217,12 @@ public class ContractSigningPlansController(IMediator mediator) : ControllerBase
|
||||
string? GhiChu = null,
|
||||
string? HoSoLink = null,
|
||||
Guid? ApprovalWorkflowId = null);
|
||||
|
||||
// Transition: `Action` BẮT BUỘC (thiếu ⇒ service ném 409 "Hành động không hợp lệ"),
|
||||
// `Comment` tuỳ chọn. Tên property PascalCase bind đúng JSON "action"/"comment" —
|
||||
// ASP.NET Core mặc định camelCase + đọc không phân biệt hoa/thường
|
||||
// (`Api/Program.cs:33-34` chỉ thêm converter DateTime, KHÔNG đổi naming policy).
|
||||
public record ContractSigningPlanTransitionBody(
|
||||
string Action,
|
||||
string? Comment = null);
|
||||
}
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
namespace SolutionErp.Application.ContractSigningPlans.Services;
|
||||
|
||||
// [W3 ĐỢT-1 SKELETON — S161 2026-07-29] Hợp đồng transition phiếu "Kế hoạch ký kết HĐ" (GĐ2).
|
||||
//
|
||||
// 🔴 ĐỢT NÀY = SKELETON THUẦN (test-before): interface + record chốt CHỮ KÝ để
|
||||
// test-specialist viết 6 test RED compile được. Thân service NÉM NotImplementedException
|
||||
// — logic thật (OR-of-N, con-trỏ đôi, LevelOpinions UPSERT, finalize) là ĐỢT-3.
|
||||
//
|
||||
// 🔴 HỢP-ĐỒNG LITERAL (lead định nghĩa @S161 — bài W2 "6 điểm đứt": 2 lane implement phải
|
||||
// khớp TỪNG TÊN FIELD, không tự đổi):
|
||||
// POST /api/contract-signing-plans/{id}/transitions [Authorize(Policy="KeHoachKyKet.Update")]
|
||||
// body : { "action": "submit" | "approve" | "return" | "reject", "comment": string|null }
|
||||
// response : { "phase": int, "currentWorkflowStepIndex": int|null, "currentApprovalLevelOrder": int|null }
|
||||
//
|
||||
// Ý nghĩa 4 action:
|
||||
// submit — người soạn trình phiếu: DangSoanThao | TraLai → ChoDuyet (chạy LẠI từ Bước 1 Cấp 1)
|
||||
// approve — approver Cấp đang chờ duyệt: tiến con-trỏ OR-of-N (+comment ⇒ UPSERT LevelOpinion)
|
||||
// return — trả lại người soạn: → TraLai
|
||||
// reject — từ chối: → TuChoi (terminal)
|
||||
public interface IContractSigningPlanWorkflowService
|
||||
{
|
||||
// Thực hiện 1 transition. Ném ConflictException/ForbiddenException/NotFoundException
|
||||
// (GlobalExceptionMiddleware map → ProblemDetails; controller KHÔNG try-catch).
|
||||
// Trả về trạng-thái SAU transition để controller phản hồi thẳng 3 field hợp-đồng.
|
||||
Task<ContractSigningPlanTransitionResult> TransitionAsync(
|
||||
Guid planId,
|
||||
string action,
|
||||
string? comment,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// 3 field ĐÚNG BẰNG response hợp-đồng, KHÔNG thừa không thiếu.
|
||||
// 🔴 `Phase` để kiểu `int` (KHÔNG ContractSigningPlanPhase) là CỐ Ý: hợp-đồng chốt
|
||||
// `"phase": int` ⇒ ép cast tại nguồn, khỏi phụ thuộc cấu hình JsonStringEnumConverter
|
||||
// (Api/Program.cs:33-34 hiện KHÔNG đăng ký converter enum, nhưng thêm sau sẽ lật kiểu
|
||||
// JSON của mọi enum ⇒ khoá kiểu ở đây là rào chống hồi-quy im lặng).
|
||||
// Null-semantics: 2 con-trỏ = null khi phiếu ở phase KHÔNG chạy workflow (nháp/terminal).
|
||||
public record ContractSigningPlanTransitionResult(
|
||||
int Phase,
|
||||
int? CurrentWorkflowStepIndex,
|
||||
int? CurrentApprovalLevelOrder);
|
||||
@ -40,6 +40,9 @@ public static class DependencyInjection
|
||||
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
|
||||
// [W1 Mig 69 — S161] mã phiếu KHKK/{YYYY}/{Seq:D3} qua WorkflowAppCodeSequences dùng chung.
|
||||
services.AddScoped<IContractSigningPlanCodeGenerator, ContractSigningPlanCodeGenerator>();
|
||||
// [W3 đợt-1 — S161] workflow duyệt 3 trạm KHKK. Đợt-1 = SKELETON (mọi nhánh ném
|
||||
// NotImplementedException); đăng ký sẵn để endpoint + test resolve được đúng chữ ký.
|
||||
services.AddScoped<IContractSigningPlanWorkflowService, ContractSigningPlanWorkflowService>();
|
||||
services.AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();
|
||||
|
||||
@ -0,0 +1,589 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.ContractSigningPlans.Services;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction (enum dùng chung)
|
||||
using SolutionErp.Domain.Identity; // AppRoles
|
||||
using SolutionErp.Domain.Notifications; // NotificationType
|
||||
|
||||
namespace SolutionErp.Infrastructure.Services;
|
||||
|
||||
// [W3 ĐỢT-3 — S161 2026-07-29] Workflow phiếu "Kế hoạch ký kết HĐ" (GĐ2).
|
||||
//
|
||||
// Đợt-1 để skeleton ném NotImplementedException cho 6 test sàn ĐỎ THẬT; đợt này điền
|
||||
// logic để chúng TỰ xanh. Hợp-đồng đợt-1 (4 GIỮ + 3 ADAPT + 2 CẤM) chép lại rút gọn
|
||||
// bên dưới kèm ĐỊA CHỈ, và ghi rõ chỗ nào đã thi hành / chỗ nào CỐ Ý không.
|
||||
//
|
||||
// NGUỒN COPY: `ContractWorkflowService.ApproveV2Async` (`ContractWorkflowService.cs:217-394`).
|
||||
// 🔴 CẤM copy `ProposalFeatures` — Proposal FLATTEN Level (SelectMany) nên OR-of-N biến
|
||||
// thành AND ÂM THẦM, và nó chỉ có 1 con-trỏ LevelOrder ⇒ không diễn tả được "Bước mới
|
||||
// reset Cấp về 1". Ở đây giữ đúng khuôn nguồn: `GroupBy(l => l.Order)` + `Contains(actor)`.
|
||||
//
|
||||
// 4 ĐIỂM GIỮ (mỗi điểm = 1 acceptance, đều đã thi hành):
|
||||
// 1. Con-trỏ ĐÔI `CurrentWorkflowStepIndex` (0-based INDEX vào list Steps đã sort theo
|
||||
// Order — KHÔNG phải giá trị Order) + `CurrentApprovalLevelOrder` (1-based).
|
||||
// Sang Bước mới ⇒ Cấp reset về 1 (`AdvancePointer` bên dưới).
|
||||
// 2. GroupBy Order = Cấp (khuôn `ContractWorkflowService.cs:246`).
|
||||
// 3. OR-of-N: MỘT người trong Cấp duyệt là Cấp đó xong (khuôn `:255-266`) —
|
||||
// `ResolveActingLevel` khớp theo tập, KHÔNG đòi đủ chữ ký cả nhóm.
|
||||
// 4. UPSERT ý kiến vào `ContractSigningPlanLevelOpinions` (khuôn `:292-316`), 1 row /
|
||||
// cặp (Plan × Level), `SignedByUserId` = người ký THẬT (Admin duyệt thay vẫn ghi Admin).
|
||||
//
|
||||
// 3 ĐIỂM ADAPT:
|
||||
// (a) Finalize choke-point: helper `ApplyApprovedValuesOnFinalize` gọi ở MỌI nhánh set
|
||||
// `DaDuyet` — xem khối LUẬT ngay trên helper. 🔴 KHAI RÕ: service này có **ĐÚNG MỘT**
|
||||
// nhánh set DaDuyet, KHÔNG có site thứ hai kiểu admin-override của nguồn copy
|
||||
// (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` qua BIẾN).
|
||||
// Lý do: hợp-đồng transition KHKK chỉ nhận 4 action cố định, KHÔNG có tham số
|
||||
// `targetPhase` ⇒ Admin muốn kết thúc phiếu vẫn phải đi đúng đường `approve`
|
||||
// (Admin chỉ được miễn vế "có tên trong Cấp", không được miễn choke-point).
|
||||
// Đó chính là write-path 2 mà test T3 đo.
|
||||
// (b) 🔴 KHÔNG PORT 2 nhánh kết thúc sớm (`AllowApproverFinalize`, `CeoApprovalThreshold`)
|
||||
// và cũng KHÔNG port `AllowApproverSkipToFinal` (`:318-352` nguồn copy) — chủ dự án
|
||||
// phán "PHÁ VỠ" @S155. W3 chỉ đường THƯỜNG: PMH → CCM → CEO ký thật.
|
||||
// (c) Trả lại = `TraLai` TOÀN PHẦN (về người soạn, xoá con-trỏ; gửi lại chạy LẠI từ Bước 1
|
||||
// Cấp 1). 4 return-mode per-level (`ApprovalWorkflow.cs:116-129`) CỐ Ý chưa đọc —
|
||||
// mở thêm mode là đổi hành vi duyệt, thuộc quyền lead.
|
||||
//
|
||||
// LỊCH SỬ: mỗi transition ghi `ContractSigningPlanApprovals` + `ContractSigningPlanChangelogs`
|
||||
// qua `LogSigningPlanTransitionAsync` (method KHKK-RIÊNG, bên dưới).
|
||||
// 🔴 CẤM `IChangelogService.LogWorkflowTransitionAsync(...)`: nó nhận `Guid contractId` và
|
||||
// ghi `ContractChangelogs` — bảng có FK VẬT LÝ `ContractId` Cascade
|
||||
// (`ContractChangelogConfiguration.cs:22-26`) ⇒ truyền `plan.Id` = FK violation 547 LÚC CHẠY,
|
||||
// compiler KHÔNG chặn. Rào bằng THIẾT KẾ: service CỐ Ý không inject `IChangelogService`
|
||||
// (không có sẵn thì không lỡ tay gọi) — giữ nguyên từ đợt-1.
|
||||
//
|
||||
// ĐIỂM LƯU (khai để lead phân xử): ĐÚNG MỘT `SaveChangesAsync` ở CUỐI `TransitionAsync`.
|
||||
// Khác nguồn copy (`ContractWorkflowService.cs:537` TransitionAsync KHÔNG tự save, caller
|
||||
// save) vì ở đây service LÀ điểm vào (controller gọi thẳng, không qua MediatR) ⇒ không có
|
||||
// "caller downstream" nào để save hộ. 1 transition = 1 unit-of-work: con-trỏ + hàng
|
||||
// Approvals + Changelogs + LevelOpinion + giá chốt + Notifications cùng sống hoặc cùng chết
|
||||
// (`NotificationService.cs:7-8` cố ý KHÔNG tự SaveChanges để caller giữ atomicity).
|
||||
public class ContractSigningPlanWorkflowService(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser,
|
||||
IDateTime dateTime,
|
||||
INotificationService notifications) : IContractSigningPlanWorkflowService
|
||||
{
|
||||
// Tập action khoá theo hợp-đồng literal của lead. So khớp KHÔNG phân biệt hoa/thường
|
||||
// (client gửi "Submit" vẫn hiểu), nhưng nhánh xử lý luôn chạy trên bản đã chuẩn hoá.
|
||||
private static readonly HashSet<string> AllowedActions =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "submit", "approve", "return", "reject" };
|
||||
|
||||
// Ý kiến rỗng → chữ thay thế (khuôn nguồn copy `ContractWorkflowService.cs:295-297`):
|
||||
// ô câm không phân biệt được "đã ký, không ý kiến" với "chưa ký".
|
||||
private const string NoOpinionPlaceholder = "(duyệt — không ý kiến)";
|
||||
|
||||
// Trần độ dài ý kiến = cột CHẶT NHẤT trong các nơi chứa nguyên văn comment:
|
||||
// `ContractSigningPlanApprovals.Comment` = 1000 (`ContractSigningPlanApprovalConfiguration.cs:26`).
|
||||
// Chặn TẠI CỔNG thay vì cắt ngầm: đường này KHÔNG đi qua MediatR nên KHÔNG có
|
||||
// FluentValidation nào đỡ; để lọt thì SQL Server ném 2628 (chuỗi bị cắt) = 500 khó hiểu.
|
||||
private const int MaxCommentLength = 1000;
|
||||
|
||||
public async Task<ContractSigningPlanTransitionResult> TransitionAsync(
|
||||
Guid planId,
|
||||
string action,
|
||||
string? comment,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Rào action đứng TRƯỚC mọi truy vấn: gõ sai tên hành động là lỗi dữ liệu gửi lên,
|
||||
// không phải "không tìm thấy phiếu" — trả 409 để FE hiện đúng nguyên nhân.
|
||||
var normalized = (action ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (!AllowedActions.Contains(normalized))
|
||||
throw new ConflictException("Hành động không hợp lệ.");
|
||||
|
||||
var normalizedComment = string.IsNullOrWhiteSpace(comment) ? null : comment.Trim();
|
||||
if (normalizedComment is not null && normalizedComment.Length > MaxCommentLength)
|
||||
throw new ConflictException($"Ý kiến duyệt tối đa {MaxCommentLength} ký tự.");
|
||||
|
||||
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||
throw new UnauthorizedException();
|
||||
var actorId = currentUser.UserId.Value;
|
||||
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
|
||||
|
||||
// Include Lines NGAY: nhánh finalize cần chốt giá, và nạp kèm ở đây thì phép lọc
|
||||
// "chỉ dòng của phiếu này" là do CHÍNH quan hệ cha-con bảo đảm (xem LUẬT ở
|
||||
// `ApplyApprovedValuesOnFinalize`), không phải do nhớ viết `Where(PlanId == ...)`.
|
||||
var plan = await db.ContractSigningPlans
|
||||
.Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.Id == planId, ct)
|
||||
?? throw new NotFoundException("ContractSigningPlan", planId);
|
||||
|
||||
// switch TÁCH RIÊNG từng nhánh (không gộp) — mỗi hành động có guard riêng; gộp lại
|
||||
// là mất ranh giới giữa "ai được trình" và "ai được duyệt".
|
||||
switch (normalized)
|
||||
{
|
||||
case "submit":
|
||||
await SubmitAsync(plan, actorId, isAdmin, normalizedComment, ct);
|
||||
break;
|
||||
|
||||
case "approve":
|
||||
await ApproveV2Async(plan, actorId, isAdmin, normalizedComment, ct);
|
||||
break;
|
||||
|
||||
case "return":
|
||||
await ReturnOrRejectAsync(
|
||||
plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TraLai, ct);
|
||||
break;
|
||||
|
||||
case "reject":
|
||||
await ReturnOrRejectAsync(
|
||||
plan, actorId, isAdmin, normalizedComment, ContractSigningPlanPhase.TuChoi, ct);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Không tới được (đã lọc ở rào trên) — giữ để switch đóng kín.
|
||||
throw new ConflictException("Hành động không hợp lệ.");
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Trả trạng thái SAU transition, đọc từ chính entity vừa lưu ⇒ FE vẽ lại panel không
|
||||
// cần refetch mà vẫn không thể lệch với DB.
|
||||
return new ContractSigningPlanTransitionResult(
|
||||
(int)plan.Phase, plan.CurrentWorkflowStepIndex, plan.CurrentApprovalLevelOrder);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// submit — người soạn trình phiếu
|
||||
// =====================================================================
|
||||
private async Task SubmitAsync(
|
||||
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
|
||||
{
|
||||
if (plan.Phase is not (ContractSigningPlanPhase.DangSoanThao or ContractSigningPlanPhase.TraLai))
|
||||
throw new ConflictException("Chỉ trình duyệt được phiếu đang ở trạng thái Nháp hoặc Trả lại.");
|
||||
|
||||
// Nói RÕ nguyên nhân: phiếu chưa gắn quy trình thì không có Bước/Cấp nào để chờ,
|
||||
// trình đại = phiếu treo ở "Đã gửi duyệt" mà không nằm trong hộp thư của ai.
|
||||
if (plan.ApprovalWorkflowId is not Guid awId)
|
||||
throw new ConflictException(
|
||||
"Phiếu chưa gắn quy trình duyệt — chọn quy trình trong phiếu nháp trước khi trình.");
|
||||
|
||||
await EnsureCanSubmitAsync(plan, actorId, isAdmin, ct);
|
||||
|
||||
var steps = await LoadStepsAsync(awId, ct);
|
||||
var firstLevelGroup = steps[0].Levels.Where(l => l.Order == 1).ToList();
|
||||
if (firstLevelGroup.Count == 0)
|
||||
throw new ConflictException(
|
||||
$"Quy trình duyệt chưa cấu hình Cấp 1 cho Bước 1 ({steps[0].Name}) — chưa trình được.");
|
||||
|
||||
var fromPhase = plan.Phase;
|
||||
plan.Phase = ContractSigningPlanPhase.ChoDuyet;
|
||||
|
||||
// 🔴 RESET về đầu quy trình cho CẢ hai lối vào (Nháp lần đầu VÀ Trả lại gửi lại):
|
||||
// nối tiếp chỗ đang dở thì bản sửa của người soạn không được các Bước trước xem lại
|
||||
// lần nào (acceptance #6).
|
||||
plan.CurrentWorkflowStepIndex = 0;
|
||||
plan.CurrentApprovalLevelOrder = 1;
|
||||
|
||||
await LogSigningPlanTransitionAsync(
|
||||
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Pending, comment,
|
||||
actingLevelId: null,
|
||||
summary: fromPhase == ContractSigningPlanPhase.TraLai
|
||||
? "Trình duyệt lại kế hoạch ký kết (chạy lại từ Bước 1 Cấp 1)"
|
||||
: "Trình duyệt kế hoạch ký kết",
|
||||
ct: ct);
|
||||
|
||||
await NotifyPendingApproversAsync(plan, firstLevelGroup, actorId, ct);
|
||||
}
|
||||
|
||||
/// Guard trình — CỐ Ý khác khuôn role-based của Contract (né lớp 403-PMH): phiếu là của
|
||||
/// người soạn + phòng của người soạn, không phải của một vai trong quy trình.
|
||||
/// 🔴 KHAI RÕ nới 1 vế so với chữ của spec ("CreatedBy == actor"): chấp nhận CẢ
|
||||
/// `DrafterUserId == actor`. Hai cột này chỉ đường tới CÙNG một người ở đường tạo phiếu
|
||||
/// W2 (`ContractSigningPlanFeatures.cs:372` gán `DrafterUserId = actorId`, còn `CreatedBy`
|
||||
/// do `AuditingInterceptor` ghi từ cùng `ICurrentUser`) ⇒ nới KHÔNG mở thêm ai. Nhưng
|
||||
/// phiếu sinh ngoài đường CQRS (seed/import chạy dưới danh nghĩa hệ thống) sẽ có
|
||||
/// `CreatedBy = null` — đọc mỗi cột đó thì chính người soạn bị chặn trình phiếu của mình.
|
||||
private async Task EnsureCanSubmitAsync(
|
||||
ContractSigningPlan plan, Guid actorId, bool isAdmin, CancellationToken ct)
|
||||
{
|
||||
if (isAdmin) return;
|
||||
if (plan.CreatedBy == actorId || plan.DrafterUserId == actorId) return;
|
||||
|
||||
// Trưởng phòng CÙNG phòng ban CỦA PHIẾU. So với `plan.DepartmentId` (phòng của phiếu),
|
||||
// KHÔNG phải `Step.DepartmentId` — cột kia chỉ là hint của quy trình
|
||||
// (`ContractSigningPlan.cs:26-28`).
|
||||
if (currentUser.Roles.Contains(AppRoles.DeptManager) && plan.DepartmentId is Guid deptId)
|
||||
{
|
||||
var sameDept = await db.Users.AsNoTracking()
|
||||
.AnyAsync(u => u.Id == actorId && u.DepartmentId == deptId, ct);
|
||||
if (sameDept) return;
|
||||
}
|
||||
|
||||
throw new ForbiddenException(
|
||||
"Chỉ người soạn kế hoạch, trưởng phòng cùng phòng ban của phiếu hoặc Admin được trình duyệt.");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// approve — approver Cấp đang chờ duyệt (OR-of-N)
|
||||
// =====================================================================
|
||||
private async Task ApproveV2Async(
|
||||
ContractSigningPlan plan, Guid actorId, bool isAdmin, string? comment, CancellationToken ct)
|
||||
{
|
||||
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
|
||||
throw new ConflictException("Chỉ duyệt được phiếu đang ở trạng thái Đã gửi duyệt.");
|
||||
if (plan.ApprovalWorkflowId is not Guid awId)
|
||||
throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");
|
||||
|
||||
var steps = await LoadStepsAsync(awId, ct);
|
||||
var (currentIdx, currentStep, currentLevelOrder, levelGroups, maxLevelOrder) =
|
||||
ResolvePointer(plan, steps);
|
||||
|
||||
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
|
||||
?? throw new ConflictException($"Bước {currentIdx + 1} không có Cấp {currentLevelOrder}.");
|
||||
|
||||
var actingLevel = ResolveActingLevel(
|
||||
pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);
|
||||
|
||||
// Ý kiến sync ngay lúc duyệt (Q1=1B — KHÔNG endpoint ý kiến rời).
|
||||
await UpsertLevelOpinionAsync(plan, actingLevel, actorId, comment, ct);
|
||||
|
||||
var fromPhase = plan.Phase;
|
||||
|
||||
// ---- Advance con-trỏ ĐÔI: còn Cấp trong Bước → Cấp++; hết Cấp → Bước kế (Cấp 1);
|
||||
// hết Bước → FINALIZE.
|
||||
if (currentLevelOrder < maxLevelOrder)
|
||||
{
|
||||
plan.CurrentApprovalLevelOrder = currentLevelOrder + 1;
|
||||
await LogSigningPlanTransitionAsync(
|
||||
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
|
||||
actingLevel.Id,
|
||||
$"Duyệt Bước {currentIdx + 1} Cấp {currentLevelOrder} — chuyển Cấp {currentLevelOrder + 1}",
|
||||
ct);
|
||||
await NotifyPendingApproversAsync(
|
||||
plan, LevelsOf(currentStep, currentLevelOrder + 1), actorId, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var nextIdx = currentIdx + 1;
|
||||
if (nextIdx < steps.Count)
|
||||
{
|
||||
plan.CurrentWorkflowStepIndex = nextIdx;
|
||||
plan.CurrentApprovalLevelOrder = 1; // 🔴 Bước mới ⇒ Cấp RESET về 1
|
||||
await LogSigningPlanTransitionAsync(
|
||||
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
|
||||
actingLevel.Id,
|
||||
$"Hoàn tất Bước {currentIdx + 1}/{steps.Count} — chuyển Bước {nextIdx + 1} (Cấp 1)",
|
||||
ct);
|
||||
await NotifyPendingApproversAsync(plan, LevelsOf(steps[nextIdx], 1), actorId, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Cấp cuối của Bước cuối ⇒ terminal.
|
||||
ApplyApprovedValuesOnFinalize(plan);
|
||||
plan.Phase = ContractSigningPlanPhase.DaDuyet;
|
||||
plan.CurrentWorkflowStepIndex = null; // terminal ⇒ không nằm trong hộp thư ai nữa
|
||||
plan.CurrentApprovalLevelOrder = null;
|
||||
await LogSigningPlanTransitionAsync(
|
||||
plan, fromPhase, plan.Phase, actorId, ApprovalDecision.Approve, comment,
|
||||
actingLevel.Id,
|
||||
$"Duyệt Cấp cuối (Bước {currentIdx + 1}/{steps.Count}) — kế hoạch ký kết ĐÃ DUYỆT, chốt giá",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// 🔴 LUẬT (không đếm số — số-đếm tự lão-hoá, bài `PurchaseEvaluationWorkflowService.cs:1008`):
|
||||
/// MỌI nhánh đưa phiếu sang `DaDuyet` BẮT BUỘC gọi helper này TRƯỚC khi gán Phase.
|
||||
/// Bỏ sót một nhánh = phiếu "Đã duyệt" mà `ApprovedAmount` rỗng ⇒ GĐ3 sinh hợp đồng 0đ.
|
||||
/// Tự kiểm 2 lệnh (chạy cả hai, cùng phải khớp nhau):
|
||||
/// grep -rn "Phase = ContractSigningPlanPhase.DaDuyet" src/Backend --include=*.cs
|
||||
/// grep -rn "ApplyApprovedValuesOnFinalize" src/Backend --include=*.cs
|
||||
///
|
||||
/// Vế "CHỈ dòng của phiếu này" (acceptance `AND PlanId = @id`) do QUAN HỆ bảo đảm:
|
||||
/// `plan.Lines` nạp bằng `Include` trên đúng một phiếu, nên không có đường nào chạm dòng
|
||||
/// của phiếu hàng xóm — cố ý KHÔNG viết `db.ContractSigningPlanLines.Where(...)` để không
|
||||
/// tồn tại chỗ cho phép quên vế lọc.
|
||||
///
|
||||
/// `??=` — GIỮ giá trị đã chốt, KHÔNG đè: số do người có thẩm quyền sửa tay phải thắng.
|
||||
private static void ApplyApprovedValuesOnFinalize(ContractSigningPlan plan)
|
||||
{
|
||||
foreach (var line in plan.Lines)
|
||||
line.ApprovedAmount ??= line.ProposedAmount;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// return / reject — trả lại người soạn hoặc từ chối hẳn
|
||||
// =====================================================================
|
||||
private async Task ReturnOrRejectAsync(
|
||||
ContractSigningPlan plan,
|
||||
Guid actorId,
|
||||
bool isAdmin,
|
||||
string? comment,
|
||||
ContractSigningPlanPhase targetPhase,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var isReturn = targetPhase == ContractSigningPlanPhase.TraLai;
|
||||
|
||||
if (plan.Phase != ContractSigningPlanPhase.ChoDuyet)
|
||||
throw new ConflictException(isReturn
|
||||
? "Chỉ trả lại được phiếu đang ở trạng thái Đã gửi duyệt."
|
||||
: "Chỉ từ chối được phiếu đang ở trạng thái Đã gửi duyệt.");
|
||||
if (plan.ApprovalWorkflowId is not Guid awId)
|
||||
throw new ConflictException("Phiếu chưa gắn quy trình duyệt.");
|
||||
|
||||
var steps = await LoadStepsAsync(awId, ct);
|
||||
var (currentIdx, currentStep, currentLevelOrder, levelGroups, _) = ResolvePointer(plan, steps);
|
||||
|
||||
var pendingLevelGroup = levelGroups.FirstOrDefault(g => g.Key == currentLevelOrder)
|
||||
?? throw new ConflictException($"Bước {currentIdx + 1} không có Cấp {currentLevelOrder}.");
|
||||
|
||||
// Cùng vế quyền với `approve`: chỉ NV của Cấp ĐANG CHỜ (OR-of-N) hoặc Admin.
|
||||
// Rào bằng CHÍNH hàm của approve ⇒ không thể lệch nhau về sau.
|
||||
var actingLevel = ResolveActingLevel(
|
||||
pendingLevelGroup, currentStep, currentIdx, currentLevelOrder, actorId, isAdmin);
|
||||
|
||||
// 🔴 KHÔNG UPSERT LevelOpinion ở đây: bảng ý kiến là chữ ký DUYỆT
|
||||
// (`ContractSigningPlanLevelOpinion.cs:11` "Reject (TraLai/TuChoi) KHÔNG sync").
|
||||
// Lý do trả lại đã nằm ở Approvals + Changelogs ngay dưới.
|
||||
var fromPhase = plan.Phase;
|
||||
plan.Phase = targetPhase;
|
||||
|
||||
// Trả lại TOÀN PHẦN / từ chối: xoá con-trỏ. Để sót thì phiếu vừa nằm ở tay người soạn
|
||||
// vừa nằm trong hộp thư người duyệt (`ResolveV2InboxIdsAsync` lọc theo con-trỏ).
|
||||
plan.CurrentWorkflowStepIndex = null;
|
||||
plan.CurrentApprovalLevelOrder = null;
|
||||
|
||||
await LogSigningPlanTransitionAsync(
|
||||
plan, fromPhase, targetPhase, actorId, ApprovalDecision.Reject, comment,
|
||||
actingLevel.Id,
|
||||
isReturn
|
||||
? $"Trả lại người soạn tại Bước {currentIdx + 1} Cấp {currentLevelOrder}"
|
||||
: $"Từ chối tại Bước {currentIdx + 1} Cấp {currentLevelOrder}",
|
||||
ct);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Helpers dùng chung
|
||||
// =====================================================================
|
||||
|
||||
/// Nạp cây quy trình đã SORT sẵn (Bước theo Order, Cấp theo Order) — mọi nhánh đọc CÙNG
|
||||
/// một thứ tự, nên INDEX con-trỏ luôn hiểu như nhau (khuôn `ContractWorkflowService.cs:228-236`).
|
||||
private async Task<List<ApprovalWorkflowStep>> LoadStepsAsync(Guid awId, CancellationToken ct)
|
||||
{
|
||||
var aw = await db.ApprovalWorkflows.AsNoTracking()
|
||||
.Include(w => w.Steps.OrderBy(s => s.Order))
|
||||
.ThenInclude(s => s.Levels.OrderBy(l => l.Order))
|
||||
.FirstOrDefaultAsync(w => w.Id == awId, ct)
|
||||
?? throw new ConflictException($"Quy trình duyệt {awId} không tồn tại.");
|
||||
|
||||
var steps = aw.Steps.OrderBy(s => s.Order).ToList();
|
||||
if (steps.Count == 0)
|
||||
throw new ConflictException("Quy trình duyệt chưa có Bước nào.");
|
||||
return steps;
|
||||
}
|
||||
|
||||
/// Đọc con-trỏ ĐÔI + kiểm biên. 🔴 `CurrentWorkflowStepIndex` là INDEX vào list đã sort,
|
||||
/// KHÔNG phải `Step.Order` — đọc nhầm thì phiếu nhảy sang Bước của người khác trong im lặng
|
||||
/// (W2 đã chốt semantic INDEX ở `ContractSigningPlanFeatures.cs:256-260`).
|
||||
private static (int Idx,
|
||||
ApprovalWorkflowStep Step,
|
||||
int LevelOrder,
|
||||
List<IGrouping<int, ApprovalWorkflowLevel>> LevelGroups,
|
||||
int MaxLevelOrder)
|
||||
ResolvePointer(ContractSigningPlan plan, List<ApprovalWorkflowStep> steps)
|
||||
{
|
||||
var idx = plan.CurrentWorkflowStepIndex ?? 0;
|
||||
if (idx < 0 || idx >= steps.Count)
|
||||
throw new ConflictException(
|
||||
$"CurrentWorkflowStepIndex={idx} không hợp lệ (max={steps.Count - 1}).");
|
||||
|
||||
var step = steps[idx];
|
||||
var levelOrder = plan.CurrentApprovalLevelOrder ?? 1;
|
||||
|
||||
// Cấp = nhóm Level CÙNG Order (khuôn `ContractWorkflowService.cs:246`). Giữ GroupBy —
|
||||
// flatten kiểu Proposal sẽ biến OR-of-N thành AND mà build vẫn sạch.
|
||||
var levelGroups = step.Levels.OrderBy(l => l.Order).GroupBy(l => l.Order).ToList();
|
||||
var maxLevelOrder = levelGroups.Count == 0 ? 0 : levelGroups.Max(g => g.Key);
|
||||
if (levelOrder < 1 || levelOrder > maxLevelOrder)
|
||||
throw new ConflictException(
|
||||
$"CurrentApprovalLevelOrder={levelOrder} không hợp lệ (max={maxLevelOrder}).");
|
||||
|
||||
return (idx, step, levelOrder, levelGroups, maxLevelOrder);
|
||||
}
|
||||
|
||||
/// OR-of-N (khuôn `ContractWorkflowService.cs:255-266`): BẤT KỲ ai trong Cấp đang chờ là
|
||||
/// duyệt được — trả về ĐÚNG row Level của người ký để ý kiến treo dưới đúng tên.
|
||||
/// Admin duyệt thay: miễn vế "có tên trong Cấp", ý kiến rơi vào row đầu Cấp và
|
||||
/// `SignedByUserId` vẫn là Admin ⇒ FE phân biệt được (`SignedByUserId != ApproverUserId`).
|
||||
private static ApprovalWorkflowLevel ResolveActingLevel(
|
||||
IGrouping<int, ApprovalWorkflowLevel> pendingLevelGroup,
|
||||
ApprovalWorkflowStep step,
|
||||
int stepIdx,
|
||||
int levelOrder,
|
||||
Guid actorId,
|
||||
bool isAdmin)
|
||||
{
|
||||
var own = pendingLevelGroup.FirstOrDefault(l => l.ApproverUserId == actorId);
|
||||
if (own is not null) return own;
|
||||
if (isAdmin) return pendingLevelGroup.First();
|
||||
|
||||
throw new ForbiddenException(
|
||||
$"Bước {stepIdx + 1} ({step.Name}) — Cấp {levelOrder}: bạn không có trong danh sách NV duyệt của cấp này.");
|
||||
}
|
||||
|
||||
private static List<ApprovalWorkflowLevel> LevelsOf(ApprovalWorkflowStep step, int levelOrder)
|
||||
=> step.Levels.Where(l => l.Order == levelOrder).ToList();
|
||||
|
||||
/// UPSERT 1 row / cặp (Plan × Level) — UNIQUE `(PlanId, LevelId)`
|
||||
/// (`ContractSigningPlanLevelOpinionConfiguration.cs:36`). Duyệt lại cùng Cấp (sau khi bị
|
||||
/// trả lại) = CẬP NHẬT dòng cũ; `Add` thẳng sẽ nổ UNIQUE hoặc đẻ dòng thứ hai.
|
||||
private async Task UpsertLevelOpinionAsync(
|
||||
ContractSigningPlan plan,
|
||||
ApprovalWorkflowLevel actingLevel,
|
||||
Guid actorId,
|
||||
string? comment,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var text = string.IsNullOrWhiteSpace(comment) ? NoOpinionPlaceholder : comment;
|
||||
var signedByFullName = await ResolveActorFullNameAsync(actorId, ct);
|
||||
var now = dateTime.UtcNow;
|
||||
|
||||
var existing = await db.ContractSigningPlanLevelOpinions
|
||||
.FirstOrDefaultAsync(o => o.ContractSigningPlanId == plan.Id
|
||||
&& o.ApprovalWorkflowLevelId == actingLevel.Id, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
db.ContractSigningPlanLevelOpinions.Add(new ContractSigningPlanLevelOpinion
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
ApprovalWorkflowLevelId = actingLevel.Id,
|
||||
Comment = text,
|
||||
SignedAt = now,
|
||||
SignedByUserId = actorId,
|
||||
SignedByFullName = signedByFullName,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// latest-write-wins TRỌN BỘ 4 field: giữ lại chữ/người/giờ cũ = hồ sơ nói sai
|
||||
// sự thật về lần ký mới.
|
||||
existing.Comment = text;
|
||||
existing.SignedAt = now;
|
||||
existing.SignedByUserId = actorId;
|
||||
existing.SignedByFullName = signedByFullName;
|
||||
}
|
||||
}
|
||||
|
||||
/// Method ghi lịch sử KHKK-RIÊNG (spec §②-4). Ghi CẢ HAI bảng trong cùng unit-of-work:
|
||||
/// • `ContractSigningPlanApprovals` — 1 row / transition (ai, Cấp nào, quyết định gì)
|
||||
/// • `ContractSigningPlanChangelogs` — 1 row / transition (nhật ký đọc-được cho người dùng)
|
||||
/// 🔴 KHÔNG gọi `IChangelogService.LogWorkflowTransitionAsync` — xem lý do FK-547 đầu file.
|
||||
/// Kèm notify NGƯỜI SOẠN (mirror `ContractWorkflowService.cs:407-427`); notify approver
|
||||
/// Cấp kế nằm riêng ở `NotifyPendingApproversAsync` (vế THÊM của spec §②-6).
|
||||
private async Task LogSigningPlanTransitionAsync(
|
||||
ContractSigningPlan plan,
|
||||
ContractSigningPlanPhase fromPhase,
|
||||
ContractSigningPlanPhase toPhase,
|
||||
Guid actorId,
|
||||
ApprovalDecision decision,
|
||||
string? comment,
|
||||
Guid? actingLevelId,
|
||||
string summary,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = dateTime.UtcNow;
|
||||
|
||||
db.ContractSigningPlanApprovals.Add(new ContractSigningPlanApproval
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
FromPhase = fromPhase,
|
||||
ToPhase = toPhase,
|
||||
Decision = decision,
|
||||
Comment = comment,
|
||||
ApprovalWorkflowLevelId = actingLevelId,
|
||||
ApprovedByUserId = actorId,
|
||||
ApprovedAt = now,
|
||||
});
|
||||
|
||||
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
|
||||
{
|
||||
ContractSigningPlanId = plan.Id,
|
||||
EntityType = ContractSigningPlanEntityType.Workflow,
|
||||
EntityId = actingLevelId,
|
||||
Action = ChangelogAction.Transition,
|
||||
PhaseAtChange = toPhase,
|
||||
UserId = actorId,
|
||||
UserName = Truncate(await ResolveActorFullNameAsync(actorId, ct), 200),
|
||||
Summary = Truncate(summary, 1000),
|
||||
ContextNote = comment,
|
||||
});
|
||||
|
||||
// Notify NGƯỜI SOẠN — trừ khi chính họ vừa bấm (khỏi tự báo cho mình).
|
||||
if (plan.DrafterUserId != Guid.Empty && plan.DrafterUserId != actorId)
|
||||
{
|
||||
var label = plan.MaKeHoach ?? "Kế hoạch ký kết";
|
||||
var (title, type) = toPhase switch
|
||||
{
|
||||
ContractSigningPlanPhase.DaDuyet =>
|
||||
($"Kế hoạch ký kết {label} đã được duyệt", NotificationType.ContractPublished),
|
||||
ContractSigningPlanPhase.TuChoi =>
|
||||
($"Kế hoạch ký kết {label} bị từ chối", NotificationType.ContractRejected),
|
||||
ContractSigningPlanPhase.TraLai =>
|
||||
($"Kế hoạch ký kết {label} bị trả lại — vui lòng sửa và trình lại",
|
||||
NotificationType.ContractRejected),
|
||||
_ => ($"Kế hoạch ký kết {label} chuyển trạng thái", NotificationType.ContractPhaseTransition),
|
||||
};
|
||||
await notifications.NotifyAsync(
|
||||
plan.DrafterUserId, type, title,
|
||||
description: summary + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||
href: $"/khkk/{plan.Id}",
|
||||
refId: plan.Id,
|
||||
ct: ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vế THÊM của spec §②-6 — báo ĐÍCH DANH NV của Cấp đang chờ (khuôn đã chạy thật ở PE
|
||||
/// `PurchaseEvaluationWorkflowService.cs:1243-1289`), vá lỗ "chỉ người soạn được báo".
|
||||
/// Bỏ qua: chính người vừa bấm, và NGƯỜI SOẠN — họ đã thấy phiếu ở màn "phiếu của tôi"
|
||||
/// và nhận chuông KẾT QUẢ ở trên (bài UAT S86).
|
||||
private async Task NotifyPendingApproversAsync(
|
||||
ContractSigningPlan plan,
|
||||
IEnumerable<ApprovalWorkflowLevel> pendingLevels,
|
||||
Guid actorId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var approverIds = pendingLevels
|
||||
.Select(l => l.ApproverUserId)
|
||||
.Where(id => id != Guid.Empty && id != actorId && id != plan.DrafterUserId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (approverIds.Count == 0) return;
|
||||
|
||||
await notifications.NotifyManyAsync(
|
||||
approverIds,
|
||||
NotificationType.Generic,
|
||||
$"Kế hoạch ký kết cần bạn duyệt: {plan.MaKeHoach ?? "(chưa có mã)"}",
|
||||
"Có kế hoạch ký kết HĐ đang chờ bạn duyệt.",
|
||||
$"/khkk/{plan.Id}",
|
||||
plan.Id,
|
||||
ct);
|
||||
}
|
||||
|
||||
/// Snapshot tên người ký (`SignedByFullName` là cột NOT NULL 200 ký tự —
|
||||
/// `ContractSigningPlanLevelOpinionConfiguration.cs:24`). Khuôn
|
||||
/// `ContractWorkflowService.cs:433-442`; thêm vế cuối lấy từ token vì service này lấy
|
||||
/// actor qua `ICurrentUser` chứ không qua `UserManager`.
|
||||
private async Task<string> ResolveActorFullNameAsync(Guid actorId, CancellationToken ct)
|
||||
{
|
||||
var user = await db.Users.AsNoTracking()
|
||||
.Where(u => u.Id == actorId)
|
||||
.Select(u => new { u.FullName, u.UserName })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (user is not null && !string.IsNullOrWhiteSpace(user.FullName)) return Truncate(user.FullName, 200);
|
||||
if (user is not null && !string.IsNullOrWhiteSpace(user.UserName)) return Truncate(user.UserName, 200);
|
||||
if (!string.IsNullOrWhiteSpace(currentUser.FullName)) return Truncate(currentUser.FullName!, 200);
|
||||
if (!string.IsNullOrWhiteSpace(currentUser.Email)) return Truncate(currentUser.Email!, 200);
|
||||
return "(unknown)";
|
||||
}
|
||||
|
||||
/// Cắt AN TOÀN cho các chuỗi service TỰ DỰNG (tên Bước do admin đặt có thể dài).
|
||||
/// Ý kiến của người dùng KHÔNG đi qua đây — chuỗi đó bị chặn ở cổng
|
||||
/// (`MaxCommentLength`) để không bao giờ bị cắt ngầm mất chữ.
|
||||
private static string Truncate(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max];
|
||||
}
|
||||
@ -0,0 +1,729 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces; // ICurrentUser — bơm cho AuditingInterceptor
|
||||
using SolutionErp.Application.ContractSigningPlans; // GetMyContractSigningPlanInboxQuery (W2, đã sống)
|
||||
using SolutionErp.Application.ContractSigningPlans.Services;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.ContractSigningPlans;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [W3 KHKK — S161 2026-07-29] Duyệt 3 trạm phiếu "Kế hoạch ký kết HĐ" (GĐ2) — **TEST-BEFORE**.
|
||||
//
|
||||
// 🔴 ĐỢT NÀY 5/6 TEST PHẢI ĐỎ. Service mới chỉ là skeleton: mọi nhánh ném
|
||||
// `NotImplementedException("W3 đợt-3: <action>")` (`ContractSigningPlanWorkflowService.cs:108/113/117/121`).
|
||||
// Đỏ-vì-CHƯA-LÀM (không phải đỏ-vì-không-biên-dịch-được) chính là bằng chứng duy nhất
|
||||
// rằng 6 lưới này có răng — đợt-3 điền logic xong chúng phải TỰ xanh, không được sửa test
|
||||
// cho vừa. Test số 4 (Inbox) đi qua query handler W2 đã sống ⇒ **xanh ngay hôm nay**, cố ý
|
||||
// không ép đỏ; nó đồng thời là phép thử "seed có hợp lệ không" cho 5 test còn lại (cùng
|
||||
// bộ helper: nếu FK/So-sánh seed sai thì test 4 đỏ trước tiên).
|
||||
//
|
||||
// Tên 6 test PIN theo spec W3 §③-B (`spec-wave-w3-duyet-3-tram-29-07-2026.md:35`) — thiếu 1
|
||||
// tên là TRƯỢT acceptance, KHÔNG đổi chữ.
|
||||
//
|
||||
// ── Đo cái gì (map 1:1 spec §③-B) ──────────────────────────────────────────────
|
||||
// T1 OR-of-N trọn 4 vế (a)(b)(c)(d) T2 con-trỏ ĐÔI + reset Cấp
|
||||
// T3 finalize `AND PlanId=@id`, 2 write-path T4 Inbox đúng người đúng Cấp
|
||||
// T5 UPSERT ý kiến 1 row T6 Trả lại → gửi lại chạy LẠI từ Bước 1 Cấp 1
|
||||
//
|
||||
// ── 3 quyết định hạ tầng (đều là ĐIỀU KIỆN để phép đo có nghĩa) ────────────────
|
||||
//
|
||||
// (1) Fixture RIÊNG có `AuditingInterceptor` (mirror `ContractSigningPlanCrudTests.cs:49-82`).
|
||||
// Guard trình đợt-3 = `CreatedBy == actor ∨ DeptManager ∨ Admin`
|
||||
// (`ContractSigningPlanWorkflowService.cs:58-59`). `TestApplicationDbContext` trần KHÔNG
|
||||
// wire interceptor ⇒ `CreatedBy` của MỌI phiếu seed sẽ là null ⇒ vế đầu của guard không
|
||||
// bao giờ đúng và `submit` đỏ vì lý do SAI. Seed phiếu bằng context của chính người soạn
|
||||
// ⇒ `CreatedBy` == `DrafterUserId` (đợt-3 đọc cột nào cũng ra cùng một người).
|
||||
//
|
||||
// (2) MỖI transition chạy trên MỘT DbContext MỚI (mô phỏng scoped-per-request của prod).
|
||||
// Dùng lại 1 context cho cả seed lẫn lệnh sẽ để EF track sẵn Lines/LevelOpinions —
|
||||
// một đường đi KHÔNG có thật ở prod (ở đó context mới chỉ nạp thứ service tự Include).
|
||||
//
|
||||
// (3) User seed THẲNG `db.Users.Add(...)`, KHÔNG kéo `IdentityFixture`/`UserManager`:
|
||||
// service nhận role qua `ICurrentUser.Roles` (lane BE QUYẾT-2 cố ý không inject
|
||||
// UserManager), còn `ApprovalWorkflowLevel.ApproverUserId` chỉ cần **row tồn tại**
|
||||
// (FK Restrict — `ApprovalWorkflowConfiguration.cs:72-75`), không cần đăng nhập được.
|
||||
public class ContractSigningPlanApprovalTests
|
||||
{
|
||||
private static readonly DateTime Clock2026 = new(2026, 7, 29, 3, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ===================================================================
|
||||
// Fixture: SQLite in-memory + AuditingInterceptor, 1 connection dùng chung.
|
||||
// ===================================================================
|
||||
private sealed class KhkkWorkflowFixture : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _conn;
|
||||
public FixedDateTime Clock { get; } = new(Clock2026);
|
||||
|
||||
public KhkkWorkflowFixture()
|
||||
{
|
||||
_conn = new SqliteConnection("DataSource=:memory:");
|
||||
_conn.Open();
|
||||
using var boot = NewDb(TestCurrentUser.System());
|
||||
boot.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
/// Context MỚI cho mỗi "request". `actor` phải là ĐÚNG người mà lệnh chạy dưới danh
|
||||
/// nghĩa — prod resolve CÙNG một `ICurrentUser` scoped cho cả interceptor lẫn service,
|
||||
/// tách hai ra thì `CreatedBy`/`UpdatedBy` ghi ra người sai *theo kiểu trông như đúng*.
|
||||
public TestApplicationDbContext NewDb(ICurrentUser actor)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseSqlite(_conn)
|
||||
.EnableSensitiveDataLogging()
|
||||
.AddInterceptors(new AuditingInterceptor(actor, Clock))
|
||||
.Options;
|
||||
return new TestApplicationDbContext(options);
|
||||
}
|
||||
|
||||
public void Dispose() => _conn.Dispose();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Seed helpers
|
||||
// ===================================================================
|
||||
|
||||
private static TestCurrentUser As(User u, params string[] roles)
|
||||
=> new(u.Id, u.FullName, u.Email, roles);
|
||||
|
||||
/// Row `AspNetUsers` tối thiểu nhưng ĐỦ THẬT: FK `Level.ApproverUserId` Restrict cần
|
||||
/// row tồn tại, còn `SignedByFullName` (snapshot người ký) cần `FullName` khác rỗng.
|
||||
private static async Task<User> AddUserAsync(KhkkWorkflowFixture f, string fullName)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
var email = $"u{Guid.NewGuid():N}@test.local";
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FullName = fullName,
|
||||
UserName = email,
|
||||
NormalizedUserName = email.ToUpperInvariant(),
|
||||
Email = email,
|
||||
NormalizedEmail = email.ToUpperInvariant(),
|
||||
EmailConfirmed = true,
|
||||
SecurityStamp = Guid.NewGuid().ToString("N"),
|
||||
ConcurrencyStamp = Guid.NewGuid().ToString("N"),
|
||||
IsActive = true,
|
||||
CreatedAt = Clock2026,
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return user;
|
||||
}
|
||||
|
||||
// `steps[i][j]` = danh sách NV của **Cấp j+1** trong **Bước i+1**. Nhiều NV trong cùng một
|
||||
// Cấp = OR-of-N (`ApprovalWorkflow.cs:85-88`).
|
||||
//
|
||||
// `stepOrderStart` / `stepOrderStride`: CỐ Ý cho phép `Step.Order` KHÔNG bắt đầu từ 1 và
|
||||
// không liền nhau. `CurrentWorkflowStepIndex` là **INDEX vào list đã sort theo Order**, KHÔNG
|
||||
// phải giá trị `Order` — mã W2 đã chốt semantic đó (`ContractSigningPlanFeatures.cs:256-260`
|
||||
// `steps[idx]`). T2 dùng Order 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn.
|
||||
private static async Task<Guid> SeedWorkflowAsync(
|
||||
KhkkWorkflowFixture f,
|
||||
string code,
|
||||
Guid[][][] steps,
|
||||
int stepOrderStart = 1,
|
||||
int stepOrderStride = 1)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = code,
|
||||
Version = 1,
|
||||
Name = "Quy trình duyệt Kế hoạch ký kết",
|
||||
// Loại phải là ContractSigningPlan(10) — quy trình module khác bị rào Create
|
||||
// của W2 chặn (`ContractSigningPlanFeatures.cs:218-219`).
|
||||
ApplicableType = ApprovalWorkflowApplicableType.ContractSigningPlan,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
|
||||
for (var s = 0; s < steps.Length; s++)
|
||||
{
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = stepOrderStart + (s * stepOrderStride),
|
||||
Name = $"Bước {s + 1}",
|
||||
DepartmentId = null, // null = bỏ qua FK phòng ban (khuôn Pattern 11)
|
||||
};
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
|
||||
for (var l = 0; l < steps[s].Length; l++)
|
||||
{
|
||||
foreach (var approverId in steps[s][l])
|
||||
{
|
||||
db.ApprovalWorkflowLevels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = l + 1, // Cấp 1-based
|
||||
Name = $"Cấp {l + 1}",
|
||||
ApproverUserId = approverId,
|
||||
// 5 cờ Allow* GIỮ MẶC ĐỊNH: W3 default dry-run = đường THƯỜNG duy nhất,
|
||||
// KHÔNG bật kết-thúc-sớm (spec §②-3b — chủ dự án phán "PHÁ VỠ" @S155).
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf.Id;
|
||||
}
|
||||
|
||||
private sealed record LineSeed(decimal PeReference, decimal Proposed, decimal? Approved = null);
|
||||
|
||||
private static async Task<Guid> SeedPlanAsync(
|
||||
KhkkWorkflowFixture f,
|
||||
TestCurrentUser drafter,
|
||||
Guid workflowId,
|
||||
string maKeHoach,
|
||||
ContractSigningPlanPhase phase,
|
||||
int? stepIndex,
|
||||
int? levelOrder,
|
||||
LineSeed[] lines)
|
||||
{
|
||||
// Seed dưới danh nghĩa NGƯỜI SOẠN ⇒ interceptor ghi `CreatedBy = drafter` (xem ghi chú (1)).
|
||||
await using var db = f.NewDb(drafter);
|
||||
|
||||
var plan = new ContractSigningPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MaKeHoach = maKeHoach,
|
||||
// 4 cột dưới là loose-Guid KHÔNG FK vật lý (`ContractSigningPlanConfiguration.cs:10-12`)
|
||||
// ⇒ W3 không phải seed PE/Project/Department như W2.
|
||||
PurchaseEvaluationId = Guid.NewGuid(),
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DepartmentId = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.UserId!.Value,
|
||||
Phase = phase,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = stepIndex,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
};
|
||||
|
||||
foreach (var l in lines)
|
||||
{
|
||||
plan.Lines.Add(new ContractSigningPlanLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ContractSigningPlanId = plan.Id,
|
||||
SupplierId = Guid.NewGuid(), // loose-Guid (`ContractSigningPlanLine.cs:15`)
|
||||
PeReferenceAmount = l.PeReference,
|
||||
ProposedAmount = l.Proposed,
|
||||
ApprovedAmount = l.Approved,
|
||||
});
|
||||
}
|
||||
|
||||
db.ContractSigningPlans.Add(plan);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Act / Read helpers — gọi service THẬT, KHÔNG mock.
|
||||
// ===================================================================
|
||||
|
||||
private static async Task<ContractSigningPlanTransitionResult> ActAsync(
|
||||
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId, string action, string? comment = null)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var svc = new ContractSigningPlanWorkflowService(db, actor, f.Clock, new NoOpNotificationServiceApp());
|
||||
return await svc.TransitionAsync(planId, action, comment, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<ContractSigningPlan> ReadPlanAsync(KhkkWorkflowFixture f, Guid planId)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
return await db.ContractSigningPlans.AsNoTracking()
|
||||
.Include(p => p.Lines)
|
||||
.SingleAsync(p => p.Id == planId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<List<ContractSigningPlanLevelOpinion>> ReadOpinionsAsync(
|
||||
KhkkWorkflowFixture f, Guid planId)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
// Lọc `WHERE PlanId = @id` ĐÚNG như acceptance §③-B yêu cầu cho Lines: nếu đợt-3
|
||||
// quên vế này ở bất kỳ đâu thì phiếu hàng xóm sẽ dính, và các assert "phiếu chứng
|
||||
// nhân bất động" bên dưới bắt được.
|
||||
return await db.ContractSigningPlanLevelOpinions.AsNoTracking()
|
||||
.Where(o => o.ContractSigningPlanId == planId)
|
||||
.ToListAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<Guid> LevelIdOfAsync(KhkkWorkflowFixture f, Guid approverUserId)
|
||||
{
|
||||
await using var db = f.NewDb(TestCurrentUser.System());
|
||||
return await db.ApprovalWorkflowLevels.AsNoTracking()
|
||||
.Where(l => l.ApproverUserId == approverUserId)
|
||||
.Select(l => l.Id)
|
||||
.SingleAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task<List<ContractSigningPlanListItemDto>> InboxAsync(
|
||||
KhkkWorkflowFixture f, TestCurrentUser actor)
|
||||
{
|
||||
await using var db = f.NewDb(actor);
|
||||
var handler = new GetMyContractSigningPlanInboxQueryHandler(db, actor);
|
||||
return await handler.Handle(new GetMyContractSigningPlanInboxQuery(), CancellationToken.None);
|
||||
}
|
||||
|
||||
/// Con-trỏ ĐÔI + phase là MỘT bộ ba — kiểm cả ba cùng lúc để không có chuyện
|
||||
/// "phase đúng mà con-trỏ trôi" lọt lưới.
|
||||
private static void ShouldBeAt(
|
||||
ContractSigningPlan plan, ContractSigningPlanPhase phase, int? stepIndex, int? levelOrder, string because)
|
||||
{
|
||||
plan.Phase.Should().Be(phase, because);
|
||||
plan.CurrentWorkflowStepIndex.Should().Be(stepIndex, because);
|
||||
plan.CurrentApprovalLevelOrder.Should().Be(levelOrder, because);
|
||||
}
|
||||
|
||||
/// 3 field response (FE đọc để vẽ lại panel ngay, không refetch) phải KHỚP DB.
|
||||
/// Lệch = FE hiển thị một trạng thái không tồn tại cho tới lần F5 kế.
|
||||
private static void ResultShouldMirror(ContractSigningPlanTransitionResult r, ContractSigningPlan plan)
|
||||
{
|
||||
r.Phase.Should().Be((int)plan.Phase, "response `phase` là int (hợp-đồng literal) và phải bằng phase đã ghi DB");
|
||||
r.CurrentWorkflowStepIndex.Should().Be(plan.CurrentWorkflowStepIndex, "response phải soi đúng con-trỏ Bước trong DB");
|
||||
r.CurrentApprovalLevelOrder.Should().Be(plan.CurrentApprovalLevelOrder, "response phải soi đúng con-trỏ Cấp trong DB");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T1) OR-of-N — acceptance #1 TRỌN 4 VẾ (a)(b)(c)(d), spec §③-B `:28`.
|
||||
//
|
||||
// Đây là lưới DUY NHẤT bắt được lỗi chết người của wave này: copy nhầm khuôn
|
||||
// `ProposalFeatures` (flatten `SelectMany` các Level) làm OR-of-N biến thành AND **ÂM
|
||||
// THẦM** — build sạch, 574 test cũ vẫn xanh, chỉ vỡ khi phiếu thật treo giữa chừng vì
|
||||
// chờ đủ chữ ký. Vì thế:
|
||||
// (a) 2 phiếu SONG SINH cùng quy trình: phiếu-1 do A duyệt, phiếu-2 do B duyệt;
|
||||
// (d) mỗi phiếu tiến với ĐÚNG MỘT chữ ký — nếu đòi cả hai thì con-trỏ đứng im ở Cấp 1;
|
||||
// (c) mỗi phiếu ghi ĐÚNG 1 dòng ý kiến, và dòng đó trỏ Level của CHÍNH người ký;
|
||||
// (b) ca ÂM 2 lớp: người sai Cấp (C, Cấp 2 chưa tới lượt) và người NGOÀI quy trình (D).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures()
|
||||
{
|
||||
using var f = new KhkkWorkflowFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var a = await AddUserAsync(f, "NV Cung ứng A");
|
||||
var b = await AddUserAsync(f, "NV Cung ứng B");
|
||||
var c = await AddUserAsync(f, "Trưởng phòng C");
|
||||
var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");
|
||||
|
||||
// 1 Bước — Cấp 1 = {A, B} (OR-of-N), Cấp 2 = {C}.
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-ORN", steps:
|
||||
[
|
||||
[[a.Id, b.Id], [c.Id]],
|
||||
]);
|
||||
|
||||
LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
|
||||
var p1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/001", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||
var p2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/002", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||
var p3 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/003", ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||
|
||||
// Sanity TRƯỚC khi gọi service: seed đặt đúng lượt Cấp 1 Bước 1. Thiếu chốt này thì
|
||||
// một seed hỏng (con-trỏ null) vẫn cho ra assert xanh ở thế giới sai.
|
||||
ShouldBeAt(await ReadPlanAsync(f, p1), ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||
"seed phải đặt phiếu ĐÚNG lượt Cấp 1 Bước 1 — sai seed thì test đo chính nó");
|
||||
|
||||
// ---- (a) + (d): A duyệt phiếu-1, B duyệt phiếu-2. Cả hai đều TIẾN.
|
||||
await ActAsync(f, As(a), p1, "approve", "A đồng ý đơn giá");
|
||||
var after1 = await ReadPlanAsync(f, p1);
|
||||
ShouldBeAt(after1, ContractSigningPlanPhase.ChoDuyet, 0, 2,
|
||||
"MỘT người trong Cấp 1 duyệt là Cấp 1 xong ⇒ con-trỏ sang Cấp 2 CÙNG Bước; "
|
||||
+ "nếu quy trình đòi đủ cả A lẫn B (AND) thì con-trỏ còn đứng ở Cấp 1");
|
||||
|
||||
await ActAsync(f, As(b), p2, "approve", "B đồng ý đơn giá");
|
||||
ShouldBeAt(await ReadPlanAsync(f, p2), ContractSigningPlanPhase.ChoDuyet, 0, 2,
|
||||
"phiếu song sinh: người CÒN LẠI của cùng Cấp duyệt cũng phải tiến y hệt — "
|
||||
+ "chỉ 1 trong 2 người tiến được là dấu hiệu match cứng theo NV đầu danh sách");
|
||||
|
||||
// Chưa phải cấp cuối ⇒ TUYỆT ĐỐI chưa được chốt giá.
|
||||
after1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||
"giá chốt CHỈ sinh tại cấp cuối cùng — chốt sớm là ký thay người chưa duyệt");
|
||||
|
||||
// ---- (c) ĐÚNG 1 dòng ý kiến / phiếu, trỏ Level của CHÍNH người ký.
|
||||
var op1 = await ReadOpinionsAsync(f, p1);
|
||||
op1.Should().ContainSingle("mỗi lượt duyệt ghi đúng 1 dòng ý kiến cho Cấp đó (UNIQUE PlanId × LevelId)");
|
||||
op1[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, a.Id),
|
||||
"ý kiến phải gắn vào row Level CỦA NGƯỜI KÝ; gắn nhầm row của B thì màn Ý kiến cấp duyệt "
|
||||
+ "hiện chữ ký treo dưới tên người chưa hề bấm nút");
|
||||
op1[0].SignedByUserId.Should().Be(a.Id);
|
||||
|
||||
var op2 = await ReadOpinionsAsync(f, p2);
|
||||
op2.Should().ContainSingle();
|
||||
op2[0].ApprovalWorkflowLevelId.Should().Be(await LevelIdOfAsync(f, b.Id),
|
||||
"hai phiếu song sinh ký bởi hai người khác nhau ⇒ hai LevelId khác nhau");
|
||||
op2[0].SignedByUserId.Should().Be(b.Id);
|
||||
|
||||
// ---- (b) ca ÂM lớp 1: C có chân trong quy trình nhưng Ở CẤP KHÁC, chưa tới lượt.
|
||||
Func<Task> actWrongLevel = async () => await ActAsync(f, As(c), p3, "approve", "duyệt vượt lượt");
|
||||
await actWrongLevel.Should().ThrowAsync<ForbiddenException>(
|
||||
"NV Cấp 2 KHÔNG được ký thay Cấp 1 — đây là chỗ mất trắng vòng kiểm soát nếu lọt");
|
||||
|
||||
// ---- (b) ca ÂM lớp 2: người ngoài quy trình.
|
||||
Func<Task> actOutsider = async () => await ActAsync(f, As(outsider), p3, "approve", "tôi ký hộ");
|
||||
await actOutsider.Should().ThrowAsync<ForbiddenException>(
|
||||
"người không có trong Cấp nào của quy trình thì không phải approver");
|
||||
|
||||
// Ném rồi mà vẫn ghi còn tệ hơn không rào: phiếu p3 phải NGUYÊN VẸN.
|
||||
ShouldBeAt(await ReadPlanAsync(f, p3), ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||
"hai lần bị chặn ⇒ con-trỏ p3 bất động");
|
||||
(await ReadOpinionsAsync(f, p3)).Should().BeEmpty("bị chặn thì KHÔNG được để lại chữ ký");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T2) Con-trỏ ĐÔI — acceptance #2 (spec §③-B `:29`).
|
||||
//
|
||||
// 3 Bước × 2 Cấp, chạy trọn 6 lượt duyệt. Hai điểm dễ hỏng nhất:
|
||||
// • hết Cấp trong Bước ⇒ sang Bước kế **và Cấp phải RESET về 1** (quên reset thì Bước 2
|
||||
// bắt đầu từ Cấp 2, Cấp 1 của Bước 2 bị bỏ qua vĩnh viễn — mất một chữ ký thật);
|
||||
// • `CurrentWorkflowStepIndex` là **INDEX** vào list đã sort, KHÔNG phải `Step.Order`.
|
||||
// Seed cố ý đặt Order = 5/15/25 để hai đại lượng này không thể trùng nhau do may mắn;
|
||||
// mã W2 đã đọc theo INDEX (`ContractSigningPlanFeatures.cs:256-260`) nên lệch semantic
|
||||
// ở đây = inbox mù ngay lập tức.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_NewStep_ResetsLevelOrderToOne()
|
||||
{
|
||||
using var f = new KhkkWorkflowFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var s1c1 = await AddUserAsync(f, "PMH cấp 1");
|
||||
var s1c2 = await AddUserAsync(f, "PMH cấp 2");
|
||||
var s2c1 = await AddUserAsync(f, "CCM cấp 1");
|
||||
var s2c2 = await AddUserAsync(f, "CCM cấp 2");
|
||||
var s3c1 = await AddUserAsync(f, "Thư ký CEO");
|
||||
var s3c2 = await AddUserAsync(f, "CEO");
|
||||
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-3TRAM", steps:
|
||||
[
|
||||
[[s1c1.Id], [s1c2.Id]], // Bước 1 (PMH)
|
||||
[[s2c1.Id], [s2c2.Id]], // Bước 2 (CCM)
|
||||
[[s3c1.Id], [s3c2.Id]], // Bước 3 (CEO)
|
||||
],
|
||||
stepOrderStart: 5, stepOrderStride: 10); // Order 5/15/25 ≠ index 0/1/2
|
||||
|
||||
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/010",
|
||||
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 900m, Proposed: 800m)]);
|
||||
|
||||
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1, "sanity seed");
|
||||
|
||||
// 1) Trong cùng Bước 1: Cấp 1 → Cấp 2.
|
||||
var r1 = await ActAsync(f, As(s1c1), planId, "approve", "Bước 1 Cấp 1");
|
||||
var p1 = await ReadPlanAsync(f, planId);
|
||||
ShouldBeAt(p1, ContractSigningPlanPhase.ChoDuyet, 0, 2, "còn Cấp trong Bước ⇒ chỉ tăng Cấp, Bước giữ nguyên");
|
||||
ResultShouldMirror(r1, p1);
|
||||
|
||||
// 2) Hết Cấp Bước 1 ⇒ Bước 2 (index 1) + Cấp RESET về 1.
|
||||
var r2 = await ActAsync(f, As(s1c2), planId, "approve", "Bước 1 Cấp 2");
|
||||
var p2 = await ReadPlanAsync(f, planId);
|
||||
ShouldBeAt(p2, ContractSigningPlanPhase.ChoDuyet, 1, 1,
|
||||
"sang Bước MỚI thì Cấp phải reset về 1; giữ nguyên 2 là Cấp 1 của Bước 2 không bao giờ được ký");
|
||||
ResultShouldMirror(r2, p2);
|
||||
|
||||
// 3) Bước 2: Cấp 1 → Cấp 2.
|
||||
await ActAsync(f, As(s2c1), planId, "approve", "Bước 2 Cấp 1");
|
||||
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 2, "trong Bước 2");
|
||||
|
||||
// 4) Reset LẦN HAI — một lần đúng có thể do hardcode, hai lần thì không.
|
||||
await ActAsync(f, As(s2c2), planId, "approve", "Bước 2 Cấp 2");
|
||||
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 2, 1,
|
||||
"reset Cấp lần 2 (Bước 3) — chứng đây là quy tắc, không phải trùng hợp ở Bước 2");
|
||||
|
||||
// 5) Bước 3 Cấp 1 → Cấp 2: chưa xong, TUYỆT ĐỐI chưa DaDuyet.
|
||||
await ActAsync(f, As(s3c1), planId, "approve", "Bước 3 Cấp 1");
|
||||
var p5 = await ReadPlanAsync(f, planId);
|
||||
ShouldBeAt(p5, ContractSigningPlanPhase.ChoDuyet, 2, 2,
|
||||
"còn Cấp cuối chưa ký ⇒ chưa được kết thúc (W3 đường THƯỜNG: CEO ký thật, không kết-thúc-sớm)");
|
||||
|
||||
// 6) Cấp cuối Bước cuối ⇒ terminal, HAI con-trỏ về null.
|
||||
var r6 = await ActAsync(f, As(s3c2), planId, "approve", "CEO duyệt");
|
||||
var p6 = await ReadPlanAsync(f, planId);
|
||||
ShouldBeAt(p6, ContractSigningPlanPhase.DaDuyet, null, null,
|
||||
"hết Bước cuối ⇒ DaDuyet và cả 2 con-trỏ = null (`ContractSigningPlan.cs:34` 'Null khi terminal'); "
|
||||
+ "để sót con-trỏ thì phiếu đã duyệt vẫn nằm trong hộp thư chờ của ai đó");
|
||||
ResultShouldMirror(r6, p6);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T3) Finalize chốt giá — acceptance #3 (spec §③-B `:30`).
|
||||
//
|
||||
// Hai thứ được đo cùng lúc:
|
||||
// • `AND PlanId = @id`: phiếu CHỨNG NHÂN (planB, cùng quy trình, cùng đang chờ) phải
|
||||
// BẤT ĐỘNG. Bug thật của lớp này là `Where(l => l.ApprovedAmount == null)` quên vế
|
||||
// PlanId — nó chốt giá cho mọi phiếu đang mở trong hệ thống, im lặng và không hoàn lại được.
|
||||
// • **2 WRITE-PATH** (spec F-B1): duyệt-cấp-cuối THƯỜNG *và* Admin duyệt-thay (bypass
|
||||
// danh sách approver — khuôn nguồn copy `ContractWorkflowService.cs:255` `if (!isAdmin…)`).
|
||||
// Helper `ApplyApprovedValuesOnFinalize` phải được gọi ở CẢ HAI; gọi một chỗ thì
|
||||
// phiếu do Admin chốt sẽ ở DaDuyet mà không có giá — GĐ3 sinh HĐ 0đ.
|
||||
//
|
||||
// Bẫy nguồn số: `ProposedAmount` ≠ `PeReferenceAmount` ở mọi dòng ⇒ lấy nhầm cột là lộ
|
||||
// ngay (`ApprovedAmount ??= ProposedAmount` — spec §②-3a). Dòng thứ 3 đã có giá sẵn ⇒
|
||||
// đo đúng ngữ nghĩa `??=` (giữ, KHÔNG đè).
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan()
|
||||
{
|
||||
using var f = new KhkkWorkflowFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var approver = await AddUserAsync(f, "CEO ký duyệt");
|
||||
var adminUser = await AddUserAsync(f, "Quản trị hệ thống");
|
||||
var admin = As(adminUser, AppRoles.Admin); // KHÔNG có chân trong quy trình
|
||||
|
||||
// 1 Bước × 1 Cấp ⇒ một lượt duyệt là terminal.
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-1CAP", steps: [[[approver.Id]]]);
|
||||
|
||||
var planA = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/020",
|
||||
ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||
[
|
||||
new(PeReference: 999m, Proposed: 111m),
|
||||
new(PeReference: 888m, Proposed: 222m),
|
||||
new(PeReference: 777m, Proposed: 333m, Approved: 300m), // đã có giá chốt sẵn
|
||||
]);
|
||||
var planB = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/021",
|
||||
ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||
[new(PeReference: 555m, Proposed: 55m), new(PeReference: 444m, Proposed: 44m)]);
|
||||
var planC = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/022",
|
||||
ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||
[new(PeReference: 222m, Proposed: 22m), new(PeReference: 111m, Proposed: 11m)]);
|
||||
|
||||
// ---- WRITE-PATH 1: approver Cấp cuối duyệt.
|
||||
await ActAsync(f, As(approver), planA, "approve", "Chốt giá theo đề xuất BCH");
|
||||
|
||||
var a = await ReadPlanAsync(f, planA);
|
||||
a.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Cấp cuối Bước cuối ⇒ terminal");
|
||||
a.Lines.Should().NotContain(l => l.ApprovedAmount == null,
|
||||
"acceptance: `Lines WHERE PlanId=@id AND ApprovedAmount IS NULL` = 0");
|
||||
|
||||
var byProposed = a.Lines.ToDictionary(l => l.ProposedAmount);
|
||||
byProposed[111m].ApprovedAmount.Should().Be(111m,
|
||||
"giá chốt lấy từ `ProposedAmount` (BCH đề xuất), KHÔNG phải `PeReferenceAmount` 999 — "
|
||||
+ "lấy nhầm cột thì phiếu chốt bằng số tham chiếu cũ của phiếu Duyệt NCC");
|
||||
byProposed[222m].ApprovedAmount.Should().Be(222m);
|
||||
byProposed[333m].ApprovedAmount.Should().Be(300m,
|
||||
"`??=` GIỮ giá đã chốt sẵn (300), không đè bằng 333 — đè là xoá số người có thẩm quyền đã sửa");
|
||||
|
||||
// ---- Chứng nhân: phiếu hàng xóm BẤT ĐỘNG (vế `AND PlanId=@id`).
|
||||
var bAfter1 = await ReadPlanAsync(f, planB);
|
||||
bAfter1.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||
"phiếu KHÁC tuyệt đối không được dính giá chốt — thiếu vế PlanId là chốt giá cả hệ thống");
|
||||
bAfter1.Phase.Should().Be(ContractSigningPlanPhase.ChoDuyet, "và cũng không được nhảy phase");
|
||||
|
||||
// ---- WRITE-PATH 2: Admin duyệt thay (không nằm trong Cấp nào) — spec F-B1.
|
||||
admin.Roles.Should().Contain(AppRoles.Admin, "actor của write-path 2 phải THỰC SỰ mang role Admin");
|
||||
await ActAsync(f, admin, planC, "approve", "Admin duyệt thay CEO");
|
||||
|
||||
var c = await ReadPlanAsync(f, planC);
|
||||
c.Phase.Should().Be(ContractSigningPlanPhase.DaDuyet, "Admin bypass danh sách approver (khuôn `ContractWorkflowService.cs:255`)");
|
||||
c.Lines.Should().NotContain(l => l.ApprovedAmount == null,
|
||||
"đường Admin cũng PHẢI đi qua choke-point chốt giá — đây là write-path thứ 2 mà "
|
||||
+ "grep literal không nhìn thấy (spec F-B1)");
|
||||
c.Lines.Single(l => l.ProposedAmount == 22m).ApprovedAmount.Should().Be(22m);
|
||||
|
||||
// Chứng nhân lần 2 — sau CẢ HAI write-path vẫn phải sạch.
|
||||
(await ReadPlanAsync(f, planB)).Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||
"qua 2 đường finalize, phiếu chứng nhân vẫn không được đụng tới");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T4) Inbox — acceptance #4 (spec §③-B `:31`).
|
||||
//
|
||||
// 🔴 Test DUY NHẤT trong 6 cái KHÔNG chạm service ⇒ **XANH ngay đợt này** (query handler
|
||||
// W2 đã wire: `ContractSigningPlanFeatures.cs:725-752`). Cố ý không ép đỏ. Nó gánh thêm
|
||||
// vai trò lưới an toàn cho 5 test kia: dùng CHUNG bộ seed helper, nên nếu seed sai FK
|
||||
// hay sai con-trỏ thì test này đỏ TRƯỚC và ta biết ngay là lỗi test chứ không phải lỗi sản phẩm.
|
||||
//
|
||||
// Ba lớp phủ: đúng-Cấp (A, B thấy — OR-of-N) · khác-Cấp (C KHÔNG thấy phiếu Cấp 1) ·
|
||||
// ngoài-quy-trình + người-soạn (rỗng). Cộng phiếu nháp: KHÔNG vào hộp thư của ai.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task Inbox_OnlyPendingLevelApprover_SeesPlan()
|
||||
{
|
||||
using var f = new KhkkWorkflowFixture();
|
||||
var drafterUser = await AddUserAsync(f, "BCH lập kế hoạch");
|
||||
var drafter = As(drafterUser);
|
||||
var a = await AddUserAsync(f, "NV Cung ứng A");
|
||||
var b = await AddUserAsync(f, "NV Cung ứng B");
|
||||
var c = await AddUserAsync(f, "Trưởng phòng C");
|
||||
var outsider = await AddUserAsync(f, "Kế toán ngoài quy trình D");
|
||||
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-INBOX", steps:
|
||||
[
|
||||
[[a.Id, b.Id], [c.Id]],
|
||||
]);
|
||||
|
||||
LineSeed[] oneLine = [new(PeReference: 999m, Proposed: 111m)];
|
||||
// Tên biến = Cấp mà phiếu ĐANG CHỜ: `pendingLevel1` đứng ở Cấp 1 (việc của A/B),
|
||||
// `pendingLevel2` ở Cấp 2 (việc của C) — có ĐỦ CẢ HAI thì ca ÂM "cấp khác không thấy"
|
||||
// mới đo được cả hai chiều.
|
||||
// 🔧 [W3 đợt-3 sửa SEED] Dòng đầu trước đó là `0, 2` kèm chú "FAULT-INJECT tạm" — vết
|
||||
// của lượt tự-thử-lưới còn sót, làm CẢ HAI phiếu cùng đứng Cấp 2 ⇒ hộp thư A/B rỗng và
|
||||
// C thấy 2 phiếu. Đỏ vì SEED, KHÔNG phải vì sản phẩm (query handler W2 không đổi một
|
||||
// dòng nào ở đợt này). Mọi assert giữ NGUYÊN CHỮ.
|
||||
var pendingLevel1 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/030",
|
||||
ContractSigningPlanPhase.ChoDuyet, 0, 1, oneLine);
|
||||
var pendingLevel2 = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/031",
|
||||
ContractSigningPlanPhase.ChoDuyet, 0, 2, oneLine);
|
||||
var draft = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/032",
|
||||
ContractSigningPlanPhase.DangSoanThao, null, null, oneLine);
|
||||
|
||||
// A và B cùng Cấp 1 (OR-of-N) ⇒ CẢ HAI cùng thấy đúng một phiếu.
|
||||
(await InboxAsync(f, As(a))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
|
||||
"approver của Cấp ĐANG CHỜ thấy phiếu; phiếu đang ở Cấp 2 thì không phải việc của A");
|
||||
(await InboxAsync(f, As(b))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel1 },
|
||||
"OR-of-N: người thứ hai cùng Cấp cũng phải thấy, nếu không thì cả Cấp phụ thuộc 1 người");
|
||||
|
||||
// C ở Cấp 2 — thấy ĐÚNG phiếu đang chờ Cấp 2, KHÔNG thấy phiếu đang ở Cấp 1 (ca ÂM).
|
||||
(await InboxAsync(f, As(c))).Select(i => i.Id).Should().BeEquivalentTo(new[] { pendingLevel2 },
|
||||
"cấp khác KHÔNG được thấy phiếu chưa tới lượt mình — thấy = duyệt vượt lượt ngay từ hộp thư");
|
||||
|
||||
(await InboxAsync(f, As(outsider))).Should().BeEmpty("người ngoài quy trình không có việc gì ở đây");
|
||||
(await InboxAsync(f, drafter)).Should().BeEmpty(
|
||||
"người SOẠN không phải người DUYỆT — hộp thư là 'chờ tôi duyệt', không phải 'phiếu của tôi'");
|
||||
|
||||
// Phiếu nháp không xuất hiện ở bất kỳ hộp thư nào (con-trỏ null).
|
||||
var allInboxIds = (await InboxAsync(f, As(a))).Concat(await InboxAsync(f, As(b)))
|
||||
.Concat(await InboxAsync(f, As(c))).Select(i => i.Id).ToList();
|
||||
allInboxIds.Should().NotContain(draft, "phiếu Nháp chưa trình thì chưa chờ ai duyệt");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T5) UPSERT ý kiến — acceptance #5 (spec §③-B `:32`).
|
||||
//
|
||||
// "Duyệt 2 lần CÙNG MỘT CẤP" chỉ xảy ra theo đúng một đường ở W3 đường-THƯỜNG: ký →
|
||||
// bị trả lại → gửi lại → ký lại. Dùng CHÍNH một người cho cả hai lượt để `LevelId` không
|
||||
// đổi, nhờ vậy phép đo "1 row" là rõ nghĩa dưới mọi cách đọc.
|
||||
//
|
||||
// Nếu đợt-3 viết `Add(...)` thẳng thay vì UPSERT: hoặc nổ UNIQUE (PlanId, LevelId), hoặc
|
||||
// đẻ dòng thứ hai — cả hai đều bị bắt ở đây. Nếu ghi đúng 1 row nhưng quên cập nhật
|
||||
// `SignedByUserId`/`SignedByFullName`/`Comment` thì đó là chữ ký cũ đứng tên cho lần duyệt mới.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow()
|
||||
{
|
||||
using var f = new KhkkWorkflowFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var a = await AddUserAsync(f, "NV Cung ứng A");
|
||||
var b = await AddUserAsync(f, "NV Cung ứng B");
|
||||
var c = await AddUserAsync(f, "Trưởng phòng C");
|
||||
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-UPSERT", steps:
|
||||
[
|
||||
[[a.Id, b.Id], [c.Id]],
|
||||
]);
|
||||
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/040",
|
||||
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new(PeReference: 500m, Proposed: 480m)]);
|
||||
var levelOfA = await LevelIdOfAsync(f, a.Id);
|
||||
|
||||
// ---- Lượt 1: A ký, KHÔNG nhập ý kiến.
|
||||
await ActAsync(f, As(a), planId, "approve", comment: null);
|
||||
|
||||
var first = await ReadOpinionsAsync(f, planId);
|
||||
first.Should().ContainSingle("lượt ký đầu tiên tạo 1 dòng");
|
||||
first[0].ApprovalWorkflowLevelId.Should().Be(levelOfA);
|
||||
first[0].SignedAt.Should().Be(Clock2026,
|
||||
"thời điểm ký lấy từ `IDateTime` được bơm vào service — dùng `DateTime.UtcNow` trực tiếp "
|
||||
+ "là không test được và lệch giờ máy chủ");
|
||||
first[0].Comment.Should().NotBeNullOrWhiteSpace(
|
||||
"ý kiến rỗng phải thành chữ thay thế (`ContractSigningPlanLevelOpinion.cs:26` 'hoặc placeholder', "
|
||||
+ "khuôn nguồn copy `ContractWorkflowService.cs:295-297`) — để trống thì màn Ý kiến hiện ô câm, "
|
||||
+ "không phân biệt được 'đã ký không ý kiến' với 'chưa ký'");
|
||||
|
||||
// ---- Trả lại rồi gửi lại ⇒ con-trỏ quay về Cấp 1, tới lượt A LẦN NỮA.
|
||||
await ActAsync(f, As(c), planId, "return", "Thiếu bảng chiết tính — trả lại BCH");
|
||||
(await ReadPlanAsync(f, planId)).Phase.Should().Be(ContractSigningPlanPhase.TraLai);
|
||||
|
||||
await ActAsync(f, drafter, planId, "submit", "Đã bổ sung chiết tính");
|
||||
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||
"gửi lại thì chạy LẠI từ Bước 1 Cấp 1 ⇒ đúng lượt A");
|
||||
|
||||
// ---- Lượt 2: CHÍNH A ký lại CÙNG Cấp đó, lần này có ý kiến.
|
||||
await ActAsync(f, As(a), planId, "approve", "Đã rà lại đơn giá — thống nhất ký");
|
||||
|
||||
var second = await ReadOpinionsAsync(f, planId);
|
||||
second.Should().ContainSingle(
|
||||
"duyệt lần 2 cùng Cấp là CẬP NHẬT dòng cũ (UNIQUE PlanId × LevelId), KHÔNG đẻ dòng thứ 2");
|
||||
second[0].ApprovalWorkflowLevelId.Should().Be(levelOfA, "vẫn là ý kiến của đúng Cấp đó");
|
||||
second[0].Comment.Should().Be("Đã rà lại đơn giá — thống nhất ký",
|
||||
"nội dung phải là ý kiến MỚI NHẤT (latest-write-wins) — giữ chữ cũ là hồ sơ nói sai sự thật");
|
||||
second[0].SignedByUserId.Should().Be(a.Id, "người ký THẬT của lượt sau");
|
||||
second[0].SignedByFullName.Should().Be(a.FullName,
|
||||
"tên là snapshot chụp lúc ký (`ContractSigningPlanLevelOpinion.cs:29`) — bỏ trống thì bản in "
|
||||
+ "tờ FO-002.01 ra ô tên rỗng");
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// (T6) Trả lại → gửi lại — acceptance #6 (spec §③-B `:33`).
|
||||
//
|
||||
// Mấu chốt: phiếu bị trả lại khi ĐÃ ĐI QUA HẾT Bước 1 và đang đứng ở Bước 2. Nếu đợt-3
|
||||
// hiểu "gửi lại" là *nối tiếp chỗ đang dở* thì con-trỏ sẽ về (Bước 2, Cấp 1) — trông rất
|
||||
// hợp lý, và hậu quả là bản sửa của BCH KHÔNG được Bước 1 xem lại lần nào. Vì thế phải
|
||||
// trả lại đầu quy trình: (Bước 1, Cấp 1).
|
||||
//
|
||||
// Lượt duyệt số 6 (A ký lại được) là vế chứng minh vòng chạy lại là THẬT, không phải chỉ
|
||||
// đặt lại hai con số rồi khoá.
|
||||
// ===================================================================
|
||||
[Fact]
|
||||
public async Task SendBack_Resubmit_RestartsFromStepOneLevelOne()
|
||||
{
|
||||
using var f = new KhkkWorkflowFixture();
|
||||
var drafter = As(await AddUserAsync(f, "BCH lập kế hoạch"));
|
||||
var a = await AddUserAsync(f, "PMH cấp 1");
|
||||
var b = await AddUserAsync(f, "PMH cấp 2");
|
||||
var c = await AddUserAsync(f, "CCM Bước 2");
|
||||
|
||||
var wf = await SeedWorkflowAsync(f, "QT-KHKK-TRALAI", steps:
|
||||
[
|
||||
[[a.Id], [b.Id]], // Bước 1: 2 Cấp
|
||||
[[c.Id]], // Bước 2: 1 Cấp
|
||||
]);
|
||||
|
||||
// Phiếu bắt đầu ở NHÁP, con-trỏ trống — đúng trạng thái sau khi W2 lập phiếu.
|
||||
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/050",
|
||||
ContractSigningPlanPhase.DangSoanThao, null, null, [new(PeReference: 700m, Proposed: 650m)]);
|
||||
|
||||
// 1) Trình lần đầu: đặt con-trỏ về (Bước 1, Cấp 1).
|
||||
var rSubmit = await ActAsync(f, drafter, planId, "submit", "Trình duyệt kế hoạch ký kết");
|
||||
var afterSubmit = await ReadPlanAsync(f, planId);
|
||||
ShouldBeAt(afterSubmit, ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||
"trình = mở quy trình từ Bước 1 Cấp 1");
|
||||
ResultShouldMirror(rSubmit, afterSubmit);
|
||||
|
||||
// 2-3) Đi hết Bước 1 để phiếu ĐANG ĐỨNG Ở BƯỚC 2 lúc bị trả lại.
|
||||
await ActAsync(f, As(a), planId, "approve", "Bước 1 Cấp 1 duyệt");
|
||||
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2, "sang Cấp 2");
|
||||
await ActAsync(f, As(b), planId, "approve", "Bước 1 Cấp 2 duyệt");
|
||||
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 1, 1,
|
||||
"hết Bước 1 ⇒ đang chờ Bước 2 Cấp 1 (đây là mốc để câu hỏi 'gửi lại về đâu' có nghĩa)");
|
||||
|
||||
// 4) Bước 2 trả lại.
|
||||
var rReturn = await ActAsync(f, As(c), planId, "return", "Sai khối lượng hạng mục 3 — trả lại");
|
||||
var afterReturn = await ReadPlanAsync(f, planId);
|
||||
ShouldBeAt(afterReturn, ContractSigningPlanPhase.TraLai, null, null,
|
||||
"Trả lại toàn phần: phase TraLai + XOÁ con-trỏ (`ContractSigningPlanWorkflowService.cs:116`) — "
|
||||
+ "để sót con-trỏ thì phiếu vừa nằm ở tay người soạn vừa nằm trong hộp thư người duyệt");
|
||||
ResultShouldMirror(rReturn, afterReturn);
|
||||
afterReturn.Lines.Should().OnlyContain(l => l.ApprovedAmount == null,
|
||||
"bị trả lại thì không có giá nào được chốt");
|
||||
|
||||
// 5) 🔴 Gửi lại: PHẢI về đầu quy trình, KHÔNG nối tiếp Bước 2.
|
||||
await ActAsync(f, drafter, planId, "submit", "Đã sửa khối lượng, trình lại");
|
||||
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 1,
|
||||
"gửi lại chạy LẠI TỪ ĐẦU (Bước 1 Cấp 1); về (1,1) tức Bước 1 không hề xem bản sửa");
|
||||
|
||||
// 6) Vòng mới là thật: A có lượt lại và duyệt được.
|
||||
await ActAsync(f, As(a), planId, "approve", "Xem lại bản sửa — đồng ý");
|
||||
ShouldBeAt(await ReadPlanAsync(f, planId), ContractSigningPlanPhase.ChoDuyet, 0, 2,
|
||||
"A ký được lần nữa ⇒ vòng duyệt chạy lại thật sự, không phải con-trỏ bị đặt lại rồi khoá");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user