IMPLBE-K7: DONE

# sub-implbe-k7 — Lane BE K7 (chuỗi SP-002 B4, S167 · 2026-08-01)

> Rào: chỉ `src/Backend/**` · KHÔNG FE · KHÔNG tests/ · KHÔNG commit. Trọng tài = `dotnet build` SẠCH.
> Artifact ghi-từng-mục chống-#53: mỗi vá xong ghi NGAY {file:line, 1 câu}.

## §0 — Tiền đề đo TRƯỚC khi gõ (KHÔNG đoán)

**git status @spawn** (sạch phía `src/`): dirty chỉ `.claude/WAL.md · .claude/agent-memory/reviewer/MEMORY.md · .claude/auto-memory/* · .claude/governance/.session-counter.json · .claude/workflows/runs/.../sub-reviewer-gate-k6.md · docs/STATUS.md` = **của lead, bỏ qua**. `src/Backend/**` = 0 file dirty. Branch `main`, HEAD `1c6d77a` (K6).

### 🔴 BÁC TIỀN ĐỀ ĐỀ BÀI — "Bridge KHKK→HĐ ĐÃ TỒN TẠI (W6-S161)" là **SAI** (4 phép đo)

| # | Phép đo | Kết quả |
|---|---|---|
| 1 | `grep -rn "CreateContractFromSigningPlan\|create-contract" src/` | **1 hit duy nhất** = `PurchaseEvaluationsController.cs:339` (bridge **PE→HĐ** cũ). 0 hit KHKK. |
| 2 | `ls src/Backend/SolutionErp.Application/ContractSigningPlans/` | `ContractSigningPlanFeatures.cs` + `Services/` — **0 file bridge** |
| 3 | `grep "HttpPost" ContractSigningPlansController.cs` | 4 POST: `""` · `{id}/dossier-items` · `{id}/attachments` · `{id}/transitions` — **0 endpoint tạo HĐ** |
| 4 | write-site `ContractSigningPlanLine.ContractId` | **0 assignment toàn codebase** (khớp đo độc lập của invest K7.0(b) @S164 **và** K5 FLAG-1 "`lines[].contractId` không tồn tại ở bất cứ đâu") |

**Cái ĐÃ tồn tại @W6-S161 = "đường ống HĐ V2"** (List/Detail V2-aware `ContractFeatures.cs:306-318` · Inbox V2 `:532-543` · guard trình `ContractWorkflowService.cs:73-92`) — invest K7.0 đã BÁC đúng chỗ này. Lead **nhập nhằng "đường ống đã vá" ⇒ "bridge đã tồn tại"** *(lead đã nhận @lượt-2)*.

### Hệ quả + quyết định lane (khai rõ, không im lặng)

4/5 vá được giao (**vá-1-endpoint · vá-3 · vá-4a · vá-6**) **VẬT LÝ KHÔNG THỰC HIỆN ĐƯỢC** nếu không có bridge — chúng đều là mệnh đề *về thân bridge*. Đặc biệt **vá-6** ("nạp Project.Code + Supplier.Code theo khuôn bridge-cũ `:63-66/:85-86`") là **no-op** nếu áp lên bridge PE cũ: bridge cũ **ĐÃ CÓ** 2 dòng đó ⇒ vá-6 chỉ có nghĩa với **bridge MỚI**.

⇒ **Quyết: DỰNG bridge KHKK→HĐ (BE-only) theo spec sẵn có `sub-invest-fable-b2-cum3.md §K7.1` (= K7.6 V1+V2) + nhúng thẳng 5 vá vào lúc dựng.** Chệch-khuôn-so-brief KHAI RÕ, trỏ đề xuất gốc (invest `:23-56` pin sẵn command signature · 6 guard · field-map · codegen · write-site · changelog · controller ⇒ **0 quyết-định-thiết-kế mới của tôi**). **KHÔNG làm:** FE (V4/V5/V6 → lane FE), test (V3/V7 → test-lane), migration (cột `ContractId` có sẵn từ Mig 69 + index `ContractSigningPlanLineConfiguration.cs:26` ⇒ **0-mig**).

### Tiền đề phụ thuộc cụm-1 — ĐO, ĐỦ

