# sub-invest-fable-b2-cum1 — B2 CỤM-1: SPEC CHI TIẾT K1+K2+K3 (S164, /fable-real, ATT-2)

> Engine /fable-real single deep-pass — ATT-2 (att-1 chết skeleton-ruột-rỗng 472B, đọc 152K chưa viết gì). Luật sắt att-2: ĐAN XEN đọc→viết per section. Nguồn pin: spec v2 §② 9 quyết định + danh-muc-sp002-transcribe (86 row) + khuôn code sống (DbInitializer / ContractSigningPlan* / PE Mig 67).

## K1 Master-data danh mục (ContractCatalogEntries + Mig 70 + seed 86 + CQRS + FE ×2)

**PRE-gate:** [OG-6] anh soát `danh-muc-sp002-transcribe.md` (4 ô ⚠️ cuối file) TRƯỚC khi land seed. Code/schema land trước được, seed-data commit sau gật.

### K1.a — Entity + schema bảng `ContractCatalogEntries`

Entity `ContractCatalogEntry : AuditableEntity` (soft-delete — mirror khuôn 4 catalog Master sẵn) tại `src/Backend/SolutionErp.Domain/Master/ContractCatalogEntry.cs` (cạnh WorkItem — folder Domain/Master hiện hữu).

| Cột | Type SQL | Null | Ghi chú |
|---|---|---|---|
| `Id` | uniqueidentifier PK | NOT NULL | BaseEntity |
| `Code` | nvarchar(20) | NOT NULL | `A1-01`…`CEO-01` — **UNIQUE filtered `WHERE [IsDeleted]=0`** (#57, khuôn Mig 47) |
| `TenVi` | nvarchar(500) | NOT NULL | tên VN (dài nhất A3-09 ~150 ký tự) |
| `TenEn` | nvarchar(500) | NULL | tên EN (seed đủ 86; nullable cho row tay sau) |
| `GroupCode` | nvarchar(10) | NOT NULL | `A1..A4, B1..B3, C1..C3, CEO` — section SP-002 |
| `ApprovalGroup` | int | NOT NULL | **1..8** — map N1..N8 (validator range, KHÔNG DB CHECK — khuôn repo không dùng CHECK) |
| `SignerRole` | nvarchar(100) | NOT NULL | TEXT vai ký chính (QĐ-4 2-tầng: TEXT in GĐ4 ⟂ Levels người thật) |
| `InitialsChain` | nvarchar(300) | NULL | chuỗi ký nháy `TBP.CCM→NV.CCM→TBP.EQU→NV.EQU` (Ô1→Ô4, bỏ N/A) |
| `NduqNote` | nvarchar(500) | NULL | tham khảo NĐUQ [OG-3] — seed **null** (ảnh không có số per-dòng, ⚠️3 transcribe) |
| `IsActive` | bit | NOT NULL def 1 | ẩn khỏi picker không cần xoá (khuôn WorkItem Mig 49 validator IsActive) |
| `SortOrder` | int | NOT NULL def 0 | thứ tự trong nhóm (seed = STT trong section) |
| audit 7 cột | — | — | CreatedAt/UpdatedAt/CreatedBy/UpdatedBy + IsDeleted/DeletedAt/DeletedBy |

Index: `UX_ContractCatalogEntries_Code` UNIQUE filtered `[IsDeleted]=0` · `IX_ContractCatalogEntries_ApprovalGroup` non-unique (picker K2 lọc theo nhóm phiếu).
Config `Persistence/Configurations/ContractCatalogEntryConfiguration.cs`: ToTable + HasQueryFilter(!IsDeleted) + 2 index + MaxLength như bảng. DbSet vào `ApplicationDbContext` + `IApplicationDbContext`.

**Mig 70 `AddContractCatalogEntries`** — 1 CreateTable, bảng **96→97**. 3-file rule (Up/Down + Designer + Snapshot — gotcha #17). Down = DropTable (reversible, chưa ai tham chiếu tại K1; FK từ Lines là chuyện Mig 71).

### K1.b — Seed `SeedContractCatalogAsync` (86 row, per-Code idempotent, UNGATED)

- **Gate: UNGATED** — mirror `SeedRealMasterDataAsync` (`DbInitializer.cs:2893-2896` "INFRASTRUCTURE master data thật... NOT gated DemoSeed") — danh mục SP-002 là master data thật phải có mặt prod ngay deploy, không phải demo.
- **Né #75/#76 (resurrect):** lookup existing codes PHẢI `IgnoreQueryFilters()`:
  `var existing = await db.ContractCatalogEntries.IgnoreQueryFilters().Select(c => c.Code).ToListAsync();`
  (⚠️ khuôn `SeedRealMasterDataAsync:2970` dò `db.Projects.Select(p=>p.Code)` KHÔNG IgnoreQueryFilters ⇒ chính là lỗ #75/#76 — row soft-deleted vô hình → RE-CREATE mỗi restart. K1 làm ĐÚNG hơn khuôn, ghi comment lý do. Đây là hit `IgnoreQueryFilters` đầu tiên trong src/Backend — S155 đo 0-hit — cố ý, khai trong PR.)
- Foreach 86 tuple `(Code, TenVi, TenEn, GroupCode, ApprovalGroup, SignerRole, InitialsChain, SortOrder)` hardcode inline (khuôn :2904 — runtime không đọc file ngoài) → skip-if-contains else Add → 1 SaveChanges + log count.
- **Mapping transcribe → field:** cột `Mã`→Code · `Tên VN`→TenVi · `Tên EN`→TenEn · heading section (A1..C3, 5.2)→GroupCode · cột `→ Nn` heading→ApprovalGroup · bảng "Nhóm duyệt (8)" đầu file: `Ký chính`→SignerRole, `Nháy Ô1→Ô4` (bỏ N/A)→InitialsChain — **denorm per-row** (86 row cùng nhóm chép cùng chuỗi; QĐ-4 chấp nhận TEXT). STT trong section→SortOrder.
- Đếm theo nhóm (khớp transcribe): N1=5 · N2=10 (A2 1+A3 9) · N3=5 · N4=23 · N5=14 (⚠️1 B2 đánh lại 01-14) · N6=15 · N7=13 (C1 6+C2 3+C3 4) · N8=1 → **Σ=86**.
- Gọi trong `InitializeAsync` cạnh `SeedRealMasterDataAsync` (trước sample workflows).

### K1.c — BE CQRS + controller (QUYẾT policy: mirror CatalogsController, KHÔNG policy mới)

**SOI MẪU:** `Api/Controllers/CatalogsController.cs:13-14` = `[Route("api/catalogs")]` + class `[Authorize]` (đăng nhập = đọc được); 12 action write đều `[Authorize(Roles = "Admin")]` (:23-:130). Grep `Authorize(Policy = "(WorkItems|MaterialItems|UnitsOfMeasure|ServiceItems)` toàn Controllers = **0 hit** ⇒ "Catalogs.*"-policy per-action KHÔNG tồn tại trong khuôn catalog.
**QUYẾT:** mirror y khuôn — GET `[Authorize]` thừa hưởng class, 4 mutation `[Authorize(Roles = "Admin")]`. Lý do: (i) luật "GIỐNG NHAU — ĐỪNG CHẾ THÊM"; (ii) picker tạo phiếu K2 cần READ rộng mọi user KHKK — nếu khoá `KeHoachKyKet.Read` thì user thiếu grant leaf sẽ 403 picker (#44/#85); (iii) `KeHoachKyKet.Update` (drafter sửa phiếu) KHÔNG được phép sửa danh mục — sai vai; (iv) OG-8 "phân quyền tính sau" — 0 policy/menu-key mới = 0 drift STATUS row Policies.

- CQRS: file MỚI `Application/Master/Catalogs/ContractCatalogFeatures.cs` (cạnh `CatalogsFeatures.cs` hiện hữu — mega-file 1 file, Pattern 12-ter):
  `ListContractCatalogQuery {ApprovalGroup?, GroupCode?, Search?, IncludeInactive?}` → all-rows sort (GroupCode, SortOrder), KHÔNG paging (86 row; picker + trang admin dùng chung) · `GetContractCatalogEntryQuery {Id}` · `CreateContractCatalogEntryCommand` + `UpdateContractCatalogEntryCommand` (validator: NotEmpty Code/TenVi/GroupCode/SignerRole; `ApprovalGroup` InclusiveBetween(1,8); dup-Code active-set → ConflictException — so exact `Code ==`, filtered-unique cho phép trùng với row đã xoá mềm) · `DeleteContractCatalogEntryCommand` soft-delete (Line K2 giữ FK Restrict chặn hard-delete; soft-delete OK vì Line denorm `TenHangMuc`).
- Controller: **EXTEND `CatalogsController`** +5 action `GET/POST/PUT/DELETE api/catalogs/contract-catalog[/{id}]` — không controller mới (khuôn 4 kind sẵn trong 1 controller).

### K1.d — FE ×2 app (KIND_CONFIG kind mới)

- File mẫu: `fe-admin/src/pages/master/CatalogsPage.tsx` + `fe-user/src/pages/master/CatalogsPage.tsx` (KIND_CONFIG `Record<Kind,...>` theo `:kind` URL — khuôn S29/S35).
- Thêm kind `contract-catalog` vào KIND_CONFIG ×2 app: columns {Code, TenVi, TenEn, GroupCode, ApprovalGroup (badge N1..N8), SignerRole, InitialsChain, IsActive, SortOrder}; form create/edit đủ field; endpoint `api/catalogs/contract-catalog`. Duplicate 2 app CÓ CHỦ ĐÍCH (CLAUDE.md).
- Menu leaf: +const `MenuKeys.ContractCatalog` (**NGOÀI `All`** — khuôn leaf `Pe_*`/tiền lệ `HopDongCung` NGOÀI All = 0 policy mới; hiển thị đi đường Permission row) + 1 MenuItem row con group `Catalogs` trong `SeedMenuTreeAsync` + grant admin (SeedAdminPermissionsAsync loop) + mirror `fe-{admin,user}/src/lib/menuKeys.ts`. Route `:kind` = `contract-catalog` — 0 route file mới.

### K1.e — Test (k=5)

1. `Seed_ContractCatalog_86Rows_Idempotent` — chạy seed ×2 → count active == 86, không nhân row.
2. `Seed_SkipsSoftDeletedCode_NoResurrect` — soft-delete `A1-01` → re-seed → active == 85, row không hồi sinh (#75/#76 có răng).
3. `Create_DuplicateActiveCode_Throws409_ButAllowsReuseOfSoftDeletedCode` — dup active → Conflict; Code trùng row IsDeleted=1 → OK (filtered #57 2 chiều).
4. `CreateOrUpdate_ApprovalGroupOutOfRange_FailsValidation` — 0 và 9 fail, 1 và 8 pass.
5. `Delete_IsSoft_ListExcludes_RowRemains` — Delete → list 85, row DB IsDeleted=1.

### K1.f — Checklist acceptance K1

- [ ] `dotnet ef migrations add AddContractCatalogEntries …` → đủ **3 file** trong `Persistence/Migrations/`; `git status` show 3.
- [ ] Restart API → log "Seeded 86 contract catalog entries"; `sqlcmd -S "(localdb)\MSSQLLocalDB" -d SolutionErp_Dev -Q "SELECT COUNT(*) FROM ContractCatalogEntries WHERE IsDeleted=0"` → **86**; `SELECT ApprovalGroup, COUNT(*) … GROUP BY ApprovalGroup` → 5/10/5/23/14/15/13/1.
- [ ] Restart API lần 2 → vẫn 86 (idempotent by-restart-THẬT, khuôn S91).
- [ ] `curl -H "Authorization: Bearer <token>" localhost:5443/api/catalogs/contract-catalog` → 200, 86 item; POST thiếu role Admin → 403.
- [ ] `dotnet test SolutionErp.slnx` → baseline (STATUS.md row Tests, hiện ~590) **+5**, khai k=5.
- [ ] DoD spec: N dòng ~85±5 khớp — **đúng 86**, lệch 0; tên VN+EN đủ 86/86.
- [ ] STATUS.md: bảng 96→**97** + Mig 69→**70**; schema-diagram migration table +row 70.

## K2 Phiếu hạng-mục + nhóm (Mig 71: ApprovalGroup + CatalogEntryId + rào (PeId,Group) + budget-freeze QĐ-9)

**PRE-gate:** [OG-1] + [OG-7] — cả 2 ✅ CHỐT @S164 (spec §④). Phụ thuộc K1 (bảng catalog phải có trước để trỏ).

### K2.a — Mig 71 `AddContractSigningPlanGroupAndLineCatalog` (3-file rule, no new table)

1. `ContractSigningPlans` **+`ApprovalGroup int NOT NULL, defaultValue: 1`** — AddColumn với default tự backfill mọi row hiện có = QĐ-6 backfill=1 (khuôn `EvaluationType` l3-F5; KHÔNG cần `Sql()` riêng — default áp cả Design-DB 0-row lẫn prod-DB có-row, né #64 lệch môi trường).
2. `ContractSigningPlanLines` **+`CatalogEntryId uniqueidentifier NULL`** — **loose-Guid KHÔNG FK vật lý** (QUYẾT: mirror y Mig 49 `PE.WorkItemId` "convention PE: ProjectId/SelectedSupplierId đều loose"; chính Line.cs:15 `SupplierId` đã loose cố ý — thêm FK thật là phá khuôn module) + `IX_ContractSigningPlanLines_CatalogEntryId`.
3. `ContractSigningPlanLines` **+`TenHangMuc nvarchar(500) NULL`** — denorm tên tại thời điểm gán (catalog rename sau KHÔNG viết lại lịch sử phiếu — khuôn `SignedByFullName` denorm Mig 26).
4. **Đổi UNIQUE:** DropIndex unique cũ trên (`ContractSigningPlanId`,`SupplierId`) (tên EF-sinh — lấy đúng từ `ContractSigningPlanLineConfiguration.cs` vùng :24-26 khi build) → CreateIndex **UNIQUE (`ContractSigningPlanId`,`SupplierId`,`CatalogEntryId`) filtered `WHERE [IsDeleted]=0`**.
   - ⚠️ **Bẫy NULL-unique SQL Server:** trong UNIQUE index, NULL == NULL ⇒ 2 row cùng (Plan,Supplier) đều `CatalogEntryId NULL` sẽ VI PHẠM. Data cũ AN TOÀN (unique cũ bảo đảm ≤1 row/cặp ⇒ tối đa 1 NULL-row/cặp). Chặn phát sinh mới bằng validator: **line MỚI/SỬA bắt buộc có `CatalogEntryId`** (chỉ row legacy được giữ null). KHÔNG thêm `AND CatalogEntryId IS NOT NULL` vào filter — làm vậy là thả trôi vô hạn dup (Plan,Supplier,null).
5. Config update cùng file `ContractSigningPlanLineConfiguration.cs`: 2 property + index swap; `:29 WithMany(p => p.Lines)` giữ nguyên.

### K2.b — QĐ-9 budget-freeze: QUYẾT **per-line, TÁI DÙNG `ApprovedAmount` — 0 cột tiền mới, KHÔNG chép 11 cột PE Mig 67**

**Bằng chứng máy-freeze ĐÃ TỒN TẠI:**
- `ContractSigningPlanLine.cs:17-20` — `PeReferenceAmount` = SNAPSHOT chụp lúc TẠO, comment tự khai *"cùng triết lý freeze ngân sách Mig 67"*; `:23 ApprovedAmount` — *"CHỈ ghi tại choke-point finalize (W3)"*.
- `ContractSigningPlanWorkflowService.cs:304` — `line.ApprovedAmount ??= line.ProposedAmount;` foreach `:303` chạy tại finalize; `:290` comment cảnh báo đúng bài #81-EXT: *"Bỏ sót một nhánh = phiếu 'Đã duyệt' mà ApprovedAmount rỗng ⇒ GĐ3 sinh hợp đồng 0đ"*.
- Grep `PeWorkItemBudget|WinnerQuoteTotal` trong `ContractSigningPlan*` = 1 hit label (`Features.cs:162` DTO picker) — **KHKK KHÔNG đọc bảng ngân sách live nào**. PE Mig 67 cần 11 cột vì PE detail đọc LIVE `PeWorkItemBudgets`; KHKK mọi số đã là cột local của chính nó.
**⇒ Lý do QUYẾT:** freeze = có sẵn về SCHEMA; K2 chỉ thêm RĂNG + GATE (luật "ĐỪNG CHẾ THÊM"). Header KHÔNG cần cột: `TotalPeReferenceAmount` (list DTO `:53`, Σ `:711`) derive từ lines bất biến.

**Việc phải làm (răng + gate):**
- **#81-EXT sweep — bảng Phase-assignment-site KHKK** (grep `Phase\s*=[^=]` — chú ý KHÔNG grep `\.Phase` vì object-initializer `Features.cs:373` không có dấu chấm, thoát lưới hẹp):
  | Site | Gán | Freeze? |
  |---|---|---|
  | `Features.cs:373` | `Phase = DangSoanThao` (create initializer) | không cần (không vào DaDuyet) |
  | `WorkflowService.cs:174` | `= ChoDuyet` (submit) | không cần |
  | `WorkflowService.cs:278` | `= DaDuyet` (finalize choke-point) | ✅ đã gọi hook `:303-304` |
  | `WorkflowService.cs:342` | `= targetPhase` (gán qua BIẾN — đúng class bẫy `:39` comment) | 🔴 PHẢI XỬ: nếu nhánh này reachable với `targetPhase == DaDuyet` (admin-override/return-path) thì hook `:304` bị bypass ⇒ hoặc (i) gọi `ApplyApprovedAmounts` ngay trước `:342` khi `targetPhase == DaDuyet`, hoặc (ii) assert nhánh không nhận DaDuyet + test khoá hành vi. Mirror đúng bài PE Mig 67 site-5 admin-override. |
  Acceptance: re-grep khi build, liệt ĐỦ site trong PR (seeder/DbInitializer hiện 0 site gán phase KHKK).
- **Display-gate `frozen`:** DetailDto +`budgetFrozen = phase == DaDuyet` (khuôn tên PE S133); FE DaDuyet đọc `ApprovedAmount` + badge khoá, trước đó đọc `ProposedAmount`; mọi editor line (K4b) chỉ mở ở Nháp/TraLai — sau DaDuyet cấm sửa/xoá line (xoá mềm line sau DaDuyet làm Σ `:711` trôi ÂM THẦM — đây là leak gián tiếp duy nhất, chặn bằng phase-guard).

### K2.c — Đổi rào 1-PE-1-phiếu → (PeId, ApprovalGroup) [OG-7] — 2 site

- **Site 1 create `Features.cs:342-347`:** `AnyAsync(p => p.PurchaseEvaluationId == pe.Id && p.Phase != TuChoi)` → **+`&& p.ApprovalGroup == request.ApprovalGroup`**; message mới: *"Phiếu Duyệt NCC này đã có kế hoạch cho NHÓM duyệt này. Chọn nhóm khác hoặc xoá kế hoạch cũ."* `CreateContractSigningPlanCommand` +`ApprovalGroup int` (validator `InclusiveBetween(1,8)`); create gán `plan.ApprovalGroup = request.ApprovalGroup`.
- **Site 2 picker `Features.cs:1201-1202`** (query PE-eligible): bỏ loại-trừ PE-đã-có-phiếu → trả kèm **`existingGroups: int[]`** per PE (groups của các plan sống). **UI hướng-1 (OG-7 verbatim):** PE đã có phiếu → vẫn hiện, hiển thị phiếu hiện có + nút phụ **"Tạo thêm cho nhóm khác"** (disable các nhóm ∈ existingGroups trong select nhóm) — thay 409 chặn hẳn. FE `KhkkCreatePage` ×2 app + `types/khkk.ts` DTO picker.
- Auto-gen Lines `:389` giữ per-winner, mồi `CatalogEntryId = null, TenHangMuc = null` (gán hạng mục = Lines-editor K4b, KHÔNG thuộc K2 — create hiện *"FE KHÔNG gửi lines"* `KhkkCreatePage.tsx:8`).

### K2.d — Validator line-cùng-nhóm (code mẫu)

Chạy ở 2 chỗ: upsert-lines (K4b, fail-fast) + **submit-guard** (service, chống data cũ lách):
```csharp
var ids = plan.Lines.Where(l => l.CatalogEntryId != null)
    .Select(l => l.CatalogEntryId!.Value).Distinct().ToList();
var badGroup = await db.ContractCatalogEntries
    .AnyAsync(c => ids.Contains(c.Id) && c.ApprovalGroup != plan.ApprovalGroup, ct);
if (badGroup) throw new ConflictException(
    "Có hạng mục không thuộc nhóm duyệt của phiếu — mọi dòng phải cùng nhóm N" + plan.ApprovalGroup);
```
Line `CatalogEntryId == null` (legacy nhóm-1 backfill) → CHO QUA (không chặn phiếu cũ resubmit).

### K2.e — List-query + DTO + bất-biến Σ

- `ListContractSigningPlansQuery` (`Features.cs:622-626`) +`ApprovalGroup int? = null` → `if (request.ApprovalGroup is not null) q = q.Where(p => p.ApprovalGroup == request.ApprovalGroup);` — **K2 là CHỦ param này** (QĐ-3/l3-F6; K4b leaf + K5 cây chỉ TIÊU THỤ).
- `ContractSigningPlanListItemDto` (record vùng `:34-53`) +`ApprovalGroup` · `DetailDto :608-616` +`ApprovalGroup` +`BudgetFrozen` · `LineDto :570-574` +`CatalogEntryId` +`TenHangMuc` · FE `types/khkk.ts` mirror ×2 app.
- **Bất-biến Σ PeReferenceAmount:** Mig 71 KHÔNG đụng data tiền ⇒ `SELECT SUM(PeReferenceAmount) FROM ContractSigningPlanLines` TRƯỚC == SAU migrate (acceptance đo 2 lần). Redefine semantics (QĐ-2b): row legacy = tham chiếu per-NCC-toàn-phiếu (giữ nguyên); row MỚI cùng NCC (tách hạng mục, K4b) mồi `PeReferenceAmount = 0` ⇒ **Σ per-(phiếu,NCC) == số PE gốc bất biến khi tách dòng**; khai trong doc + tooltip FE.

### K2.f — Bảng consumer `.Lines` ĐÃ-XỬ / KHÔNG-ĐỔI (grep-verified; đối chiếu danh sách 16 = 10 BE + 6 FE của l3-F18 `b1b-review-synthesis.md`)

| # | Site | Disposition |
|---|---|---|
| 1 | `WorkflowService.cs:111` Include Lines | KHÔNG-ĐỔI (Include tự nạp cột mới) |
| 2 | `WorkflowService.cs:296` comment scope | KHÔNG-ĐỔI (doc) |
| 3 | `WorkflowService.cs:303-304` foreach freeze | **XỬ** — thêm răng site-`:342` (K2.b) |
| 4 | `LineConfiguration.cs:29` WithMany | KHÔNG-ĐỔI; cùng file **XỬ** +2 property + index swap |
| 5 | `Features.cs:389` auto-gen create | **XỬ** — mồi 2 field null + comment semantics |
| 6 | `Features.cs:412` changelog count | KHÔNG-ĐỔI |
| 7 | `Features.cs:417` result count | KHÔNG-ĐỔI |
| 8 | `Features.cs:500` Include detail | KHÔNG-ĐỔI |
| 9 | `Features.cs:524` supplierIds | KHÔNG-ĐỔI |
| 10 | `Features.cs:568-574` projection LineDto | **XỬ** — +2 field |
| 11 | `Features.cs:711` Σ TotalPeReference (list) | KHÔNG-ĐỔI (bất-biến K2.e giữ nghĩa) |
| 12-13 | `types/khkk.ts:203` ×2 app | **XỬ** — `KhkkLineDto` +2 field optional |
| 14-15 | `KhkkDetailPage.tsx:425-441` ×2 app | **XỬ** — +cột "Hạng mục" (TenHangMuc, fallback "—") |
| 16-17 | `KhkkCreatePage.tsx:8` ×2 app | KHÔNG-ĐỔI đợt này (create không gửi lines; editor = K4b) |

(17 site đếm được > "16" của l3-F18 vì `:296` comment + `:711` — dư an toàn hơn thiếu; PR đối chiếu từng dòng.)

### K2.g — Test (k=7)

1. `Mig71_BackfillsApprovalGroup1_CountEqualsSumOfFolders` — mọi plan cũ group==1.
2. `Create_SamePeSameGroup_409_SamePeOtherGroup_201` — rào mới 2 chiều (DoD "phiếu nhóm 2 CÙNG PE → 201").
3. `Lines_UniqueTriple_DupCatalog_Throws_DiffCatalog_Ok` — cùng (plan,supplier,catalogA) ×2 fail; +catalogB pass (năng lực mới).
4. `Submit_LineWrongGroup_409_LegacyNullCatalog_Pass` — validator K2.d 2 chiều.
5. `Finalize_FreezesAllLines_NoNullApprovedAmount` — sau DaDuyet mọi line ApprovedAmount != null (`:303-304`).
6. `TransitionViaVariableSite_CannotReachDaDuyetWithoutFreeze` — răng site-`:342` (theo phương án (i)/(ii) đã chọn).
7. `Sum_PeReferenceAmount_InvariantAcrossMigAndSplit` — Σ trước/sau + tách dòng mồi 0.

### K2.h — Checklist acceptance K2

- [ ] Mig 71 đủ 3 file; `sqlcmd … "SELECT SUM(PeReferenceAmount) FROM ContractSigningPlanLines"` trước & sau update → **bằng nhau** (ghi 2 số vào PR).
- [ ] `sqlcmd … "SELECT COUNT(*) FROM ContractSigningPlans WHERE ApprovalGroup=1"` == tổng phiếu hiện có; COUNT(tất cả) == Σ COUNT 8 nhóm.
- [ ] curl create phiếu nhóm 2 trên PE đã có phiếu nhóm 1 → **201**; lặp nhóm 1 → **409**.
- [ ] curl list `?approvalGroup=2` → chỉ phiếu nhóm 2; DTO có `approvalGroup`.
- [ ] Grep `Phase\s*=[^=]` toàn `ContractSigningPlan*` → liệt đủ site vào PR (đối chiếu bảng K2.b).
- [ ] `dotnet test` +7, khai k=7; bảng K2.f dán vào PR từng dòng (bài S119 disposition TỪNG DÒNG, không số tổng).

## K3 8 workflow KHKK-N1..N8 + designer + validator pin-đúng-nhóm

**PRE-gate:** [OG-2] ✅ "Đúng đội hình cũ" — nhưng PHẢI **verify đội hình `QT-DN-V2-001` bản LIVE** trước seed (SSH+sqlcmd prod chết khi SQL-client load — S134/S148 ⇒ đo qua API prod `GET /api/approval-workflows-v2` bằng curl + token admin; Dev làm fallback, flag rõ trong PR nếu prod bất khả).

### K3.⚠️ MÂU THUẪN SPEC↔CODE phải xử TRƯỚC (finding chính của lane)

Spec §④ OG-3 ghi *"K3 **0 code mới** cho nhánh này — CHỈ config workflow"* (tái dùng level-finalize V2). Nhưng code THẬT: `fe-admin/src/pages/system/ApprovalWorkflowsV2Page.tsx:151-152` — `KHKK_EARLY_FINALIZE_NOTE = 'Chưa hỗ trợ cho quy trình Kế hoạch ký kết HĐ — backend chưa xử lý đường "duyệt sớm", tick vào sẽ KHÔNG có tác dụng.'` (Designer KHOÁ 2 ô duyệt-sớm cho type-10) + `ContractSigningPlanWorkflowService.cs` chỉ có finalize thường `:278` (KHÔNG có nhánh AllowApproverFinalize; MEMORY-verified `:44` comment không port CeoApprovalThreshold). Cột `AllowApproverFinalize` trên `ApprovalWorkflowLevels` CÓ SẴN (Mig 58 — dùng chung bảng, 0 mig) — cái thiếu là NHÁNH SERVICE + mở khoá Designer.
**⇒ K3 PHẢI gồm 3 việc code (không phải 0):** (a) port nhánh duyệt-sớm vào `ContractSigningPlanWorkflowService` — approver tại level `AllowApproverFinalize=true` tick "kết thúc tại đây" → nhảy thẳng choke-point `:278` (**BẮT BUỘC đi qua `ApplyApprovedAmounts :303-304`** — nối răng K2.b, không tự gán Phase tay #81-EXT) + ghi Changelog ContextNote marker `"Duyệt KẾT THÚC tại %"` (khuôn PE Mig 61/#79); (b) +cột runtime `ContractSigningPlans.EndedByLevelFinalize bit NOT NULL DEFAULT 0` — display đọc RUNTIME không suy CONFIG (né tái diễn #78) — **gắn vào Mig 71 item 6 (K2.a) nếu K2 chưa land; nếu đã land → Mig 72 AddColumn riêng**; (c) fe-admin GỠ khoá 2 ô + note `:151-152` cho type-10 (không gỡ = config-lie NGƯỢC: BE hỗ trợ mà UI bảo không).

### K3.a — Seed `SeedKhkkApprovalWorkflowsAsync` (khuôn `DbInitializer.cs:171-217`)

- **Idempotent PER-CODE** (KHÔNG per-type như khuôn `:174-176` — vì 8 code cùng type): `if (await db.ApprovalWorkflows.AnyAsync(w => w.Code == code)) continue;`
- **Fail-soft** thiếu user: `FindByEmailAsync` từng email, null → `LogWarning` + return KHÔNG seed (mirror `:179-183`), boot không chết.
- 8 workflow: `Code = "KHKK-N1".."KHKK-N8"`, `ApplicableType = 10 (ContractSigningPlan)`, `Version=1`, `IsActive=true`, **`IsUserSelectable=true`** (picker lọc isUserSelectable — `KhkkCreatePage.tsx:56-66`), `ActivatedAt=UtcNow`.
- Tên VN (từ transcribe): N1 "KHKK — Nhóm 1: Thiết bị, máy móc, vật tư phụ (A1)" · N2 "…Nhóm 2: Kết cấu, hạ tầng, phá dỡ + Dịch vụ hỗ trợ thi công (A2+A3)" · N3 "…Nhóm 3: Hoạt động văn phòng (A4)" · N4 "…Nhóm 4: Vật tư (B1)" · N5 "…Nhóm 5: Thầu phụ xây dựng (B2)" · N6 "…Nhóm 6: Thầu phụ MEP (B3)" · N7 "…Nhóm 7: BCH công trường (C1+C2+C3)" · N8 "…Nhóm 8: CEO — HĐ chưa ủy quyền (5.2)".
- **Đội hình GIỐNG NHAU cả 8** (OG-2 "đội hình cũ"; ký-nháy KHÁC nhau nằm ở TEXT catalog K1 — QĐ-4 2-tầng, KHÔNG nằm ở Levels):
  Step 1 "Phòng Cung ứng" (Dept Code `PRO`) — C1 Order=1 OR-of-4 · C2 Order=2 ×1
  Step 2 "Phòng KSCP" (Dept `CCM`) — C1 Order=1 OR-of-4 · C2 Order=2 ×1 **`AllowApproverFinalize=true`** (QĐ-9: a Chương quyết kết-thúc-hay-lên-CEO)
  Step 3 "Ban Giám đốc" (Dept `BOD`) — C1 Order=1 ×1
- **Bảng resolve email (đối chiếu `DbInitializer.cs:1613-1632` — 14 user Solutions thật):**
  | Vai | Tên roster | Email seed | Nguồn |
  |---|---|---|---|
  | PRO C1 | Phượng | `phuong.nguyen@solutions.com.vn` (Nguyễn Thị Bích Phượng) | `:1614` |
  | PRO C1 | Duy | `duy.nguyen@solutions.com.vn` (Nguyễn Văn Duy) | `:1617` |
  | PRO C1 | Long | `long.chau@solutions.com.vn` (Châu Tạ Kim Long — ⚠️ ĐỪNG nhầm `long.nguyen@` HRA/ISO `:1629`) | `:1616` |
  | PRO C1 | Thanh | `thanh.lethanh@solutions.com.vn` (Lê Thanh Bình) | `:1615` |
  | PRO C2 | Trà | `tra.bui@solutions.com.vn` (Bùi Lê Thủy Trà, TP Cung ứng) | `:1613` |
  | CCM C1 | Nguyên | `nguyen.ho@solutions.com.vn` (Hồ Thị Nữ Nguyên) | `:1623` |
  | CCM C1 | Lưu | `luu.tran@solutions.com.vn` (Trần Xuân Lưu) | `:1622` |
  | CCM C1 | Trưởng ⚠️ | `truong.le@solutions.com.vn` (Lê Trần Đăng Trường, NV CCM) — ⚠️ roster ghi "Trưởng", DB có "Trường"(Lê `:1626`) + "Trình"(`tring.le@` `:1625`): xác nhận qua verify-LIVE QT-DN-V2-001, lệch thì hỏi owner | `:1626` |
  | CCM C1 | Binh | `binh.le@solutions.com.vn` (Lê Văn Bình) | `:1621` |
  | CCM C2 | Chương | `chuong.phan@solutions.com.vn` (Phan Văn Chương, TP KSCP) | `:1620` |
  | BGĐ | Trường | `truong.nguyen@solutions.com.vn` (Nguyễn Văn Trường, CEO) | `:1632` |

### K3.b — Validator phiếu-pin-đúng-nhóm (map Code↔ApprovalGroup = CONVENTION parse suffix)

- **QUYẾT: convention `^KHKK-N([1-8])$` — 0 mig, 0 cột mới.** Mở rộng `ContractSigningPlanScope.EnsureWorkflowTypeAsync` (`Features.cs:350` create + `:458-462` re-pin UpdateDraft — 2 call-site cùng helper = sửa 1 chỗ) thành `EnsureWorkflowTypeAndGroupAsync(db, awId, approvalGroup, ct)`: nếu `workflow.Code` match pattern → group parse PHẢI == plan.ApprovalGroup, lệch → `ConflictException("Quy trình KHKK-N{x} không thuộc nhóm duyệt N{y} của phiếu.")` (DoD: phiếu nhóm 3 pin nhóm 5 → 409); Code KHÔNG match pattern (vd `QT-KHKK-V2-001` — DEFAULT_CODE_BY_TYPE `ApprovalWorkflowsV2Page.tsx:170` gợi ý admin tự tạo) → **pass + LogWarning** (không chặn phiếu UAT cũ/quy trình tay). Phương án cột `ApprovalWorkflows.ApprovalGroup` tường minh hơn = defer, chỉ nâng khi owner cần chặn cứng quy trình tay.

### K3.c — 8 menu-con designer + resolvePath + route per-code (fe-admin ONLY — designer không có ở fe-user, S155)

- MenuKeys.cs +8 const `AwV2_KhkkN1..AwV2_KhkkN8` — **NGOÀI `All`** (0 policy mới, tiền lệ `HopDongCung`/leaf `Pe_*`); +8 MenuItem row con node `ApprovalWorkflowsV2` (`DbInitializer.cs:1801` vùng seed menu — QĐ-7 CHỪA subtree này khi thu gọn admin), Label "Nhóm n — <tên ngắn>", Order n.
- fe-admin `Layout.tsx` resolvePath vùng `:166-172` (map `AwV2_*` 3-code hiện tại) **+8 entry** `AwV2_KhkkN{n}` → `/system/approval-workflows-v2/ContractSigningPlan?code=KHKK-N{n}` — leaf lạ KHÔNG map = rớt im lặng #50, phải đủ 8.
- `ApprovalWorkflowsV2Page.tsx`: deep-link type `ContractSigningPlan` ĐÃ MỞ (`:155-163` TYPE_CODE_TO_INT slot 10 `:147`); THÊM đọc `?code=` → filter danh sách definition đúng Code + prefill ô "Mã quy trình" = code đó (thay gợi ý `:164-171`). Mở designer nhóm n load đúng `KHKK-N{n}` (DoD l3-F10).
- mirror `fe-admin/src/lib/menuKeys.ts` +8 const.

### K3.d — Test (k=5)

1. `SeedKhkk_8Workflows_Idempotent` — chạy ×2 → đúng 8 row type-10, mỗi Code 1 (đếm-8).
2. `SeedKhkk_Shape_PerWorkflow` — 3 Steps; PRO Order1×4+Order2×1; CCM Order1×4+Order2×1 có `AllowApproverFinalize=true` đúng level Chương; BOD ×1.
3. `SeedKhkk_MissingUser_SkipWarn_NoBootCrash` — thiếu 1/11 email → skip + warn.
4. `CreateOrRepin_WrongGroupCode_409_CustomCode_PassWarn` — K3.b 3 chiều (đúng/lệch/code-lạ).
5. `KhkkEarlyFinalize_ChuongTick_DaDuyet_FreezeApplied_RuntimeFlagSet` — nhánh (a) K3.⚠️: qua choke-point, `ApprovedAmount` không null, `EndedByLevelFinalize=1`, marker Changelog ghi.

### K3.e — Checklist acceptance K3

- [ ] Verify-LIVE đội hình: curl prod `GET /api/approval-workflows-v2` (hoặc Dev + flag) — đối chiếu 11 email bảng K3.a TRƯỚC khi commit seed; ô ⚠️ "Trưởng" chốt được thì ghi vào PR, không thì hỏi owner.
- [ ] Restart API ×2 → `sqlcmd … "SELECT Code FROM ApprovalWorkflows WHERE ApplicableType=10 AND Code LIKE 'KHKK-N%'"` → **đúng 8**, không nhân.
- [ ] fe-admin sidebar: node ApprovalWorkflowsV2 đếm được **8 menu-con**; click "Nhóm 3" → designer load đúng `KHKK-N3`.
- [ ] curl create phiếu group=3 pin `KHKK-N5` → **409**; pin `KHKK-N3` → 201; duyệt trọn trạm 1 phiếu → DaDuyet (DoD §③-K3).
- [ ] Chương tick kết-thúc-tại-cấp → phiếu DaDuyet + mọi line có ApprovedAmount + cờ runtime =1; ô Designer duyệt-sớm type-10 hết khoá (note `:151-152` gỡ).
- [ ] `dotnet test` +5, khai k=5.

## Thứ tự commit + phụ thuộc chéo

**Tuyến tính K1 → K2 → K3; seed-86 tách chờ OG-6 (không chặn nhánh sau).**

1. **Commit K1-code** `[CLAUDE] Domain+Infra+App: add ContractCatalogEntry + Mig 70 + CQRS + FE kind` — entity/config/DbSet ×2 interface + **Mig 70** 3-file + `ContractCatalogFeatures.cs` + extend `CatalogsController` + FE ×2 KIND_CONFIG + menu row + menuKeys mirror + test K1 {3,4,5}. Restart local verify bảng rỗng OK. ⚠️ update `SolutionErp.slnx` KHÔNG cần (không .csproj mới) — chỉ file .cs mới trong project sẵn.
2. **Commit K1-seed** (SAU [OG-6] anh gật transcribe): `SeedContractCatalogAsync` 86-row + test K1 {1,2}. Restart ×2 → 86 idempotent. **Prod:** deploy xong seed tự chạy (UNGATED) — không cần tay.
3. **Commit K2** `[CLAUDE] App+Infra: KHKK ApprovalGroup + Line catalog + freeze răng (Mig 71)` — **Mig 71 gồm cả item 6 `EndedByLevelFinalize`** (forward-provision cho K3 — QUYẾT gộp để K3 **0-mig**; nếu lead tách thì K3 tự mang Mig 72) + rào 2 site + validator cùng-nhóm + list param + DTO + FE types + test K2 ×7. **Phụ thuộc K1-code** (validator K2.d query bảng catalog — phải CÓ bảng; không cần data 86). Σ PeReferenceAmount đo TRƯỚC/SAU migrate ghi PR.
4. **Commit K3** `[CLAUDE] Infra+FE-Admin: seed 8 KHKK workflows + early-finalize + designer per-code` — seed 8 + nhánh duyệt-sớm service + validator pin-group + 8 menu-con + Layout +8 + `?code=` + gỡ note khoá `:151-152` + test K3 ×5. **Phụ thuộc K2** (cột `ApprovalGroup` + `EndedByLevelFinalize`). PRE: verify-LIVE đội hình (K3.e bước 1) TRƯỚC khi commit.
5. Mỗi commit qua Gitea CI (~3 phút, path-filter docs-only 0s); prod IIS restart → `DbInitializer.MigrateAsync` áp mig + seed tự chạy; **restart ×2 verify idempotent** (khuôn S91). Docs cuối cụm: `STATUS.md` (Mig 69→**71**, bảng 96→**97**, test +**17**), `schema-diagram.md` +2 row mig, `HANDOFF.md`, tick `migration-todos.md`.

**Phụ thuộc chéo tóm tắt:** K2.d cần bảng K1 (không cần 86 row — null-pass) · K3.a seed KHÔNG đọc catalog (label hardcode) — chỉ cần K2 cột · K3.⚠️(b) cột runtime NẰM TRONG Mig 71 = K2 phải land trước K3 · seed-86 lơ lửng độc lập chờ OG-6 · K4a (cụm-2) 0 phụ thuộc cụm-1, chạy song song được (l3-F8).

## Checklist tổng cụm-1

- [ ] **Mig:** `__EFMigrationsHistory` top = Mig **71**; Mig 70+71 mỗi cái đủ **3 file** (`git show --stat`); bảng `sys.tables` = **97**.
- [ ] **K1:** catalog active = **86**; phân bố nhóm `GROUP BY ApprovalGroup` = **5/10/5/23/14/15/13/1**; restart ×2 không nhân; CRUD 200/403 đúng vai; tên VN+EN 86/86.
- [ ] **K2:** `COUNT(ContractSigningPlans)` == Σ COUNT 8 nhóm (backfill=1 đủ); phiếu nhóm-2-cùng-PE → **201**, trùng nhóm → **409**; line khác nhóm → 409; `SUM(PeReferenceAmount)` TRƯỚC == SAU (2 số ghi PR); grep `Phase\s*=[^=]` liệt đủ site PR; bảng 17-consumer dán PR TỪNG DÒNG.
- [ ] **K3:** đúng **8** workflow `KHKK-N%` type-10; sidebar admin **8 menu-con**, designer nhóm-n load `KHKK-N{n}`; pin lệch nhóm → 409; duyệt trọn trạm → DaDuyet; Chương tick kết-thúc → DaDuyet + freeze + cờ runtime; 2 ô Designer type-10 hết khoá.
- [ ] **Test:** `dotnet test SolutionErp.slnx` = baseline (STATUS row Tests) + **17** (k khai: K1=5 · K2=7 · K3=5), 0 fail.
- [ ] **CI/prod:** 4 commit CI PASS; BE-only wave = smoke API (l3-F17, KHÔNG cần byte-verify #77 trừ commit có FE-ship → curl-grep bundle marker); STATUS/schema-diagram/HANDOFF cập nhật.
- [ ] **OG còn mở khi land:** OG-6 (chặn DUY NHẤT commit K1-seed) · ô ⚠️ "Trưởng" K3.a (chốt qua verify-LIVE hoặc hỏi owner).

END sub-invest-fable-b2-cum1 — TOTAL: K1 6v · K2 8v · K3 6v · 17 test.
