# sub-invest-fable-b2-cum3 — B2 CỤM-3: SPEC CHI TIẾT K7 (cầu KHKK→HĐ + đường ống V2) + K8 (dry-run E2E)

> Run `2026-07-31-S164-4gd-khkk-fanout` · engine `/fable-real` · investigator-codebase (read-only spec-writer)
> Luật đan xen: đọc đủ MỘT section → VIẾT NGAY. Kế thừa S156/S160 — không soi lại vùng đã đo.

## K7 Cầu KHKK→HĐ + đường ống V2

### K7.0 RE-ĐO 3 nấc (đo lại hôm nay, không tin sổ) + 1 BÁC tiền đề đề bài

| Nấc | Kết quả đo @S164 | Evidence |
|---|---|---|
| (a) cột/index | ✅ CÓ | `ContractSigningPlanLine.cs:25` `ContractId Guid?` + comment "[C6 review-schema] HĐ sinh ra từ dòng này (W5)"; index non-unique `ContractSigningPlanLineConfiguration.cs:26` |
| (b) write-site | ✅ = 0 (tự grep, không mượn số lead) | grep `ContractId` trong `ContractSigningPlanFeatures.cs` → 3 hit đều READ: `:67` DTO field, `:574` projection, `:1185` comment; `ContractSigningPlanWorkflowService.cs` → 1 hit `:54` = COMMENT FK. **0 assignment** |
| (c) test E2E | ✅ = 0 test bridge | `tests/` có 4 file SigningPlan (Approval/Crud/Schema/AuthorizePolicy) + `CreateContractFromEvaluationMultiWinnerTests.cs` (bridge PE cũ) — KHÔNG file nào test cầu KHKK→HĐ |

🔴 **BÁC tiền đề K7(b) của đề bài** — "SỬA 3 lỗ đường ống (GetEligiblePhases V2-aware + inbox V2 + guard trình)" là kiến thức S156 ĐÃ LỖI THỜI. Cả 3 đã LAND @W6-S161:
1. **List + Detail V2-aware**: `ContractFeatures.cs:306-318` — vế (a) `myWorkflowIds.Contains(x.c.ApprovalWorkflowId)` per-HĐ (helper `ResolveUserApprovalWorkflowIdsAsync:403-411`) + vế (b) `HardCopyActorRoles:391-392` thấy DaPhatHanh. Comment `:381-387` CẤM nhét ChoDuyet vào `GetEligiblePhases` (hàm dùng chung với ListDeleted `:441` → nhét là RÒ HĐ ChoDuyet xóa-mềm sang màn "Đã xóa"). **K7 KHÔNG sửa `GetEligiblePhases` — implementer đọc đề bài cũ mà "làm cho xong" là tạo lỗ.**
2. **Inbox V2**: `ContractFeatures.cs:532-534` `v2InboxIds = ResolveV2InboxIdsAsync(...)` (bản Contract ĐÃ TỒN TẠI) + where `:543` `|| v2InboxIds.Contains(c.Id)` + `AdminInboxPhases:503-513` đã +ChoDuyet (DR-4).
3. **Guard trình**: `ContractWorkflowService.cs:73-92` đã nới `Drafter ∨ DeptManager ∨ Procurement ∨ isCreator` (vá lỗ PMH-403 đo 07-29).

⇒ K7(b) co lại thành **V3: test-khóa 3 vế** (regression lock — xem K7.4 T8), KHÔNG build lại. Phần build THẬT của K7 = bridge (a) + FE (c).

### K7.1 (a) BRIDGE MỚI — `POST /api/contract-signing-plans/{id}/create-contract`

**Khuôn mẫu:** mirror bridge PE→HĐ cũ `CreateContractFromEvaluationFeatures.cs` (endpoint `PurchaseEvaluationsController.cs:340-343`) + guard workflow V2 của `CreateContractCommand` (`ContractFeatures.cs:74-81`). Luật spec-4gd §①: GIỐNG NHAU — CHỈ KHÁC FORM — ĐỪNG CHẾ THÊM.