- K2 Mig 71: `ContractSigningPlanLine.TenHangMuc` (`:27`) + `CatalogEntryId` (`:23`) ✅ trên đĩa.
- K3 finalize: write-site `ApprovedAmount` ✅ `ContractSigningPlanWorkflowService.cs:348` (`line.ApprovedAmount ??= line.ProposedAmount`) ⇒ happy-path bridge có số để đọc.

---

## §1 — [vá-1a] Grant `Contracts.Create` (seeder, code-first)

- **`DbInitializer.cs:2757-2822`** — hàm MỚI `SeedContractCreateAccessAsync`: cấp `CanRead+CanCreate` trên key root **`Contracts`** cho **Drafter + Procurement + Admin**, UPGRADE-ONLY (row có sẵn → nâng đúng 2 cờ, KHÔNG hạ cờ nào, KHÔNG đụng Update/Delete; row chưa có → tạo). Khuôn sao y `SeedKeHoachKyKetAccessAsync:2672` (W1 S161).
- **`DbInitializer.cs:2361`** — call site, đặt **SAU** `RevokeTemporarilyHiddenModulesAsync:2341` (luật "chạy sau revoke để THẮNG", cùng chỗ 2 seeder anh em `:2347`/`:2354`).
- **Lỗ đã đóng (đo được, không suy đoán):** `SeedAllRolesReviewReadPermissionsAsync:2516` tạo row key `Contracts` với `CanCreate = isPe` mà `isPe = IsPeKey("Contracts") = false` ⇒ **trước S167 KHÔNG role nào mang `Contracts.Create`**; chỉ Admin lọt nhờ bypass. Không có §1 thì nút bridge 403 với 13/13 role.
- **Câu III (ai bấm) = default, chờ ratify:** comment `[K7 — câu III default, chờ anh ratify]` ngay trên mảng `roleNames` — anh chốt khác thì sửa ĐÚNG 1 mảng đó, **máy BE không đổi** (controller giữ nguyên).
- ⚠️ **Grant này ăn @RESTART API prod (seeder), KHÔNG phải deploy FE.** CẤM SQL tay (#84 — grant tay bị revoker lật, bài S159 Run #423 mất 447/494 row). Nghiệm thu = restart THẬT rồi đếm row `Permissions WHERE MenuKey='Contracts' AND CanCreate=1` (#75/#76).

## §2 — [vá-1b] Endpoint bridge = HỘI 2 khóa

- **`ContractSigningPlansController.cs:230-238`** — `POST /api/contract-signing-plans/{id}/create-contract`, `[Authorize(Policy = "Contracts.Create")]` (action) **∧** class-level `[Authorize(Policy = "KeHoachKyKet.Read")]` `:28` (đã có sẵn từ W2 — đo, không thêm). ASP.NET Core cộng dồn class+action theo **AND** ⇒ thiếu 1 trong 2 vế = **403**. Trả `201 Created` + `{ contractId, maHopDong }`.
- **`:262-266`** — body record `CreateContractFromSigningPlanBody(LineIds?, ContractType, ApprovalWorkflowId, TenHopDong?)`; `PlanId` lấy từ **route** (1 nguồn sự thật). `LineIds` nullable ở body để thiếu-field ra **400 validator** ("chọn ít nhất 1 dòng") thay vì 500 NRE.
- **`:8`** — `using SolutionErp.Domain.Contracts;` cho `ContractType`.
- **Chọn key theo gotcha #85** (đúng-key-của-endpoint): tài nguyên SINH RA là `Contract` ⇒ vế ghi phải là `Contracts.Create`, KHÔNG phải `KeHoachKyKet.Create`. Đối chiếu PE controller per-action: `PurchaseEvaluationsController` **class `[Authorize]` TRẦN + 0 policy per-action** (`:15`, `:339-343`) ⇒ bridge PE cũ chỉ cần đăng-nhập; cửa MỚI này chặt hơn hẳn — **KHÔNG đụng cửa cũ** (`POST /api/contracts` `[Authorize]` trần = #82 treo chủ đích).

## §3 — [vá-3] Guard workflow pin `IsActive ∧ IsUserSelectable`

- **`CreateContractFromSigningPlanFeatures.cs:132-148`** — "Rào (6)" **4 vế**: (a) tồn tại → `NotFoundException`; (b) `ApplicableType == Contract(3)` → Conflict; (c) `IsActive`; (d) `IsUserSelectable`.
- (a)+(b) = khuôn `ContractFeatures.cs:79-87`; **(c)+(d) là phần VÁ** — khuôn gốc thiếu, nên forge POST pin được quy trình đã retired/bỏ ghim (dropdown FE lọc 2 cờ `ContractCreatePage.tsx:334-341` nhưng FE không phải rào).
- 2 cờ **throw TÁCH RIÊNG** (không gộp `||`): "đã ngừng hoạt động" và "chưa được ghim" xử ở 2 nút khác nhau trong Designer ⇒ admin phải đọc được nguyên nhân; test-lane cũng phân biệt được 2 nhánh.

## §4 — [vá-4a] `Line.ContractId` write-site + DTO expose

- **WRITE-SITE:** `CreateContractFromSigningPlanFeatures.cs:237-238` — `foreach (var l in lines) l.ContractId = contract.Id;` = **assignment ĐẦU TIÊN toàn codebase** (§0 phép đo 4 xác nhận trước đó = 0). Đóng nợ K5 FLAG-1 "chỗ-cắm-1-dòng".
- **FALLBACK giữ nguyên chiều:** `:246` `if (pe is not null) pe.ContractId ??= contract.Id;` — `??=` CÓ CHIỀU đúng như đề bài yêu cầu: HĐ đầu của gói thắng ô đơn, HĐ 2..N **không ghi đè** (chúng vẫn thấy được qua `Line.ContractId` — đúng lý do vá-4a tồn tại). Bridge PE cũ gán thẳng `= contracts[0].Id` (`CreateContractFromEvaluationFeatures.cs:145`) vì đã chặn ở `:59-60`; ở cầu MỚI phải `??=` để 2 cầu cùng chạy trên 1 PE không cướp ô của nhau.
- **DTO — trả lời chính xác câu "đã có hay chưa" (2 DTO KHÁC NHAU, đừng gộp):**
  1. 🟢 **`ContractSigningPlanLineDto.ContractId`** (chi tiết phiếu) = **ĐÃ CÓ SẴN** từ W2-S161: khai báo `ContractSigningPlanFeatures.cs:74`, projection `:657`. ⇒ **lane FE K7 KHỎI CHỜ** — dialog "Đưa vào Hợp đồng" đọc `detail.lines[].contractId` để render badge "Đã có HĐ" + disable là **dùng được NGAY**, tôi không phải thêm gì.
  2. 🆕 **`ContractSigningPlanListItemDto.ContractIds` (`List<Guid>`)** = **THÊM MỚI** — khai báo `:65-75` (append-CUỐI đúng convention file), projection `:836-842` (đặt trong `ProjectPagedAsync` = **1 site dùng chung 3 endpoint** list/inbox/deleted). Lý do phải có: cây 4-folder `usePipelineStages.ts:287-291` dựng GĐ3 từ payload **LIST** và hôm nay chỉ có 1 nguồn `pe.contractId` đơn ⇒ gói N-HĐ thì HĐ thứ 2+ vô hình. Trả mảng **PHẲNG** (consumer là `Set<string>`), KHÔNG `Distinct()` trong projection (N dòng gộp 1 HĐ ra ID trùng — vô hại, đỡ 1 tầng dịch SQL).

## §5 — [vá-6] Project.Code/Supplier.Code + SlaDeadline + WorkflowDefinitionId

- **`:150-157`** — nạp `Project` + `Supplier` ĐỦ (không `Select` mỏng) + **2 guard `NotFoundException`** theo khuôn bridge cũ `:63-66/:85-86`; 2 con trỏ này là **loose-Guid không FK vật lý** ⇒ thiếu guard = NRE ngay tại `GenerateAsync`.
- **`:212`** — `contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct)` gọi **TRƯỚC `db.Contracts.Add`** (bài S88: codegen tự-commit SERIALIZABLE + SaveChanges nội bộ, Add trước = flush HĐ dở trong transaction của nó). Cùng thứ tự bridge cũ `:112-113` + `CreateContractSigningPlanCommandHandler:426-429`.
- **`:203-206`** — field-map `SlaDeadline = UtcNow + GetPhaseSla(DangSoanThao) ?? 7d` (thiếu thì đây là HĐ DUY NHẤT trong hệ không có hạn phase; cả 2 tiền lệ `ContractFeatures.cs:110` + bridge cũ `:109-110` đều set).
- **`:159-166` + `:201`** — `WorkflowDefinitionId = activeWfId` (V1 active theo `ContractType`) pin **NGANG** 2 tiền lệ `ContractFeatures.cs:105-109` ("Cả 2 set ok") + bridge cũ `:108`. 🔴 **Chỗ này spec-cụm-3 vá-6 THẮNG invest K7.1** (invest `:46` viết `WorkflowDefinitionId = null`) — đúng luật "16 vá thắng khi xung đột với nền" (spec `:4`); để null là rơi về policy hardcode `ContractFeatures.cs:647-662`.
- Field-map còn lại theo invest `:46`: `GiaTri = Σ ApprovedAmount(ĐÚNG tập LineIds)` `:180` · `SupplierId` từ dòng · `ProjectId/DepartmentId` từ plan · `ApprovalWorkflowId` pin V2 `:202` · `BudgetManual*` = Σ `PeReferenceAmount` `:198-199` · 2 changelog (`ContractChangelogs` Insert + `ContractSigningPlanChangelogs` Update/EntityType=Line) `:216-235` · **1 `SaveChangesAsync` cuối** `:248` (atomic, 0 HĐ dở-dang).
- 6 guard tuần tự (invest `:38-44`): phase DaDuyet `:106-108` · dòng ∈ phiếu `:111-118` · 1-HĐ-1-NCC `:121-124` · `ApprovedAmount != null` **message NÊU TÊN hạng mục** `:127-141` (KHÔNG fallback `ProposedAmount`) · `ContractId == null` per-dòng (chống double-bridge) · workflow §3.

## §6 — Build + runtime check

- **`dotnet build SolutionErp.slnx`** → **Build succeeded · 0 Warning · 0 Error** (67s). Diffstat `src/`: `ContractSigningPlansController.cs +33` · `ContractSigningPlanFeatures.cs +23/-2` · `DbInitializer.cs +86` · **file mới** `CreateContractFromSigningPlanFeatures.cs` (~250 dòng). **0 file ngoài `src/Backend/**`.**
- **Runtime check (câu hỏi thật: collection-subquery mới có dịch được trên SQLite fixture không?):**
  `dotnet test tests/SolutionErp.Infrastructure.Tests --filter "FullyQualifiedName~ContractSigningPlan"` → **34 passed / 1 failed / 35 total (12s)**.
  ✅ **Cả 3 handler đọc `ProjectPagedAsync` đều XANH** (`GroupAndCatalogTests.ListAsync` · `CrudTests.ListDeletedAsync` · `ApprovalTests.InboxAsync`) ⇒ `ContractIds` subquery **dịch được**, không phải sửa gì. (Khuôn giống `WinnerSupplierNames` list PE — nay có bằng chứng CHẠY THẬT, không chỉ tiền lệ.)
- 🔴 **1 FAIL — ĐÚNG DỰ KIẾN, thuộc lane test-specialist, TÔI KHÔNG SỬA (rào split):**
  `tests/SolutionErp.Infrastructure.Tests/Api/AuthorizePolicyRegressionTests.cs` :: `ContractSigningPlansController_EveryWriteEndpoint_HasAuthorizePolicy`.
  Nguyên nhân chính xác: `:398` `parts[0].Should().Be(MenuKeys.KeHoachKyKet)` — test bắt **MỌI** action của controller phải gate bằng key `KeHoachKyKet`; endpoint bridge cố ý gate `Contracts.Create` (đó CHÍNH LÀ vá-1, và là gotcha #85 đúng-key-của-endpoint).
  **Bàn giao test-lane:** thêm ngoại lệ cho action `CreateContract` (kỳ vọng `Contracts.Create`), giữ nguyên luật cho 15 action còn lại. Neo chống-vacuous `:375` (`>= 15 action`) **KHÔNG vỡ** — nay 16 action. Đề nghị bổ sung 2 test authz 2 chiều (đủ 2 vế → 201 · thiếu 1 vế → 403) theo spec `:9`.
- **Chưa làm / không thuộc lane (khai để không ai tưởng đã xong):** T1-T8 (test-lane) · nút + dialog ×2 app (lane FE) · A1-A7 acceptance sau deploy · FE type mirror `khkk.ts` cho `contractIds` (lane FE — JSON thừa field không gãy TS hiện tại).

END implbe-k7 — VERDICT=DONE (bridge KHKK→HĐ BE dựng xong + 5/5 vá land · build 0/0 · 34/35 test chạm, 1 fail đã-verify thuộc test-lane · 0-mig · 0 file FE/tests · chưa commit)
