# SPEC WAVE-3 — Duyệt 3 trạm + finalize chốt giá (GĐ2 KHKK) 🔴 wave nặng nhất

> Nguồn RATIFIED: spec cũ §2.1 (bẫy Proposal) + §2.3 service + §③ WAVE-3 (`:341-386`). Chạy SAU W2. Test-before BẮT BUỘC (critical algorithm OR-of-N).

## ① TÍNH NĂNG / ĐỀ BÀI

Phiếu KHKK chạy trọn `DangSoanThao → ChoDuyet → (PMH → CCM → CEO) → DaDuyet`, chốt `ApprovedAmount` per-winner tại finalize choke-point. Default dry-run: **đường THƯỜNG duy nhất** — CEO ký thật, KHÔNG bật 2 đường kết thúc sớm (`AllowApproverFinalize`/`CeoApprovalThreshold` — anh từng phán "PHÁ VỠ" S155; plan cha default #1).

## ② CÁCH IMPLEMENT

1. **Gate 5-anchor** (§③-A).
2. **Test-before**: viết 6 test sàn (bảng §③-B) RED trước.
3. **`Infrastructure/Services/ContractSigningPlanWorkflowService.cs`** — copy `ContractWorkflowService.ApproveV2Async:217-394` (🔴 CẤM copy ProposalFeatures — flatten phá OR-of-N, comment tự thú "assume 1 step" ở `:424`, cơ chế `:427-439`). Giữ nguyên: con-trỏ ĐÔI StepIndex+LevelOrder · GroupBy Order `:246` · OR-of-N `:259-260` · LevelOpinions UPSERT `:292-316` · skipToFinal `:320-352`. Adapt 3 chỗ:
   (a) 🔴 **Finalize choke-point — 2 WRITE-PATH, KHÔNG "DUY NHẤT" (review F-B1, lớp #81):** nguồn copy có ≥2 nhánh set terminal — nhánh duyệt-cấp-cuối VÀ nhánh **admin-override** (`ContractWorkflowService.cs:190-193` gán `contract.Phase = targetPhase` QUA BIẾN ⇒ literal-grep KHÔNG thấy). Helper `ApplyApprovedValuesOnFinalize` (mọi Line `ApprovedAmount ??= ProposedAmount`) phải gọi ở **CẢ HAI**. Khuôn RULE-comment ĐÚNG = `ContractFeatures.cs:356-360` (LUẬT + `grep -rn` + "không đếm số") — **KHÔNG** phải `PEWS:1008` (review F-B4: dòng đó CÓ số-đếm "4 site" mà thực 5 call-site, tự mâu thuẫn; và `:1009` là helper NS khác helper GIÁ `:973-1002`).
   (b) 🔴 **KHÔNG PORT 2 nhánh kết thúc sớm** (review F-B3 — anh phán "PHÁ VỠ" S155, "port-nhưng-không-kích-hoạt" = containment GIẢ vì Designer type=10 render checkbox thật). W3 chỉ đường THƯỜNG. Containment thật = W1 ẩn 2 checkbox cho type=10.
   (c) Reject → tối thiểu `TraLai` toàn phần (4 return-mode per-level cờ sẵn `Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:116-125` — 🔴 class `ApprovalWorkflowLevel` nằm TRONG `ApprovalWorkflow.cs:95`, KHÔNG có file riêng tên đó).
4. 🔴 **Ghi lịch sử — DÙNG BẢNG KHKK RIÊNG, KHÔNG hàm Contract (review F-B2 = FK-547 runtime):** mỗi transition ghi `ContractSigningPlanApprovals` (#7) + `ContractSigningPlanChangelogs` (#6) qua **method KHKK-riêng** (`LogSigningPlanTransition` tự viết, ghi `_db.ContractSigningPlanChangelogs`). 🔴 **CẤM gọi `IChangelogService.LogWorkflowTransitionAsync`** — signature `(Guid contractId,…)` ghi `ContractChangelogs` có **FK vật lý `ContractId` Cascade** (`ContractChangelogConfiguration.cs:22-26`) ⇒ truyền `plan.Id` = **FK violation 547** (enum cast được nên compiler KHÔNG chặn).
5. **Guard trình** = `CreatedBy==actor ∨ DeptManager(cùng DepartmentId phiếu) ∨ Admin` — CỐ Ý khác khuôn role-based Contract để né lớp 403-PMH (spec cũ `:193-196`).
6. **Notify**: mirror `LogTransitionAsync` (`ContractWorkflowService.cs:407-427`) + **THÊM notify đích danh approver Cấp kế** (fix lỗ notify-chỉ-Drafter từ ngày 1).
7. **Endpoint** `POST {id}/transitions` (+ policy `KeHoachKyKet.Update`).
8. **FE ×2**: panel workflow (mirror PE Panel-3 ✓/●/○) + banner "Đến lượt bạn" (khuôn `blockedByV2Level`) + nút Duyệt/Trả lại/Từ chối + ô ý kiến (UPSERT vào LevelOpinions khi duyệt — Q1=1B sync).
9. Build ×2 + test GREEN + UAT 1 lượt thật (2 phiếu song sinh) + commit.

## ③ CHECKLIST

**A. Gate 5-anchor:** `ContractWorkflowService.cs:217-394` (nguồn copy) · `:259-260` (OR-of-N) · `:292-316` (UPSERT) · `ApprovalWorkflow.cs:95+:116-125` (return-mode cờ, class trong file này) · `ContractFeatures.cs:356-360` (khuôn RULE choke-point — LUẬT + `grep -rn` + "không đếm số"; thay `PEWS:1008` per F-B4, khớp §②-3(a)).
**B. Acceptance (spec cũ W3 `:348-384` — SÀN 6 test, thiếu 1 tên = TRƯỢT):**
- [ ] 🔴 OR-of-N 4 vế: (a) 2 phiếu song sinh — phiếu-1 A duyệt → tiến; phiếu-2 B duyệt → CŨNG tiến; (b) ca ÂM: người ngoài cấp → 403; (c) `LevelOpinions WHERE PlanId=@id` = 1 row; (d) KHÔNG đòi cả A và B.
- [ ] Con-trỏ đôi: workflow 3 Bước → StepIndex 1→2→3, LevelOrder reset về 1 mỗi Bước mới.
- [ ] Finalize: `Lines WHERE PlanId=@id AND ApprovedAmount IS NULL` = 0 (🔴 có `AND PlanId=@id`) — TEST **CẢ 2 write-path**: duyệt-cấp-cuối THƯỜNG + admin-override (F-B1).
- [ ] Inbox: approver cấp đang chờ thấy phiếu; cấp khác KHÔNG.
- [ ] UPSERT: duyệt 2 lần cùng cấp → 1 row, `SignedByUserId` người thật.
- [ ] Trả lại → TraLai → sửa → gửi lại chạy từ Cấp 1 Bước 1.
- [ ] Notify: approver Cấp kế NHẬN thông báo (test-after được phép — ngoài sàn 6).
- [ ] `dotnet test` PASS 0 fail, số-sau ≥ baseline + 6, đủ 6 TÊN theo **khuôn repo** (anh chốt @S160 đổi khỏi tên thuần-Việt spec cũ `:377-382`; English-predicate + danh-từ-VN-không-dấu) trong `ContractSigningPlanApprovalTests`: `ApproveV2_OneApproverInLevel_Advances_WithoutRequiringBothSignatures` (OR-of-N ca (d)) · `ApproveV2_NewStep_ResetsLevelOrderToOne` (con-trỏ đôi) · `Finalize_SetsApprovedAmount_OnlyForLinesOfThatPlan` (choke-point `AND PlanId=@id`) · `Inbox_OnlyPendingLevelApprover_SeesPlan` (ca ÂM cấp khác) · `ApproveV2_TwiceSameLevel_UpsertsSingleOpinionRow` (UPSERT + SignedByUserId) · `SendBack_Resubmit_RestartsFromStepOneLevelOne` (vòng trả-lại). *(Ý nghĩa 1:1 với 6 acceptance #1-#6; đổi tên KHÔNG đổi phủ.)*
- [ ] FE build ×2 PASS; cicd 3-chân-kiềng sau deploy; prod curl: user approver thật vào inbox thấy phiếu test ZZTEST.
**C. Rủi ro:** [ ] copy nhầm Proposal = mất OR-of-N ÂM THẦM (build sạch test cũ xanh — acceptance #1 là lưới duy nhất) · [ ] quên inbox precompute (đã wire W2) = lặp lỗi Contract.
