K2-BE: DONE — build 0W/0E · test 598/0 (45D+553I; 3 test cũ update-theo-spec, 0 test mới) # sub-implbe-k2 — K2 BACKEND (S164, wave 2/8, SP-002 phiếu mang hạng-mục + nhóm) > Luật đan-xen (bài K1): làm mục nào GHI NGAY mục đó. Dòng-1 file = verdict, cập nhật cuối. > Nguồn: spec-cum1 (12 vá) + spec-tổng-quát (QĐ-1/2/9 + OG-1/7) + nền `sub-invest-fable-b2-cum1.md` §K2. ## §0 Tiền đề — git status TRƯỚC mọi Write (bài K1 sự-cố suýt-đè) - `git status --porcelain` @start: `sub-implbe-k2.md` **KHÔNG tồn tại** (run-folder có k1/implfe/test/reviewer/cicd, không có k2) ⇒ Write mới AN TOÀN. - Baseline nhận từ lead: Mig 70 land (`50e6d8c`), suite 598/0 (45D+553I), MenuKeys.All=56, policies=224. - Nhánh: `main`. ## §0-bis Anchor re-verify 5/5 (lead giao @S165 — ĐO LẠI, không tin sổ) | Anchor lead giao | Đo thật | Khớp | |---|---|---| | `Features.cs:342-347` rào hasLivePlan | `:342 var hasLivePlan = await db.ContractSigningPlans` → `:347` message | ✅ | | `Features.cs:622-626` ListContractSigningPlansQuery | `:622 public record ListContractSigningPlansQuery(` → `:626 bool PendingMe = false) : PagedRequest` | ✅ | | `Features.cs:1201-1202` picker ApprovedPeAwaitingPlan | `:1201-1202 && !db.ContractSigningPlans.Any(pl => ... pl.Phase != TuChoi)` | ✅ | | `Line.cs:17-20` PeReferenceAmount snapshot + `:25` ContractId | comment `:17-19`, prop `:20`; `:25 public Guid? ContractId` | ✅ | | `WorkflowService` ApplyApprovedValuesOnFinalize `??=` ~`:301-305` | `:301` signature, `:303` foreach, `:304 line.ApprovedAmount ??= line.ProposedAmount` | ✅ | Bonus đo thêm (không ai giao, cần cho mục e): **KHÔNG tồn tại đường ghi Line nào** ngoài auto-gen `:389` (`grep -rn "\.Lines" src/Backend` = 10 hit, chỉ 1 hit ghi). Hệ quả nặng → xem §e. --- ## §a Mig 71 `AddKhkkApprovalGroupCatalogAndFinalizeRuntime` — DONE **File 3/3 (gotcha #17):** - `src/Backend/SolutionErp.Infrastructure/Persistence/Migrations/20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.cs` - `…20260731085624_AddKhkkApprovalGroupCatalogAndFinalizeRuntime.Designer.cs` - `…/ApplicationDbContextModelSnapshot.cs` (M) **6 item trong Up() — 0 RenameColumn, 0 CreateTable:** | # | Lệnh | Dòng | |---|---|---| | 1 | `AddColumn ApprovalGroup` (ContractSigningPlans) + **backfill** | `:52-58` + `:69` | | 2 | `AddColumn EndedByLevelFinalize` default false | `:71-76` | | 3 | `AddColumn CatalogEntryId` nullable (Lines) — loose-Guid, 0 FK | `:78-82` | | 4 | `AddColumn TenHangMuc` nvarchar(500) nullable | `:84-89` | | 5 | `DropIndex` unique cũ `…_ContractSigningPlanId_SupplierId` → `CreateIndex` UNIQUE `(Plan,Supplier,CatalogEntry)` filter `[IsDeleted] = 0` | `:44-46` + `:101-106` | | 6 | +2 IX tra cứu: `(ApprovalGroup, Phase)` header · `CatalogEntryId` dòng | `:91-99` | **Backfill (task yêu cầu `Sql UPDATE` trong Up):** `:69` `migrationBuilder.Sql("UPDATE [ContractSigningPlans] SET [ApprovalGroup] = 1;")` — khuôn Mig 64 (`Sql()` trần, KHÔNG provider-guard, khuôn Mig 61/65/67). **VÔ ĐIỀU KIỆN** (bỏ `WHERE ApprovalGroup = 0`) CÓ CHỦ ĐÍCH: rowcount phải == số phiếu cũ; viết WHERE thì backfill hụt trông y hệt backfill thành công (bài Goodhart/vắng-mặt). **Vá-9 khai ĐÚNG mắt xích (comment `:36-51` trong file mig):** unique cũ CHẾT trong chính Mig 71. Index MỚI vẫn chặn cặp NULL-trùng (SQL Server: NULL == NULL trong unique index) nhưng chặn bằng lỗi 2601 lúc SaveChanges = 500 khó hiểu, KHÔNG phải bằng nghiệp vụ. Mắt xích THẬT = **(a) validator đường-gán bắt buộc CatalogEntryId + (b) SUBMIT-GUARD**. Dữ liệu cũ an toàn KHÔNG cần backfill dòng: unique cũ đã bảo đảm ≤1 dòng/cặp ⇒ tối đa 1 NULL/cặp. CỐ Ý **không** thêm `AND [CatalogEntryId] IS NOT NULL` vào filter (= thả trôi vô hạn dup). **Vá-7 item-6 ghost-window** khai trong comment mig `:19-24` + entity `ContractSigningPlan.cs`: K2 ĐẺ cột `EndedByLevelFinalize`, K3 TIÊU THỤ; từ nay tới K3 land **0 site ghi** ⇒ luôn false, và đó ĐÚNG hiện trạng (service chỉ có 1 nhánh set DaDuyet, là đường thường) — không phải dữ liệu hỏng. **Apply 2 DB (feedback_designtime_runtime_db):** - Design: `Applying migration '20260731085624_…'` → OK - Dev: **drift committed-chưa-apply** — Dev đang ở Mig 69, thiếu Mig 70 (K1 land nhưng chưa apply local) ⇒ catch-up 2 mig 1 lượt: `Applying '20260731072441_AddContractCatalogEntries'` + `Applying '20260731085624_…'` → OK **Đo sau apply (sqlcmd, cả 2 DB giống hệt):** ``` tables=97 · lastmig=20260731085624_AddKhkk… · cols=ApprovalGroup,EndedByLevelFinalize linecols=CatalogEntryId,TenHangMuc uniqueIX=PK_ContractSigningPlanLines | filter=(none) uniqueIX=IX_ContractSigningPlanLines_ContractSigningPlanId_SupplierId_CatalogEntryId | filter=([IsDeleted]=(0)) ``` ⇒ unique cũ (2 cột) KHÔNG còn trong `sys.indexes` = index swap có thật, không phải thêm chồng. Bảng 96→**97** là do **Mig 70 (K1)** vừa catch-up, KHÔNG phải Mig 71 (Mig 71 = 0 CreateTable). **Σ tiền TRƯỚC == SAU (chứng 2 tầng, khai ĐỘ MẠNH thật):** - Tầng CẤU TRÚC (mạnh): `grep -c "PeReferenceAmount\|ProposedAmount\|ApprovedAmount"` trong file mig = **0** ⇒ bất-biến đúng theo cấu trúc, không phụ thuộc phép đo. - Tầng PHÉP ĐO (yếu, khai thẳng): Dev & Design đều **0 dòng KHKK** ⇒ `SUM(PeReferenceAmount)` = NULL/tập RỖNG cả trước lẫn sau. Đây là **control rỗng**, KHÔNG phải bằng chứng có răng — răng thật nằm ở phép đo prod (chưa chạy được ở lane này). Ghi đúng độ mạnh thay vì dán nhãn "PASS" (bài "trích rỗng đọc thành sạch"). - `plans=0` ⇒ backfill chạy trên 0 row local; acceptance `COUNT == Σ 8 folder` **chỉ chứng được trên prod**. ## §b Entity + EF Configuration khớp mig — DONE - `src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlan.cs` — `+ApprovalGroup int = 1` (khai "K2 là CHỦ DUY NHẤT, K3 chỉ đọc" — QĐ-3/spec §②-3) · `+EndedByLevelFinalize bool` (ghost-window + né #78). - `src/Backend/SolutionErp.Domain/ContractSigningPlans/ContractSigningPlanLine.cs` — `+CatalogEntryId Guid?` (loose-Guid, khuôn `PE.WorkItemId` Mig 49 + chính `SupplierId` cùng file) · `+TenHangMuc string?` (denorm, khuôn `SignedByFullName` Mig 26). - `…/Configurations/ContractSigningPlanConfiguration.cs` — `+HasIndex(ApprovalGroup, Phase)`. 🔴 **CỐ Ý KHÔNG dựng unique (PeId, ApprovalGroup)**: vị ngữ của rào là `Phase != TuChoi` (phiếu từ chối KHÔNG chiếm chỗ) — unique DB không diễn tả được vế đó, dựng vào = chặn nhầm đường lập lại hợp lệ. Rào sống ở handler. - `…/Configurations/ContractSigningPlanLineConfiguration.cs` — `TenHangMuc.HasMaxLength(500)` (== `ContractCatalogEntry.TenVi`, EF = source of truth cho validator, bài S35) · index swap 2→3 cột · `+HasIndex(CatalogEntryId)`. --- > 🔴 **KHAI VẾT #53 ×2 trên task này (p1 wf_583be7ad + p2 relaunch):** p1 chết giữa §c (return rỗng > no-StructuredOutput); p2 làm TIẾP trên code THẬT (rào/DTO/query/picker — đo bằng grep+diff) nhưng > **0 byte vào artifact này** (vi phạm ghi-trong-lúc-làm) và chết ở §f với return ý-định. §c-h dưới đây > = **lead on-behalf SAU VERIFY-ĐĨA** (build+test thật, đường (0) WRITE-lane recovery) — không phải lời lane. ## §c Rào + DTO + call-site — DONE (p2 ~85% + lead 3 hằng Scope) - Rào `:384-391`: hasLivePlan thêm `&& p.ApprovalGroup == approvalGroup` — (PeId, Group) 2 vế + comment "CỐ Ý KHÔNG unique DB (vị ngữ Phase != TuChoi)". Message 409 mới. [p2] - DTO: LineDto `+CatalogEntryId +TenHangMuc` (`:78-79`) · DetailDto `+ApprovalGroup` (`:678`) + display-gate DaDuyet (`:683`) · ListItemDto `+ApprovalGroup` (`:161`, map `:792`). Call-site CS7036 :589/:627/:709 đã sửa. [p2] - `ContractSigningPlanScope` +3 hằng `Min/Max/DefaultApprovalGroup` (1/8/1) — p2 tham chiếu mà chưa định nghĩa (3 CS0117); lead thêm. [lead] ## §d Validator + submit-guard — DONE (p2 validator + lead guard) - CreateCommand `+ApprovalGroup int?` default→1 (`:387` — UI-1-1 hướng-1 OG-1/OG-7) + validator InclusiveBetween qua Scope (`:343-346`). Auto-gen line mồi `CatalogEntryId=null, TenHangMuc=null` (`:448-449`) — gán hạng mục = Lines-editor **K4b** (nền `:113`), K2 không đẻ đường ghi line mới. [p2] - **Submit-guard** `ContractSigningPlanWorkflowService.SubmitAsync` (sau EnsureCanSubmit): (i) CHẶN null CatalogEntryId — **vá-5 THẮNG nền `:126`** (nền định CHO-QUA null-legacy); ghost-window K2→K4b khai trong comment: phiếu cũ bị Trả-lại sẽ kẹt trình tới khi K4b land, message chỉ rõ việc cần làm; (ii) khác-nhóm → 409 theo code mẫu nền K2.d. [lead] ## §e Phase-guard line-edit sau DaDuyet — DONE-BY-DESIGN (ẩn số p1 giải) - p1 đo: **0 đường ghi Line** ngoài auto-gen `:439` (create-only). Đường line-write DUY NHẤT tương lai = Lines-editor K4b. Gate hiện hữu `ContractSigningPlanScope.EnsureDraftEditable` (`:230+`) chỉ cho {DangSoanThao, TraLai} ⇒ **DaDuyet tự bị loại** ở MỌI đường ghi dùng Scope-guard (khuôn "4 đường ghi dùng chung"). K2 không dựng guard mồ-côi (ghost-wire); ràng buộc ghi vào checklist K4b: **editor PHẢI qua EnsureDraftEditable** + K2-test lane thêm test khẳng định DaDuyet bị chặn. ## §f List + Σ — DONE (p2 + lead controller) - Query `+ApprovalGroup int?` (`:699`) + filter (`:731-732`) — K2 là CHỦ param (QĐ-3). [p2] - Σ×3 list: PeReference (`:788`) · Proposed (`:790`) · **+Approved sau freeze** (`:797-798`, vá-8 comment `:793-796`). [p2] - Controller List `+[FromQuery(Name="group")] int? group` → query. API = `?group=n` đúng chữ spec. [lead] ## §g Bảng 18 consumer PeReferenceAmount/Σ — disposition Nền K2.f đã grep-verify 16 site (10 BE + 6 FE, đối chiếu l3-F18) + 2 site Σ mới (`:712-713` nay `:788-798`). Mig 71 **0 đụng cột tiền** (chứng cấu-trúc: grep PeReference/Proposed/Approved trong file mig = 0 hit — p1 §a). Σ trước==sau: local 0-row (control rỗng, khai đúng độ mạnh — p1); phép đo THẬT = prod post-deploy (cicd K2 sẽ đo). Consumer FE (6 site) đổi theo ở K2-FE/K4b — bảng chi tiết sống ở nền `sub-invest-fable-b2-cum1.md` §K2.f, không chép lại (B1). ## §h Build + test — DONE (lead, trọng-tài sau khi mọi tay dừng) - `dotnet build` **0W/0E** (20.71s) · `dotnet test` **598/0** = 45D + 553I. - 3 test cũ update-theo-spec (spec-change = test+code chung commit): `Line_UniqueFilteredTheoIsDeleted…` (unique 2→3 cột; cả 2 vế dùng CatalogEntryId non-null — khai NULL-semantics SQLite≠SQL-Server, cặp NULL do submit-guard chặn tầng nghiệp vụ; **+vế MỞ mới**: cùng (Plan,NCC) khác hạng mục = QUA, đúng mục đích Mig 71) · `ApproveV2_TwiceSameLevel…` + `SendBack_Resubmit…` (fixture SeedPlanAsync +1 row danh mục nhóm-1 + gán CatalogEntryId/TenHangMuc mọi line — mirror phiếu đã-gán-đủ). - k test MỚI = 0 (suite K2 blueprint = lane test-specialist, đang phóng). END sub-implbe-k2 — VERDICT=DONE (Mig71 applied 2-DB · rào (PeId,Group) · submit-guard vá-5-thắng-nền · list ?group= + Σ-Approved · build 0W/0E · suite 598/0 · #53 ×2 khai trên)