# SUB-INVEST /fable-real — B2 CỤM-2: SPEC CHI TIẾT K4 + K5 + K6

> Run `2026-07-31-S164-4gd-khkk-fanout` · agent investigator-codebase (Fable) · luật ĐAN XEN: đọc đủ 1 section → viết ngay section đó.

## K4 Sidebar 8 nhóm × 6 leaf

### K4a — 49 row menu + MenuKeys.All + grant 3-site (0 phụ thuộc — chạy song song cụm-1)

1. **MenuKeys.cs — 49 key VÀO `MenuKeys.All`** (vá-1 cụm-1, 3-lane hội tụ): 7 group `Khkk_G2..G8` + 42 leaf `Khkk_G{n}_{WfView|List|Create|Pending|Approved|Deleted}` (n=2..8). Đề xuất factory `KhkkGroupKey(n)`/`KhkkLeafKey(n,action)` mirror khuôn `ContractTypeGroup`. **G1 GIỮ NGUYÊN 7 key cũ không-infix** (`Khkk_G1` + `Khkk_WfView`… — seed `DbInitializer.cs:1780-1787`): đổi key = mồ côi permission-row (S155, 13 row/key). ⚠️ Khai precedent-split để reviewer khỏi tưởng mâu thuẫn: `Ct_*`/`Pe_*` leaf CỐ Ý ngoài All (`DbInitializer.cs:2193-2195` "All… KHÔNG chứa Pe_* leaf") ⟂ catalog/AwV2 leaf TRONG All — cụm-2 đi nhánh TRONG-All theo vá-1 (ít site mirror hơn: Admin tự grant). Policy sinh thêm 49×4 registered-unused = vô hại, endpoint vẫn chỉ dùng `KeHoachKyKet.*` (#85).
2. **Seed menu-row**: thêm loop G2..G8 cạnh `:1780-1787`, khuôn PE-loop `:1896-1913` — group (parent `KeHoachKyKet`, Order n, icon FileCheck) + 6 leaf y nhãn/icon/Order G1 (`:1782-1787`: Network/List/Plus/CheckCircle2/CheckCheck/Trash2, Order 1-6). Upsert `:1926-1943`: key mới đi nhánh INSERT; key cũ chỉ re-set Order.
3. **labelBackfill G1** (dict `:1953-1995`, apply `:1997-2005` — sửa chữ trong `tree.Add` VÔ TÁC DỤNG DB đang chạy, comment `:1959-1961`): `Khkk_G1` "1. Kế hoạch ký kết HĐ (NCC-TP)" → nhãn nhóm 1. Bảng nhãn 8 nhóm (đề xuất từ `danh-muc-sp002-transcribe.md:5-16`; owner chỉnh CHỮ được, KEY không đổi):
   | Key | Nhãn đề xuất |
   |---|---|
   | Khkk_G1 (labelBackfill) | 1. Thiết bị, máy móc, vật tư phụ (A1) |
   | Khkk_G2 | 2. Kết cấu, hạ tầng & DV hỗ trợ thi công (A2+A3) |
   | Khkk_G3 | 3. Hoạt động văn phòng (A4) |
   | Khkk_G4 | 4. Vật tư (B1) |
   | Khkk_G5 | 5. Thầu phụ xây dựng (B2) |
   | Khkk_G6 | 6. Thầu phụ MEP (B3) |
   | Khkk_G7 | 7. BCH công trường (C1-C3) |
   | Khkk_G8 | 8. HĐ chưa ủy quyền (CEO) |
4. **Grant 3-site — QUYẾT: KHÔNG retire site nào, EXTEND cả 3.** Evidence:
   - Site-1 `SeedAdminPermissionsAsync` loop `MenuKeys.All` `:2058` **chỉ Admin-role** (`:2049-2050`) → 49 key vào All ⇒ Admin TỰ có row, 0 code thêm.
   - Site-2 `KhkkKeys()` `:2185-2189` (feed `reviewKeys` `:2216` → CanRead MỌI role, skip-existing): **extend += 7 group + 42 leaf**. KHÔNG retire — group-key CẦN CanRead row để render mà site-3 CỐ Ý không cấp group (comment `:2418-2419` "Khkk_G1 = group điều hướng, CanRead-all đã có từ nhánh read-only").
   - Site-3 `SeedKeHoachKyKetAccessAsync` `:2415-2472` (UPGRADE-if-exists, R+C+U 13/13 role, Delete=Drafter+Admin `:2437`): keys `:2420-2425` **extend += 42 leaf** (giữ convention không cấp cờ ghi cho group). KHÔNG retire — All-loop chỉ Admin; reviewKeys chỉ CanRead; cờ GHI chỉ site-3 phát.
5. 🔴 **Sửa grounding đề bài (evidence ngược):** bảng kỳ-vọng 13 role KHÔNG phải "7 vai có grant + 6 vai 0-leaf". `SeedKeHoachKyKetAccessAsync` loop `roleManager.Roles` `:2427` = **13/13 role R+C+U** (O-A "hiển thị hết để góp ý" `:2408-2410`). "7 vai" là seeder PE `:2669-2675` — không áp KHKK. Bảng kỳ-vọng đúng: 13/13 thấy đủ 48 leaf; cột Delete ✓ chỉ Drafter+Admin.
6. **Drift khai cùng commit** (bài cụm-1): STATUS 2 row — Menu keys 64→**113** (55→64 đã khai cụm-1) · Policies 256→**452** (+49×4). MenuItems DB +49 (Khkk% tổng 56 row). Route-map ×2 app khai trong PR (K4c).

### K4b — 4 leaf list nhận `?group=` (CHỜ K2: param `group` BE `ContractSigningPlanFeatures.cs:622-625` + DTO `approvalGroup`)

7. `KhkkListPage.tsx` ×2 app (mirror SHA — header tự khai `:3-4`): đọc `searchParams.get('group')` cạnh `filter`/`view` `:60-61` → truyền `group` vào params API `:75-82`, CẢ nhánh `/deleted` `:74`. Chip lọc giữ state-only `:11-12` (không ghi URL — bug UAT S155); `group` nằm trong URL leaf nên NavLink active-logic ăn tự nhiên.
8. **6 URL leaf G1 CŨ phải mang `group=1`** (phiếu cũ backfill `ApprovalGroup=1` — QĐ6 spec-tổng `:16`): sửa staticMap fe-user `Layout.tsx:59-62` + `:82-83` (List `/khkk/list?group=1` · Pending `?group=1&filter=ChoDuyet` · Approved `?group=1&filter=DaDuyet` · Deleted `?group=1&view=deleted` · Create `/khkk/create?group=1` · WfView `?type=10&group=1`). Root `KeHoachKyKet:79` GIỮ `/khkk/list` không-group = xem tất. Query-string là DANH TÍNH leaf (active-check `:341-347` + bài TRANSIENT `:318-331`) → group phân biệt G1..G8 sạch.
9. Nếu thiếu K2-param mà ship K4b: leaf G2..G8 List hiển thị TẤT phiếu (silent-wrong, không lỗi build) → acceptance mục 2 bắt buộc đo SAU K2.

### K4c — regex staticMap + WfView/Create (CHỜ K3: 8 Code `KHKK-N1..N8`)

10. fe-user `Layout.tsx`: regex MỚI sau `staticMap[key]` `:131`, khuôn Pe-regex `:154`: `/^Khkk_G([1-8])_(WfView|List|Create|Pending|Approved|Deleted)$/` → List `/khkk/list?group=n` · Pending `+&filter=ChoDuyet` · Approved `+&filter=DaDuyet` · Deleted `+&view=deleted` · Create `/khkk/create?group=n` · WfView `/khkk/workflow-matrix?type=10&group=n`. (G1 dùng key cũ nên đi staticMap mục 8; regex `[1-8]` phòng key G1-infix tương lai, không hại.)
11. **WfView lọc theo nhóm** (vá l2-F3 B1b): `WorkflowMatrixViewPage` hiện chỉ lọc type (`:52-71`) → đọc `group` → chỉ hiện workflow `Code === 'KHKK-N'+n` (convention Code do K3 seed; map group↔Code = 1 chỗ export const dùng chung).
12. **Create preset**: `KhkkCreatePage` ×2 app đọc `group` → preselect picker nhóm (picker + body `approvalGroup` đã là việc vá-4 cụm-1; đây CHỈ preset giá trị).
13. **fe-admin mirror — ĐÃ CHỐT bằng K6-read (không còn treo):** fe-admin `Layout.tsx` Pe-regex `:134` CỐ Ý không WfView (`:129-133` thiếu `WorkflowMatrixViewPage` toàn cục — lead chốt 2026-07-27 giữ lệch) VÀ K6 ẩn root `KeHoachKyKet` khỏi sidebar admin ⇒ **fe-admin KHÔNG nới regex `Khkk_G{n}_*`** (dead-by-K6). Chỉ fe-user Layout đổi ở K4b/c; `KhkkListPage/KhkkCreatePage` fe-admin VẪN sửa (mirror SHA — sống qua deep-link). Ghi comment khai lệch tại regex fe-user.

### Acceptance K4 (đo được, UI-first — bài cụm-1)
- [ ] A1. Login Admin ×2 app: root GĐ2 → **8 group × 6 leaf = 48 leaf HIỆN sidebar** (đếm từ UI, không chỉ curl).
- [ ] A2. Login vai thường (nv.test Drafter): CŨNG 48 leaf (13/13 grant — mục 5); bấm TỪNG LOẠI leaf ở nhóm ≠1: List/Pending/Approved/Deleted lọc đúng group+phase; Create preset đúng nhóm; WfView chỉ matrix `KHKK-N{n}`.
- [ ] A3. Bảng 13 role × {R,C,U,D} khớp seeder (R/C/U ✓ 13/13 · D ✓ Drafter+Admin): PermissionsPage matrix + 1 curl DELETE vai thường → 403 (2-tầng #82).
- [ ] A4. Key cũ 0 mồ côi: `SELECT DISTINCT MenuKey FROM Permissions WHERE MenuKey LIKE 'Khkk%'` ⊆ `MenuItems.Key`; 7 key G1 cũ nguyên row.
- [ ] A5. Restart API ×2 lần: MenuItems `Khkk%` = **56 row** đúng (7 cũ + 49 mới), không nhân; labelBackfill đổi đúng 1 nhãn G1.
- [ ] A6. Drift 2 row STATUS (Menu keys 113 · Policies 452) cùng commit + route-map ×2 app khai PR.

## K5 Cây sub-folder

**QUYẾT khuôn: thêm `groups?: PipelineGroup[]` mang leaves[] THẬT — KHÔNG đi đường `content`.** Evidence bẫy: `stageCount` (`PipelineStageFolders.tsx:74-76`) — nhánh `content` bắt truyền `count` tay (`:68-69` "Bắt buộc khi dùng content — component không đếm hộ được") còn nhánh leaves tự đếm; `content` còn mất luôn hideEmpty/failed/leaf-styling. `groups` với leaves thật ⇒ count DERIVED, bất biến cấu trúc `stage.leaves == concat(groups[].leaves)` ⇒ Σ đúng by-construction.

1. **Type** (`PipelineStageFolders.tsx` vùng `:58-72`): `export type PipelineGroup = { key: string; label: string; leaves: PipelineLeaf[] }` + `PipelineStage.groups?: PipelineGroup[]`. GIỮ `stage.leaves` = flat tổng (nguồn `stageCount:74-76` KHÔNG đổi — badge folder cha đếm như cũ).
2. **Render**: trong khối `ml-3 border-l` (`:156`), nếu `stage.groups?.length` → mỗi group 1 sub-`<details>` (chevron + nhãn + chip đếm `g.leaves.length`, tái dùng token `STAGE_STYLE:39-44`), ruột = đúng `<ul>` leaf hiện tại (`:169-202`) tách thành component nội bộ `LeafList` dùng chung 2 nhánh. Group RỖNG không render (cây per-gói-thầu — hiện đủ 8 sub-folder rỗng là noise; nhãn/thứ tự khớp sidebar KHI hiện). Không đụng `hideEmpty:228`.
3. **Builder GĐ2** (`usePipelineStages.ts:189-206`): group `khkkLeaves` theo `k.approvalGroup` (DTO field K2 — cụm-1 K2 chủ), sort n tăng dần; **`approvalGroup` null/lạ → group fallback `"(chưa phân nhóm)"` HIỆN CUỐI, KHÔNG rơi** (chống rơi-im-lặng NULL-group; sau backfill=1 fallback đóng vai canary lệch backfill).
4. **Builder GĐ3/GĐ4** (`:211-243`): group theo `c.type` 7 loại (`c.type` đã dùng `:240`). 🔴 GĐ3 leaf "ngoài chỉ mục 200" (`:222-224` không có `c` ⇒ KHÔNG biết type) → vào fallback "(ngoài chỉ mục)" — đừng ép vào loại. GĐ4 luôn có `c` (`:231` cần `c?.hasSealedCopy`) ⇒ đủ 7-loại sạch. Nhãn loại tái dùng map sẵn trong `types/contracts` (đối chiếu khi build; thiếu thì thêm map 7 nhãn "01.-07." khớp label menu `Ct_*` — `DbInitializer.cs:1988-1994`).
5. **Single-source nhãn nhóm + Code** (dùng chung K4c/K5): `types/khkk.ts` ×2 app export `KHKK_GROUP_LABELS: Record<1|..|8, string>` (đúng bảng nhãn K4a-3) + `khkkGroupWorkflowCode(n) = 'KHKK-N'+n`. Sidebar + cây + WfView filter cùng 1 nguồn — không drift chữ.
6. **Mirror ×2 app**: 2 file đích danh SHA-identical (`usePipelineStages.ts` header `:3` + `PipelineStageFolders.tsx` header `:10` tự khai luật mirror) + `types/khkk.ts`. ⚠️ Bộ pipeline có file thứ 3 `PipelineTreePanel.tsx` (l3-F19 gọi tên nó): K5 dự kiến KHÔNG đụng — verify bằng `git diff`; NẾU đụng thì SHA-compare luôn.

### Acceptance K5
- [ ] B1. Cây trên trang GĐ2/GĐ3 hiện sub-folder đúng nhóm/loại, nhãn + thứ tự khớp sidebar (cùng `KHKK_GROUP_LABELS`).
- [ ] B2. `certutil -hashfile` (hoặc `Get-FileHash`) từng cặp file ×2 app: `usePipelineStages.ts` · `PipelineStageFolders.tsx` · `types/khkk.ts` — SHA256 identical; `PipelineTreePanel.tsx` không đổi (git diff) hoặc SHA-compare nếu đổi.
- [ ] B3. `npm run build` ×2 app PASS.
- [ ] B4. Đếm: badge folder cha == Σ chip sub-folder (kể cả "(chưa phân nhóm)"/"(ngoài chỉ mục)") — thử 1 gói thầu có phiếu 2 nhóm + 1 HĐ ngoài index; KHÔNG leaf nào biến mất so với trước K5.

## K6 Admin thu gọn

**Hiện trạng:** `isAdminHidden` fe-admin `Layout.tsx:182-184` = đúng 1 luật `key.startsWith('Ct_')`; `filterForAdmin:186-190` lọc ĐỆ QUY mọi tầng ⇒ **ẩn 1 root = cả subtree chết theo** — tập key chỉ cần liệt ROOT (+2 con của System), không phải từng leaf.

1. **Mở rộng tập key tường minh** (QĐ7 — GIỮ blacklist, KHÔNG đảo whitelist):
   ```ts
   const ADMIN_HIDDEN_KEYS = new Set<string>([
     'PurchaseEvaluations',  // GĐ1 root (:1797) — Pe_* subtree chết theo
     'KeHoachKyKet',         // GĐ2 root (:1775) — Khkk_* 8 nhóm chết theo
     'Contracts',            // GĐ3 root (:1771) — Ct_* đã có prefix-rule, root nay ẩn nốt
     'HopDongCung',          // GĐ4 root (:1776) — Hdc_* chết theo
     'Forms',                // :1788
     'Reports',              // :1789
     'Master',               // Danh mục root (:1760) — Suppliers/Projects/Departments/Catalogs/HrmConfig(:1816 parent Master) chết theo
     'Hrm',                  // :1809
     'Off',                  // :1827
     'Personal',             // :1853
     'Workflows',            // :1795 — CON của System, phải liệt riêng (Wf_* chết theo)
     'PeWorkflows',          // :1798 — CON của System (PeWf_* chết theo)
   ])
   function isAdminHidden(key: string): boolean {
     return ADMIN_HIDDEN_KEYS.has(key) || key.startsWith('Ct_')
   }
   ```
   **CHỪA (không vào set):** `Dashboard` (QĐ7 giữ) · `System` + 4 leaf quản trị `Users/Roles/Permissions/MenuVisibility` (`:1791-1794`) · `ApprovalWorkflowsV2` (`:1801`) + subtree AwV2 (2 leaf seed `:1802-1803` + 8 designer K3).
2. **Sửa comment ĐỦ KHỐI** `:177-184`: khối cũ khai "[S57] BỎ ẩn Danh mục — admin quản master trực tiếp" (`:179-181`) — QĐ7 ĐẢO lời đó ⇒ comment phải viết lại (khai đảo + trỏ spec S164), kẻo lượt sau đọc comment cũ tưởng bug (bài cụm-1 "đọc comment đủ khối").
3. **Display-only, KHÔNG đụng quyền** (2 tầng #82): route fe-admin còn nguyên — admin deep-link `/purchase-evaluations`… vẫn 200. Thu gọn = menu, không phải revoke; khai rõ trong PR để không ai tưởng mất quyền.
4. **Hệ quả cho K4c (chốt nghi vấn WfView):** fe-admin Pe-regex `:134` CỐ Ý không WfView (`:129-133` — fe-admin KHÔNG có `WorkflowMatrixViewPage`, lead chốt 2026-07-27 "giữ lệch, đừng vá") + K6 ẩn `KeHoachKyKet` root ⇒ **fe-admin Layout KHÔNG cần nới regex `Khkk_G{n}_*`** (leaf không bao giờ render admin-side). Ghi 1 comment tại chỗ regex fe-user để khai lệch có chủ đích.

### Acceptance K6
- [ ] C1. **Liệt kê đúng N mục LÁ còn thấy (đủ tên — dư/thiếu 1 = FAIL).** Công thức: N = `Dashboard` + `Users` + `Roles` + `Permissions` + `MenuVisibility` + |leaf AwV2 trong DB|. Seed đọc được 2 leaf AwV2 (`:1802-1803`); ⚠️ uncertain: row `AwV2_Contract` có thể tồn tại DB ngoài seed (regex `:166-172` nhận code `Contract`) — đo trước bằng `SELECT [Key] FROM MenuItems WHERE [Key] LIKE 'AwV2%'` rồi chốt N: **7 (nếu 2 AwV2) / 8 (nếu 3)**; SAU K3 = N+8 designer `KHKK-N1..N8`.
- [ ] C2. fe-user KHÔNG đổi: `git diff --stat <base>..<head> -- fe-user/src` = **0 file** (đo diff, KHÔNG đo bundle-hash — #69/l3-F12).
- [ ] C3. l3-F9: K6 kéo sớm trước K3 ⇒ mục "đếm được 8 designer trong sidebar admin" DỜI sang acceptance K3 (không đo ở K6).
- [ ] C4. Deep-link admin 1 route bị ẩn menu (vd `/purchase-evaluations`) vẫn 200 — chứng display-only.

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

**Vẽ lại (trả lời câu "K4a chạy được sau K1 hay cần K2 DTO?"): K4a KHÔNG cần cả K1 lẫn K2** — menu-row + grant + MenuKeys.All thuần `DbInitializer`/`MenuKeys.cs`, 0 đụng schema/DTO. Nhưng ship K4a MỘT MÌNH thì 42 leaf trỏ `?group=n` mà BE chưa hiểu param ⇒ list hiện TẤT phiếu (silent-wrong, mục K4b-9) — leaf RENDER được, LỌC chưa đúng.

```
Cụm-1:   K1 ──► K2 ──► K3 (K3 đang treo OG-9)
                │        │
Cụm-2:   K6 ───┐│        │   (độc lập 100% — kéo SỚM NHẤT: còn rút việc fe-admin
         K4a ──┼┘        │    khỏi K4c [K6-4]; đổi lại C3/l3-F9: đếm-8-designer dời sau K3)
         K4b ◄─┘ (cần K2: param group :622-625 + DTO approvalGroup + backfill=1)
         K5  ◄── (cần K2: approvalGroup trên KhkkListItemDto; GĐ3/4 group-by c.type
                  tự do nhưng CÙNG FILE — làm 1 lượt sau K2 cho đỡ 2 lần mirror-SHA)
         K4c ◄── (cần K3: 8 Code KHKK-N1..N8 cho WfView filter + preset Create)
```

**Thứ tự ship đề xuất:** `K6 → K4a → [K2 land] → K4b + K5 → [K3 land] → K4c`.
- K6 + K4a chạy song song cụm-1 ngay hôm nay (0 chặn).
- K4a land trước K2 = chấp nhận cửa sổ leaf-lọc-chưa-đúng trên Dev; nếu sợ UAT hiểu nhầm bug thì gộp K4a ship cùng K4b sau K2 (đo acceptance A2 luôn thể). Acceptance A1/A3-A6 đo được NGAY sau K4a; A2 BẮT BUỘC sau K2.
- K4c KHÔNG ship-trước-degrade: filter WfView theo Code `KHKK-N{n}` khi chưa seed 8 workflow ⇒ matrix RỖNG (tệ hơn không filter) — đợi K3.

## Checklist tổng cụm-2

- [ ] **0 EF migration toàn cụm-2** — menu = seed DB + restart API (bài S155); có ai đề mig là sai khuôn.
- [ ] K4: 13 việc (K4a 1-6 · K4b 7-9 · K4c 10-13) + acceptance A1-A6 (A2 đo sau K2).
- [ ] K5: 6 việc + acceptance B1-B4 (SHA-identical 3 file ×2 app · build ×2 PASS · Σ sub-folder == folder cha).
- [ ] K6: 4 việc + acceptance C1-C4 (liệt đúng N lá kèm tên · fe-user git-diff 0 file · 8-designer dời sau K3).
- [ ] Drift khai CÙNG COMMIT K4a: STATUS 2 row Menu keys 64→113 · Policies 256→452 (nối tiếp 55→64 cụm-1) — đừng Goodhart giữ-số-đứng-yên.
- [ ] Test: **+1 BE** (extend test seed-grant cụm-1 phủ 49 key mới — khai k=1, tổng ≥590+k' cụm-1 +1); còn lại là acceptance UI/SQL đo tay (14 mục A/B/C).
- [ ] Mọi wave: cicd PASS · wave FE-ship byte-verify #77 (K4b/c, K5, K6) · wave BE-only smoke API (K4a) — l3-F17.
- [ ] Nhãn 8 nhóm (K4a-3) = đề xuất từ transcribe — owner chỉnh CHỮ tự do, KEY không đổi; không chặn wave nào (khác OG-6 vốn chỉ chặn K1-seed).

END sub-invest-fable-b2-cum2 — TOTAL: K4 13v · K5 6v · K6 4v · 1 test