**Command** (file mới `Application/ContractSigningPlans/CreateContractFromSigningPlanFeatures.cs`):
```csharp
public record CreateContractFromSigningPlanCommand(
    Guid PlanId,
    List<Guid> LineIds,          // OG-1: schema GỘP — N line cùng NCC → 1 HĐ; UI-1-1 truyền 1 phần tử
    ContractType ContractType,
    Guid ApprovalWorkflowId,     // V2 type-3 — user chọn trong dialog
    string? TenHopDong = null) : IRequest<Guid>;   // 1 call = 1 HĐ (khác bridge PE trả List)
```
⚠️ **Lệch đề bài có chủ đích:** đề ghi input `{planId, lineIds[], contractType}`; PHẢI thêm `ApprovalWorkflowId` vì V2 KHÔNG phân theo ContractType — `ApprovalWorkflow.cs:57` `Contract = 3, // HĐ general (any ContractType)` ⇒ "pin theo ContractType" tự động là BẤT KHẢ; chọn quy trình = việc của dialog (mirror ContractCreatePage dropdown type-3).

**Guard tuần tự trong handler (mỗi guard 1 message VN rõ):**
1. plan tồn tại + `Phase == DaDuyet` (mirror rào (i) `ContractSigningPlanFeatures.cs:330-332`).
2. `LineIds` non-empty, distinct, TẤT CẢ ∈ plan.Lines.
3. TẤT CẢ line cùng `SupplierId` — 1 HĐ = 1 NCC (`Contract.SupplierId` đơn). Liên-danh N NCC = N lần bấm (UI-1-1 mỗi lần 1 line nên tự thỏa). Message: "Các dòng chọn thuộc nhiều NCC khác nhau — mỗi Hợp đồng chỉ 1 NCC, tạo lần lượt."
4. TỪNG line `ApprovedAmount != null` — null → Conflict message NÊU TÊN hạng mục: "Dòng '{TenHangMuc}' chưa có giá duyệt chốt — phiếu phải qua finalize (K2-freeze) trước khi đưa vào HĐ." **KHÔNG fallback ProposedAmount** (số chưa chốt).
5. TỪNG line `ContractId == null` — chống double-bridge per-line (thay guard `pe.ContractId` single của bridge cũ `:59-60`). Message: "Dòng '{TenHangMuc}' đã có Hợp đồng."
6. `ApprovalWorkflowId`: tồn tại + `ApplicableType == Contract(3)` — copy trọn 2 nhánh NotFound/Conflict theo khuôn `EnsureWorkflowTypeAsync` (`ContractSigningPlanFeatures.cs:209-220`) đổi hằng type.

**Build Contract (map field):** `Type=request.ContractType` · `Phase=DangSoanThao` · `SupplierId=line.SupplierId` · `ProjectId=plan.ProjectId` (`ContractSigningPlan.cs:25`) · `DepartmentId=plan.DepartmentId` (`:26`) · `DrafterUserId=currentUser` · **`GiaTri = Σ ApprovedAmount(lines chọn)`** — phạm-vi-gộp = ĐÚNG tập LineIds trong call, không hơn không kém (DoD spec-4gd K7) · `TenHopDong = request.TenHopDong ?? "{pe.TenGoiThau} — {TenHangMuc}"` · `ApprovalWorkflowId = request.ApprovalWorkflowId` (**pin V2 — khác bridge cũ chỉ pin V1 `:68-71,108`**) · `WorkflowDefinitionId = null` (KHÔNG pin V1 song song — HĐ mới đi V2 thuần, nhánh dispatch `ContractWorkflowService.cs:111` ưu tiên V2) · optional `BudgetManualName="NS tham chiếu KHKK {MaKeHoach}"` + `BudgetManualAmount=Σ PeReferenceAmount(lines)` (mirror `:106-107`, tham khảo).

**Mã HĐ:** gen NGAY lúc bridge — mirror tiền lệ bridge PE `:113` + bài S88 self-committing codegen (`:78-81`): gen TRƯỚC khi Add/track (codegen tự SaveChanges — comment `ContractSigningPlanFeatures.cs:352-357` đã trả giá bài này); terminal V2 có guard `if (MaHopDong is null)` (`ContractWorkflowService.cs:384`) nên không double-gen. Trade-off cháy seq RG-001 khi HĐ không phát hành → khai ở K8 guard.

**Write-site ContractId (nấc b đóng):** sau khi Add contract + SaveChanges chung 1 lần: `line.ContractId = contract.Id` cho TỪNG line chọn (load lines TRACKED — đây là assignment ĐẦU TIÊN toàn codebase). + 2 changelog mirror `:117-141`: `ContractChangelogs` (Insert, "Tạo HĐ {mã} từ KHKK {MaKeHoach}") + `ContractSigningPlanChangelogs` (Update, ContextNote=contractId). Atomic: 1 SaveChanges cuối.

**Nối cây GĐ3 (quyết):** `pe.ContractId ??= contract.Id` (chỉ khi null) — `usePipelineStages.ts` đi GĐ3 DUY NHẤT qua `pe.contractId` (skill contract-workflow đã khai); không set = HĐ sinh ra NHƯNG cây 4-folder mù GĐ3. Hệ quả 2 chiều khai rõ: (i) bridge PE→HĐ cũ sẽ 409 cho PE đó (`:59-60` — đúng ý, khóa đường cũ tự nhiên); (ii) liên-danh N-HĐ cây vẫn chỉ link HĐ[0] — hố sẵn có của bridge cũ (`:145`), nâng cấp cây đọc `line.contractId` = backlog W9, KHÔNG làm đợt này (đụng 2 file SHA-identical ràng buộc K5).

**Controller:** `ContractSigningPlansController` — `[HttpPost("{id:guid}/create-contract")]` `[Authorize(Policy = "Contracts.Create")]` — 🔴 đúng-key-policy-endpoint-đích (gotcha #85): tài nguyên sinh ra là Contract ⇒ key `Contracts.Create`, KHÔNG phải `KeHoachKyKet.*`. Trả `201 { contractId, maHopDong }`.

**Ai bấm + chọn ContractType (câu III):** ⏳ PENDING-OWNER — default spec: "người có quyền tạo HĐ" = ai mang policy `Contracts.Create` thấy nút + tự chọn ContractType trong dialog (khớp l1-F15 spec-4gd "mặc định người bấm chọn trong dialog"). Đánh dấu chờ anh; đổi answer chỉ đổi điều kiện hiện nút FE, máy BE không đổi.

### K7.2 (b) Đường ống — trạng thái sau re-đo

- ĐÃ VÁ (K7.0): list/detail/inbox/guard-trình. Việc K7 = **V3 test-khóa** + xác nhận runtime bằng curl trong acceptance (A3/A4).
- CÒN THIẾU thật: **0** — HĐ pin V2 sinh từ bridge đi được trọn: trình (`:70-101` set ChoDuyet + levelOrder=1 vì AWId≠null `:97`) → ApproveV2Async (`:111-113`) → terminal gen mã + DaPhatHanh (`:381-392`).
- ⚠️ Tiền đề vận hành (không phải code): prod phải có ≥1 `ApprovalWorkflow` ApplicableType=3 active + roster thật. Dev hiện chỉ có `QT-HD-V2-001` "mẫu UAT" (sqlcmd Dev @S164); prod CHƯA đo được (SSH chết khi load SQL-client — S134/S148) → chuyển thành pre-flight K8 B0.

### K7.3 (c) Nút FE ×2 app

- Vị trí: `fe-user/src/pages/khkk/KhkkDetailPage.tsx` khối actions PageHeader `:291-318` (cạnh nút Xóa/Danh sách) + mirror `fe-admin/.../KhkkDetailPage.tsx` (tiền lệ PeWorkflowPanel byte-identical 2 app — S155).
- Điều kiện hiện: `phase === KhkkPhase.DaDuyet && can('Contracts','Create')` (#85 — đúng key endpoint; KHÔNG OR `Khkk_*`).
- Dialog "Đưa vào Hợp đồng": (1) chọn **1 line** (UI-1-1 OG-1 — radio list các line `contractId == null`, hiện TenHangMuc + NCC + ApprovedAmount; line đã bridge → badge "Đã có HĐ" + link, disabled); (2) Select ContractType 7 enum; (3) Select Quy trình duyệt HĐ (nguồn = mirror dropdown ContractCreatePage, filter ApplicableType=3); (4) TenHopDong prefill. Submit → POST → toast mã HĐ + navigate/link HĐ; invalidate query detail.
- Line có `ApprovedAmount == null` → disabled + tooltip "chưa có giá chốt".

### K7.4 (d) Test — 7 case (fixture khuôn `ContractSigningPlanApprovalTests.cs` sẵn có)

| # | Case | Assert |
|---|---|---|
| T1 | Happy 1-line: plan DaDuyet, line ApprovedAmount=X | 1 HĐ: GiaTri==X · Phase=DangSoanThao · ApprovalWorkflowId pin đúng · WorkflowDefinitionId null · MaHopDong non-null · `line.ContractId==hđ.Id` · `pe.ContractId==hđ.Id` · 2 changelog |
| T2 | Gộp N=2 line CÙNG NCC (máy OG-1) | GiaTri==Σ 2 ApprovedAmount · CẢ 2 line cùng ContractId |
| T3 | 1 line ApprovedAmount=null | Conflict, message chứa TenHangMuc; DB 0 HĐ mới + 0 line đổi (atomic) |
| T4 | plan.Phase=ChoDuyet | Conflict "chỉ từ phiếu Đã duyệt" |
| T5 | line đã có ContractId | Conflict (double-bridge per-line) |
| T6 | 2 line KHÁC SupplierId 1 call | Conflict "mỗi HĐ 1 NCC" |
| T7 | ApprovalWorkflowId type-10 (KHKK) | Conflict (guard type-3 — chống forge, mirror `:74-81`) |
| T8 (khóa V3) | HĐ pin V2 ở ChoDuyet: approver-V2-không-role-legacy | thấy List (vế `:315-316`) + Inbox (`:543`); cấp-2 CHƯA thấy inbox khi đang cấp-1 |

### K7.5 (e) Acceptance đo được (chạy sau deploy, số cụ thể)

- [ ] A1 `POST .../create-contract` → 201; sqlcmd `SELECT ContractId FROM ContractSigningPlanLines WHERE Id IN (…)` = đúng contractId, đúng N dòng chọn.
- [ ] A2 sqlcmd `Contracts.GiaTri` == Σ `ApprovedAmount` các line chọn — khớp từng đồng.
- [ ] A3 login user CHỈ có chân V2 (0 role legacy): `GET /api/contracts?phase=10` + `GET /api/contracts/{id}` → thấy HĐ (trước W6: rỗng).
- [ ] A4 `GET /api/contracts/inbox`: approver cấp-hiện-tại CÓ HĐ; user cấp-sau CHƯA.
- [ ] A5 duyệt hết trạm V2 → `Phase=9 (DaPhatHanh)`; `MaHopDong` đúng RG-001 (`{ProjectCode}/{abbr}/SOL&{SupCode}/{Seq:D2}`) — sinh từ lúc bridge, terminal không double-gen.
- [ ] A6 POST lần 2 cùng line → 409; FE line badge "Đã có HĐ" + disabled.
- [ ] A7 `dotnet test` ≥ baseline+8 (khai k con số); cicd PASS; FE-ship → byte-verify #77 ×2 app.

### K7.6 Danh sách việc (n = 8)

V1 BE command+validator+handler bridge (file mới) · V2 BE endpoint controller + policy `Contracts.Create` · V3 test-khóa đường ống (T8 — KHÔNG sửa GetEligiblePhases) · V4 FE fe-user nút+dialog · V5 FE fe-admin mirror · V6 FE types/hook + invalidate (×2 app) · V7 test T1-T7 · V8 acceptance A1-A7 + khai số test.

## K8 Dry-run E2E

### K8.0 Tham số chốt + 2 QUYẾT

- **Nguồn:** `PE/2026/A/049` (gói 14 Mat PVC) — theo đề bài. **QUYẾT-1 nhóm:** ống PVC = vật tư chính ⇒ nhóm **B1 · Vật tư → workflow `KHKK-N4`** (transcribe `danh-muc-sp002-transcribe.md:52` "B1 · Vật tư / Materials (23) → N4"; hàng khớp chọn trong 23 dòng B1, fallback `B1-23 Vật tư chính khác` `:75`). KHÔNG phải A1 (`:20` = thiết bị/vật tư PHỤ → N1).
- **QUYẾT-2 loại HĐ:** `HopDongNhaCungCap` (abbr "NCC" CÓ trong RG-001 v02 — né hố "MB" không gốc quy định, S160 F-06.1/F-21).
- **Đội hình:** cột "Ai" = chép từ **bảng danh tính K3-acceptance** (vá-11 cụm-1, đo trên bản LIVE) — Dev KHÔNG có `QT-DN-V2-001` (sqlcmd Dev @S164 chỉ có `QT-HD-V2-001` type-3 UAT) ⇒ KHÔNG điền trước email từ Dev. Anchor đã chốt: trạm CCM = **TP.CCM Phan Văn Chương** (finalize-gate OG-3/OG-9) · trạm cuối = **CEO (anh Trường)**. Giả định 4 trạm — số trạm THẬT theo seed K3, người chạy bám panel duyệt.
- **Nhánh chính = A (finalize tại a Chương)** — chứng tính năng MỚI OG-9; nhánh B (lên CEO) = phiếu ZZTEST thứ 2 nếu còn giờ.
- Login API prod: parse field `accessToken` (KHÔNG phải `token` — đo S160).

### K8.1 Bảng bước (B0-B16 = 17 bước; mỗi bước Ai · Hành động · Kỳ vọng · Bằng chứng)

**B0 PRE-FLIGHT (chặn cả buổi nếu thiếu):** (i) `scripts/backup-sql.ps1` backup prod; (ii) verify prod có `ApprovalWorkflow` ApplicableType=3 ACTIVE (đội hình HĐ thật) + `KHKK-N4` active + roster khớp bảng K3 — thiếu → admin tạo bằng designer fe-admin TRƯỚC; (iii) verify `PE/2026/A/049`: Phase=DaDuyet + có winner IsWinner + chưa có plan nhóm-4 sống (rào `ContractSigningPlanFeatures.cs:342-347`, sau K2 = per-(PeId,group)); (iv) **dặn team** (Zalo/email): "hệ thống bắn notification phiếu ZZTEST trong khung giờ X — bỏ qua" — KHÔNG có cơ chế tắt notify per-record, chỉ dặn (DoD l3-F16); (v) hỏi anh **câu giờ-G** ở K8.2. Bằng chứng: ảnh backup + sqlcmd 3 dòng verify.

| # | Ai | Hành động | Kỳ vọng UI/API | Bằng chứng |
|---|---|---|---|---|
| B1 | BCH drafter [K3-bảng] | fe-user `/khkk` → Tạo mới: picker chọn `PE/2026/A/049`, workflow `KHKK-N4`, GhiChu tiền tố **ZZTEST** | 201 + `MaKeHoach = KHKK/2026/{seq}` hiện header | ss-k8-01 + mã phiếu |
| B2 | BCH drafter | Điền line: gán hạng mục B1-xx (PVC) per line, ProposedAmount per NCC×hạng mục; save | Lines lưu; line thiếu hạng mục bị submit-guard chặn (vá-5 cụm-1) | ss-k8-02 |
| B3 | BCH drafter | Trình duyệt (Nháp→ChoDuyet) | Badge "Chờ duyệt"; phiếu vào inbox trạm-1; notification bắn | ss-k8-03 |
| B4 | Trạm-1 [K3-bảng] | Login → `/khkk` Đang duyệt → mở phiếu → KhkkWorkflowPanel bấm Duyệt + ý kiến "ZZTEST duyệt trạm 1" | Advance cấp/bước; LevelOpinion UPSERT hiện Section ý kiến | ss-k8-04 |
| B5 | Trạm-2 [K3-bảng] | Như B4 tại trạm 2 | Advance; user trạm-1 hết thấy phiếu trong inbox | ss-k8-05 |
| B6 | **TP.CCM Phan Văn Chương** | Trạm CCM: tick **"Duyệt kết thúc tại cấp"** (AllowApproverFinalize — OG-9 nhánh A) | `Phase=DaDuyet` KHÔNG qua CEO; `EndedByLevelFinalize=true`; ApprovedAmount ghi tại choke-point + FREEZE (QĐ9 mirror Mig 67) | ss-k8-06 + sqlcmd `EndedByLevelFinalize,ApprovedAmount` |
| B7 | *(chỉ nhánh B — phiếu ZZTEST-2)* CEO Trường | Duyệt final trạm cuối | `Phase=DaDuyet` đường chuẩn | ss-k8-07 |
| B8 | BCH/Admin | Mở lại detail: số khóa 🔒; thử sửa line | ApprovedAmount hiển thị khóa; sửa line bị phase-guard chặn (vá-6 cụm-1); sqlcmd số không đổi | ss-k8-08 + sqlcmd |
| B9 | Người có `Contracts.Create` [⏳ câu III] | Bấm **"Đưa vào Hợp đồng"** (K7): dialog chọn **1 line** (UI-1-1) + ContractType=NhaCungCap + workflow HĐ type-3 | 201 → HĐ draft `GiaTri == ApprovedAmount` line; `MaHopDong` RG-001 có ngay; line badge "Đã có HĐ" | ss-k8-09 + mã HĐ |
| B10 | Người bấm B9 | Mở HĐ draft (TenHopDong tiền tố ZZTEST), kiểm NCC/dự án/giá → Trình duyệt | Phase→ChoDuyet (guard trình đã nới `:73-92`); vào inbox approver V2 HĐ | ss-k8-10 |
| B11 | Approver HĐ [đội hình type-3] | Duyệt lần lượt hết trạm V2 | Terminal: `Phase=DaPhatHanh` (mã giữ nguyên — guard `:384`); notification "đã phát hành" | ss-k8-11 |
| B12 | HRA/CCM [HardCopyActorRoles] | GĐ4: mở HĐ (leaf `Hdc_*`) → upload scan bản cứng (purpose SealedCopy) | Badge `hasSealedCopy` (EXISTS `ContractFeatures.cs:348-355`); vai bản-cứng THẤY HĐ DaPhatHanh (`:317-318`) | ss-k8-12 |
| B13 | Bất kỳ + Admin | Mở cây 4-folder (usePipelineStages) | Đủ 4 tầng: PE A/049 → KHKK ZZTEST (folder nhóm 4) → HĐ ZZTEST → bản cứng; đường nối `khkk.purchaseEvaluationId` · `pe.contractId` · `hasSealedCopy` | ss-k8-13 |
| B14 | Lead | Chốt sổ: bảng mã thật (PE/2026/A/049 · KHKK/2026/xxx · mã HĐ) + tick DoD K8 spec-4gd `:35` | Đủ: ZZTEST prefix · toàn trình hết trạm · cây 4 GĐ · screenshot + mã thật | bảng trong session-log |

**B15 CLEANUP + danh sách bản-ghi-KHÔNG-xóa-được (sống chung):**
- **HĐ ZZTEST DaPhatHanh** — HỐ-1: `DeleteContract` guard numeric `Phase >= DangInKy` (`ContractFeatures.cs:632-633`, S160) chặn xóa cả 9/10/98/99, admin không thoát qua API. Sống chung: giữ vĩnh viễn với tiền tố ZZTEST, HOẶC sqlcmd soft-delete tay (`IsDeleted=1`) sau khi anh gật.
- **KHKK ZZTEST DaDuyet** — 🔴 **HỐ-1-KHKK (đo mới @S164):** delete allow-list `{DangSoanThao, TuChoi}` nằm SAU nhánh admin (`ContractSigningPlanFeatures.cs:1145-1148`) ⇒ phiếu DaDuyet KHÔNG xóa được KỂ CẢ Admin + **chiếm slot (A/049, nhóm-4)** — PE thật này hết lập phiếu N4 cho tới khi xử. Lối thoát: sqlcmd `UPDATE ContractSigningPlans SET IsDeleted=1,…` — global filter làm rào `:342-344` giải phóng slot (soft-deleted không tính "sống").
- **Seq đã cháy:** `ContractCodeSequences` (mã HĐ ăn STT 01 của cặp prefix (dự án thật, NCC thật)) + `KHKK/2026` seq — không thu hồi.
- **Changelog/Approval/LevelOpinion** các phiếu trên + notification đã bắn — vĩnh viễn by-design.
- PE A/049: KHÔNG đụng số liệu (chỉ bị set `pe.ContractId` bởi bridge B9 — khai với anh; muốn gỡ = sqlcmd NULL lại sau khi soft-delete HĐ).

**B16 ROLLBACK:** hỏng giữa chừng → DỪNG, không xóa gì, chụp lỗi + ghi bước đứng; phiếu KHKK kẹt ChoDuyet → approver "Từ chối" (TuChoi → xóa được qua UI `:1145`); HĐ kẹt ChoDuyet → admin Reject→TuChoi; restore DB = last-resort KHÔNG dùng (prod đang sống, restore = mất data người khác — S91).

### K8.2 Câu hỏi giờ-G cho anh (1 câu, trước B1)

Vòng-1 chạy thẳng trên **PE/2026/A/049 thật** (giá: KHKK ZZTEST DaDuyet + HĐ ZZTEST DaPhatHanh mang tên dự án/NCC THẬT nằm vĩnh viễn trong list tới khi anh cho sqlcmd soft-delete + cháy 1 seq mã HĐ của cặp prefix thật) — HAY dựng **bộ ZZTEST riêng** (ZZTEST Project + Supplier + PE tự tạo từ GĐ1, 0 đụng data thật, thêm ~30 phút setup)? Đề bài chỉ định A/049 ⇒ default = A/049 nếu anh im lặng; "PE test riêng" = vòng-2 (đề bài đã chừa).

## Thứ tự + phụ thuộc

```
K1 ──→ K2 ──→ K3 ──────────────────────┐
        │        │                      ▼
        │        └─ phiếu duyệt được → DaDuyet + ApprovedAmount (port finalize)
        └─ TenHangMuc/ApprovalGroup/freeze-răng            K7 (V1→V2→V7 BE; V3∥; V4/V5/V6 FE; V8 sau deploy)
cụm-2: K4a ∥ ngay · K4b/K4c sau K2/K3 · K5 ∥ · K6 ∥         │
                                                            ▼
                          K8 = CUỐI CÙNG (sau MỌI wave deploy prod + cicd PASS + B0)
```

- **K7 SAU cụm-1, lý do đo được (3):** (i) message-chặn T3 cần `TenHangMuc` — cột chỉ có sau Mig 71 K2; (ii) guard/rào per-(PeId, ApprovalGroup) + phase-guard line = K2 vá-6, K7 build trên trạng thái đó; (iii) **không có K3 thì `ApprovedAmount` NULL 100%** (cột "CHỈ ghi tại choke-point finalize" `Line.cs:23` — nhánh finalize là việc K3.a) ⇒ bridge chặn mọi phiếu, K7 không test nổi happy-path.
- **K7 ⊥ cụm-2:** không cần K4/K5/K6 về máy (điểm chạm cây duy nhất = set `pe.ContractId`, không đổi file cây).
- **Rebase:** K7 thêm code vào `ContractSigningPlanFeatures.cs` + FE KhkkDetailPage — cụm-1 vá 2/6/8 chạm CÙNG file ⇒ K7 khởi công SAU khi cụm-1 merge, đọc lại spec-cum1 §② trước khi viết (đừng vá lại `:342` — vá-2 đã CẤM).
- **K8 sau TẤT CẢ:** dry-run đi qua menu 48-leaf (K4), cây sub-folder (K5), admin thu gọn (K6), designer 8 nhóm (K3), bridge (K7) — thiếu wave nào lộ wave đó; + B0 pre-flight prod.

## Checklist tổng cụm-3

- [ ] K7-BE: POST bridge 201 · `Line.ContractId` ghi (write-site ĐẦU TIÊN toàn codebase) · pin `ApprovalWorkflowId` type-3 · `GiaTri == Σ ApprovedAmount` tập line chọn · null → chặn message CÓ TÊN hạng mục · double-bridge per-line 409 · policy `Contracts.Create` (#85 đúng-key-endpoint).
- [ ] K7-KHÔNG-LÀM (chống làm-thừa-tạo-lỗ): `GetEligiblePhases` GIỮ NGUYÊN (`ContractFeatures.cs:381-387` cấm nhét ChoDuyet) · ListDeleted GIỮ NGUYÊN (`:442-447` cố ý không ghép vế V2).
- [ ] K7-FE ×2 app: nút chỉ `DaDuyet && can('Contracts','Create')` · dialog 1-line UI-1-1 · badge "Đã có HĐ" · byte-verify #77 (wave có FE-ship).
- [ ] K7-test: T1-T8 xanh, suite ≥ baseline+8 (khai k), cicd PASS.
- [ ] K7-acceptance: A1-A7 số thật (curl + sqlcmd), dán vào PR.
- [ ] K8-pre: B0 đủ 5 mục + câu giờ-G (K8.2) có trả lời.
- [ ] K8-run: B1-B14 đủ bằng chứng (ss-k8-01..13 + bảng 3 mã thật PE/KHKK/HĐ).
- [ ] K8-hậu: B15 danh sách bản-ghi-vĩnh-viễn ghi vào session-log (gồm HỐ-1-KHKK mới) · B16 không kích hoạt hoặc ghi lý do.
- [ ] DoD gốc spec-4gd: K7 `:34` + K8 `:35` tick từng dòng — không tick gộp.

END sub-invest-fable-b2-cum3 — TOTAL: K7 8v · K8 17-bước · 8 test
