# sub-invest-fable-b1a — SPEC TỔNG QUÁT CÁC WAVE chuỗi 4 GĐ theo SOL-CCM-SP-002 (ATTEMPT 2)

> Engine /fable-real single deep-pass. Attempt-1 chết ngược-#53 (207K token, 0 byte đĩa). Lượt này: ghi từng section ngay khi xong.

## A. Kế thừa từ S157/S160

1. **O-1 (S157):** "cấu trúc trình ký GIỐNG, khác nội dung" ⇒ KHKK tái dùng khung duyệt V2 (`ApprovalWorkflow`>Step>Level OR-of-N + LevelOpinions UPSERT); dựng mới = NỘI DUNG phiếu. O-2: SLA = tham khảo, 0 engine.
2. **Bẫy Proposal-lite** (S157 §B.1): `ProposalFeatures.cs:427-439` phá OR-of-N (flatten global-level, match 1 user) ⇒ service duyệt PHẢI copy `ContractWorkflowService.ApproveV2Async:217-394`, Proposal chỉ làm khuôn CRUD/menu/FE.
3. **Ranh GĐ (owner @S159, S160 F-09):** GĐ2=b.7→12 · GĐ3=b.13→18 (tận dụng Contract V2) · GĐ4=b.19→21 ký cứng upload-only. Bản cứng vật lý SINH ở b.17-18 ∈ GĐ3; GĐ4 hiển thị readonly phần nó không tạo (F-11).
4. **GĐ4 = 1-MỐC** (owner chốt @S161, skill contract-workflow): form đủ → duyệt nếu có → upload bản cứng = xong; 0 enum-extend, badge `hasSealedCopy`. Đề xuất 4-mốc S160 F-14 đã bị owner thay bằng 1-mốc — KHÔNG hồi sinh.
5. **Hdc_\* = 7-leaf-per-ContractType** (S160 lane-2 §3 sửa grounding): loop `DbInitializer.cs:1862-1867`, KHÔNG phải bộ 6-leaf; `HopDongCung`+`Hdc_*` NGOÀI `MenuKeys.All` ⇒ 0 policy riêng, dùng root-policy `Contracts.{Action}`.
6. **3 lỗ đường ống Contract (S156/S160 §1):** `GetEligiblePhases` (`ContractFeatures.cs:334-351`) + inbox `PhaseActorRoles` 100% legacy ⇒ approver V2 không xem/inbox được HĐ ChoDuyet (W6) · bridge PE→HĐ pin V1-only + không đọc giá KHKK (`CreateContractFromEvaluationFeatures.cs:68-71,88-90`) (W5/W7) · guard trình đòi Drafter/DeptManager ⇒ PMH 403.
7. **Mặt tấn công thật:** Reject-trước-guard `ContractWorkflowService.cs:49-66` (Approve có guard, Reject không) + `DeleteContract` guard `Phase >= DangInKy` so sánh SỐ ⇒ chặn xóa cả 9/10/98/99 — HĐ dry-run trọn vòng không xóa được qua API (S160 §4 HỐ-1).
8. **"MB" không gốc quy định** (S160 F-06/F-21): RG-001 v02 chỉ 4 viết tắt (HĐTP/HĐGK/NCC/HĐDV); codegen tự chế "MB". FO-002.01 tick-box 6 loại ≠ 7 ContractType (F-23).
9. **Đã LAND sau 2 invest đó (S161-S162, phải kế thừa KHÔNG dựng lại):** Mig 69 = 7 bảng `ContractSigningPlan*` (89→96) · mã phiếu `KHKK/{YYYY}/{Seq:D3}` bỏ token loại · menu `Khkk_G1` + 6 leaf (`DbInitializer.cs:1780-1787`) · KHKK KHÔNG inherit — grant từng leaf (`:2107`, chạy SAU revoke) · 15 policy CRUD+Dossier (W2) · duyệt V2 3 trạm PRO/CCM/CEO `ApplicableType=10` (W3) · cây 4-folder `usePipelineStages` ×2 app (S162) · gotcha #85 gate FE đúng key policy `KeHoachKyKet`.
10. **Còn nợ từ S161:** W5 cầu KHKK→HĐ chưa làm ⇒ `pe.contractId` là đường duy nhất tới GĐ3; phiếu liên-danh N-HĐ chỉ nối HĐ[0] (`CreateContractFromEvaluationFeatures.cs:145`). W4 (gate/checklist) + W9 (authz) defer.
11. **Dry-run:** dự án test riêng ZZTEST (seq per-prefix riêng, ngoài seed ⇒ không resurrect #75/#76); prod Contracts = 0 active + 0 deleted (lead đo S160 — sổ "7 HĐ V1" STALE); notification bắn user thật, dặn team; login prod parse field `accessToken`.

## B. Ground-truth code (đo 07-31)

1. **`ContractSigningPlan` header** (`Domain/ContractSigningPlans/ContractSigningPlan.cs:15-53`): MaKeHoach gen NGAY LÚC TẠO (`:17-22`), PurchaseEvaluationId/ProjectId/DepartmentId/DrafterUserId loose-Guid, con-trỏ đôi StepIndex+LevelOrder (`:35-37`), SLA hiển thị tham khảo O-2 (`:39-42`). **KHÔNG có field nhóm-duyệt/hạng-mục/loại-HĐ nào.**
2. **`ContractSigningPlanLine` = per-NCC-winner, KHÔNG per-hạng-mục** (`ContractSigningPlanLine.cs:12-29`): SupplierId + PeReferenceAmount snapshot + ProposedAmount + ApprovedAmount? + `ContractId?` (C6, chờ W5). UNIQUE filtered (PlanId, SupplierId) WHERE IsDeleted=0. Model "duyệt hạng mục" MỚI chưa có chỗ đứng — xem C2.
3. **ApplicableType hiện dùng 1-10, slot 11+ trống** (`Domain/ApprovalWorkflowsV2/ApprovalWorkflow.cs:53-71`); comment `:60` chốt trục "mỗi module 1 type". `ContractSigningPlan = 10` (`:70`).
4. **POST workflow mới deactivate MỌI active CÙNG type** ("only ONE active per type" — `ApprovalWorkflowV2AdminFeatures.cs:339-343`) — 8 nhóm = 8 Code cùng type 10 thì chỉ 1 IsActive. NHƯNG vô hại cho KHKK: create validator CHỈ check ApplicableType==10, KHÔNG check IsActive/IsUserSelectable (`ContractSigningPlanFeatures.cs:209-220`); picker FE lọc `isUserSelectable=true` trong bucket type-10 (`KhkkCreatePage.tsx:57-64`) — IsUserSelectable độc lập IsActive, PATCH per-workflow (`ApprovalWorkflowV2AdminFeatures.cs:1164-1177`).
5. **KHKK service CỐ Ý KHÔNG port `CeoApprovalThreshold`/`AllowApproverFinalize`** (comment `ContractSigningPlanWorkflowService.cs:44`) ⇒ set ngưỡng trên workflow type-10 hôm nay = config-lie #78 (0 consumer). Cột sẵn `ApprovalWorkflow.cs:43`.
6. **Menu Khkk = 1 group + 6 leaf** (`DbInitializer.cs:1780-1787`: `Khkk_G1` "1. Kế hoạch ký kết HĐ (NCC-TP)" + WfView/List/Create/Pending/Approved/Deleted — leaf key KHÔNG infix G1). Grant explicit 13/13 role `SeedKeHoachKyKetAccessAsync` (`:2110`, `:2415-2424`, chạy SAU revoke). Inherit switch vẫn đúng 4 root, KHKK ngoài (`GetMyMenuTreeQuery.cs:70-74`); `IsVisible`+`DisplayLabel` pass-through DTO (`:88`).
7. **"Quy trình duyệt (Mới)" seed chỉ 2 leaf** DuyetNcc/DuyetNccPhuongAn (`DbInitializer.cs:1801-1803`); **0 workflow type-10 nào được seed** (grep `ContractSigningPlan` DbInitializer = 0 hit ngoài menu) — 8 workflow nhóm sẽ phải seed mới hoặc admin tạo tay. Designer đã nhận deep-link `/system/approval-workflows-v2/ContractSigningPlan` (`ApprovalWorkflowsV2Page.tsx:159-162`); fe-admin `Layout.tsx:166-172` resolvePath AwV2_ chỉ biết 3 code cũ.
8. **Cây 4-folder:** `usePipelineStages.ts` (MIRROR SHA256 ×2 app, `:3`) — GĐ2 nối `khkkByPeId` (`:110-121`), GĐ3/4 nối qua `pe.contractId` ĐƠN (`:214-217`, liên-danh chỉ HĐ[0]), GĐ4 = `c.hasSealedCopy` (`:231`), `c.type` CÓ SẴN trên leaf (`:240` navigate `/hard-copies?type=`). `PipelineStage` = `{n, leaves[], content?, count?}` (`PipelineStageFolders.tsx:58-72`) — leaves PHẲNG, muốn sub-folder nhóm thì hoặc thêm `groups?` vào type hoặc truyền `content` ReactNode (khuôn stage1Content `:66-69`). GĐ2 group-by cần field nhóm trên `KhkkListItemDto` (chưa có); GĐ3/4 group-by `c.type` = thuần FE.
9. **Sidebar admin = menu-tree ĐỘNG từ `AuthContext.menu` + filter client `filterForAdmin`** (`fe-admin/Layout.tsx:299,:315,:186-190`); hiện `isAdminHidden` chỉ ẩn `Ct_*` (`:182-184`). ⇒ "Ẩn hết bên Admin, chừa HỆ THỐNG + Quy trình duyệt (Mới)" = mở rộng hàm này (đảo blacklist→whitelist) — 1 file FE, 0 BE, revert 1 commit, KHÔNG đụng IsVisible (IsVisible là cột DB chung ăn CẢ fe-user).
10. **Khuôn seed catalog:** `SeedRealMasterDataAsync` (`DbInitializer.cs:2901+`) = tuple-array + per-Code idempotent, UNGATED (`:128`) — khuôn đúng cho bảng danh mục ~85 dòng. `HardCopiesPage.tsx` ĐÃ là trang thật ×2 app (GĐ4 W7 landed, đọc `?type=` `:64-67` theo hook comment).

## C. 8 treo → 8 verdict

| # | Treo (draft §6) | Verdict | Evidence / đề-xuất |
|---|---|---|---|
| 1 | Cardinality "đưa vào loại HĐ" | **[OWNER-GATE OG-1]** — đề-xuất default: **GỘP theo cặp (NCC × dòng-danh-mục)**: N hạng-mục-đã-duyệt cùng NCC cùng dòng SP-002 → 1 HĐ; nhiều Line trỏ chung 1 `ContractId` | `ContractSigningPlanLine.cs:25` ContractId per-line, KHÔNG UNIQUE ⇒ N-line→1-HĐ đã hợp khuôn; bảng 3.5 SP-002 nhiều dòng gợi gộp; default đảo được (1-1 = mỗi line 1 HĐ, cùng máy) |
| 2 | Phiếu per NHÓM hay per HẠNG-MỤC | **Per NHÓM** — 1 phiếu = 1 nhóm-duyệt, chứa N hạng-mục (Lines repurpose per-hạng-mục) | Workflow pin ở HEADER (`ContractSigningPlan.cs:35-37`) ⇒ chuỗi duyệt chạy per-phiếu; per-hạng-mục = N phiếu × 4 trạm = spam duyệt, trái "cùng 1 nhóm duyệt" (lời 7). Lines hiện per-NCC (`ContractSigningPlanLine.cs:12-29`) → Mig thêm `CatalogEntryId`+`TenHangMuc`, drop/recreate UNIQUE (cardinality change ⇒ grep consumers — module trẻ, consumer đếm được: Features + service + bridge-W5 + FE types) |
| 3 | 8 workflow nhóm ↔ ApplicableType | **1 type=10 + 8 workflow** (Code `KHKK-N1..N8`), KHÔNG mint type 11-18 | Create chỉ check type (`ContractSigningPlanFeatures.cs:209-220`), picker lọc `isUserSelectable` (`KhkkCreatePage.tsx:57-64`) ⇒ only-ONE-active-per-type (`ApprovalWorkflowV2AdminFeatures.cs:339-343`) VÔ HẠI; trục type = module (`ApprovalWorkflow.cs:60`), nhóm = DATA nghiệp vụ ⇒ cột `ApprovalGroup int` trên plan + validator line-cùng-nhóm |
| 4 | Vai trò catalog (TBP.CCM, QS…) lưu đâu | **2 tầng: catalog lưu chuỗi vai TEXT (hiển thị/in), workflow V2 Levels lưu NGƯỜI THẬT** — KHÔNG dựng bảng role-map mới | V2 match = đích danh `ApproverUserId` (skill + `ContractSigningPlanFeatures.cs:190-192`); AppRoles không có TBP.EQU/QS/MEP-SM ⇒ đẻ role mới = chế thêm, trái lời 9. Map vai→user khi seed 8 workflow [OG-2 roster] |
| 5 | NĐUQ ngưỡng tiền | **Cột sẵn `CeoApprovalThreshold` per-workflow (= per-nhóm) NHƯNG KHKK 0 consumer CỐ Ý** ⇒ đợt này = cột THAM KHẢO trên danh mục (TEXT), máy enforce = wave opt-in sau [OG-3] | `ApprovalWorkflow.cs:43` cột sống; `ContractSigningPlanWorkflowService.cs:44` comment "KHÔNG PORT 2 nhánh kết thúc sớm" ⇒ admin set hôm nay = config-lie #78 |
| 6 | Mục "1. KHKK (NCC-TP)" cũ | **GIỮ key `Khkk_G1` + 6 leaf key cũ, ĐỔI LABEL thành nhóm 1**; nhóm 2-8 = key MỚI infix `Khkk_G{n}_*` (48 leaf tổng, chấp nhận bất-đối-xứng tên G1) | Đổi key = mồ côi 13 row/key (S155); label đổi qua seed Label + labelBackfill khuôn `DbInitializer.cs:1918-1924` (S155 — upsert không đụng Label; `DisplayLabel` chỉ ăn fe-user). Phiếu thử cũ (nếu có): `ApprovalGroup=NULL`, hiện ở cây + list không-filter, KHÔNG migrate |
| 7 | Admin ẩn = cơ chế gì | **fe-admin `filterForAdmin` client-filter, đảo blacklist→whitelist** (chừa `System` subtree + `ApprovalWorkflowsV2`) — KHÔNG dùng `IsVisible` | Sidebar admin render ĐỘNG từ menu-tree (`fe-admin/Layout.tsx:299,:315`), filter `:182-190` hiện chỉ ẩn `Ct_*`; `IsVisible` là cột DB CHUNG ăn cả fe-user (`GetMyMenuTreeQuery.cs:88`) ⇒ ẩn admin-only bằng DB sẽ tắt nhầm eOffice. Ẩn ≠ xóa ✓ (revert 1 commit) |
| 8 | B2 trùng số "7" ×2 + "TPB/TBP" dấu "/" | **Extract file gốc SOL-CCM-SP-002 bằng office-document = việc ĐẦU wave master-data**; "/" đọc là OR-of-N (khớp V2 nhiều Level cùng Order); trùng-số → đánh mã lại B2-01..B2-14, nếu extract vẫn mơ hồ → [OG-5] | OR-of-N = `ApprovalWorkflow.cs:81-94` (S157 xác nhận); số ~85 dòng = đếm từ ảnh, số chính xác PHẢI từ file gốc (draft §2) |

## D. SPEC TỔNG QUÁT WAVE

Nhãn **K1-K8** (tránh đụng W1-W9 của S161/S160 — W1-W3+W7-W8-tương-đương ĐÃ LAND, W5/W6 tái xuất trong K7). Thứ tự = phụ thuộc: **K1 (master-data) đứng TRƯỚC mọi wave UI ăn nó (K2, K4, K5)**; K8 = dry-run E2E cuối bắt buộc. Mỗi wave deploy độc lập.

| K | Tên · mục tiêu 1 câu | Phạm vi BE/FE/mig/seed | Khuôn tái dùng (lời 9: nhân khung, đổi form) | Định-nghĩa-XONG đo được | Cụm |
|---|---|---|---|---|---|
| **K1** | **Master-data danh mục HĐ SOLUTION** — bảng catalog ~85 dòng SP-002 (mã A1..C3 · tên · nhóm-duyệt 1-8 · người-ký · chuỗi-nháy · ghi-chú NĐUQ) admin CRUD được | BE: entity + config + CQRS + controller per-action policy; **Mig 70 CreateTable** filtered-unique Code #57; seed `SeedContractCatalogAsync` per-Code idempotent ungated; FE: leaf mới dưới Master/Catalogs ×2 app | Khuôn `SeedRealMasterDataAsync` (`DbInitializer.cs:2901+`) + catalog 4-leaf KIND_CONFIG; extract file gốc = việc ĐẦU wave (treo 8) | Bảng đủ N dòng == số extract từ file gốc (khai N khi extract xong); CRUD admin 200; `dotnet test` PASS + test seed idempotent (chạy 2 lần không nhân đôi); restart API không nhân row | 1 |
| **K2** | **Phiếu KHKK mang HẠNG MỤC + NHÓM** — Lines per-NCC → per-hạng-mục, phiếu có `ApprovalGroup` | BE: **Mig 71** (plan +`ApprovalGroup int?`; Lines +`CatalogEntryId Guid?` +`TenHangMuc` + DropIndex UNIQUE(PlanId,SupplierId) → UNIQUE(PlanId,SupplierId,CatalogEntryId) filtered); validator line-cùng-nhóm-phiếu; DTO; FE: form Lines editor pick dòng danh mục lọc theo nhóm ×2 app | Cardinality change ⇒ **grep MỌI consumer Lines** (Features/service/bridge/FE types); row cũ backfill `CatalogEntryId=NULL` = "hạng mục tổng" giữ số | Tạo phiếu nhóm X + N hạng mục qua UI; line khác nhóm bị 409; phiếu cũ mở được nguyên vẹn; test PASS (+ test validator ÂM) | 1 |
| **K3** | **8 workflow nhóm + designer** — mỗi nhóm 1 workflow Code `KHKK-N1..N8` type-10, Levels đích danh từ roster [OG-2] | BE: seed 8 workflow (khuôn `SeedSampleApprovalWorkflowsV2Async` `DbInitializer.cs:171`) IsUserSelectable=true; create/update pin lọc workflow đúng nhóm (Code map `ApprovalGroup`); FE: picker lọc theo nhóm (`KhkkCreatePage.tsx:57-64` + filter Code); menu AwV2 leaf KHKK (`:1801-1803` + fe-admin `Layout.tsx:166-172` +code) | Designer type-10 ĐÃ nhận deep-link (`ApprovalWorkflowsV2Page.tsx:159-162`) — 0 designer mới; chuỗi trạm per nhóm theo draft §2 bảng 8 nhóm | 8 workflow tồn tại qua `GET /approval-workflows-v2?applicableType=10`; phiếu nhóm 3 KHÔNG pin được workflow nhóm 5 (409); duyệt 3-4 trạm 1 phiếu nhóm bất kỳ chạy hết → DaDuyet | 1 |
| **K4** | **Sidebar eOffice fan-out 8 nhóm × 6 leaf** — relabel G1 + thêm G2-G8 (48 leaf) | Seed menu (`DbInitializer.cs:1780-1787` extend + labelBackfill G1) + grant seeder (`SeedKeHoachKyKetAccessAsync:2415` extend — KHKK KHÔNG inherit `GetMyMenuTreeQuery.cs:70-74`); FE ×2 app: regex staticMap `Khkk_G{n}_*` + list pages nhận `?group=n`; 0 mig (menu = seed DB, restart API — S155) | 6-chỗ-mirror S155 (menu + 2 site permission + regex Layout ×2 app); gate FE = đúng key policy `KeHoachKyKet` #85, KHÔNG OR key con | 8 mục + 48 leaf hiện đúng thứ tự; bấm từng leaf list lọc đúng `ApprovalGroup`; user 13/13 role không dính 403 rải UI; key cũ `Khkk_*` còn nguyên (0 mồ côi) | 2 |
| **K5** | **Cây toàn trình sub-folder nhóm/loại** — GĐ2 group-by `approvalGroup`, GĐ3+GĐ4 group-by `c.type` (7 loại), thay "Chưa có dữ liệu" | BE: `KhkkListItemDto` +`approvalGroup` (1 field); FE ×2 app: `usePipelineStages.ts` + `PipelineStageFolders.tsx` thêm tầng `groups` (hoặc `content` ReactNode khuôn stage1) — MIRROR SHA256 sửa 1 bên PHẢI copy | `PipelineStage` type `PipelineStageFolders.tsx:58-72`; GĐ3/4 đã có `c.type` (`usePipelineStages.ts:240`); sidebar ↔ cây = MỘT bộ nhóm hai mặt (draft §3.iii) | Cây gói thầu có phiếu GĐ2 hiện sub-folder nhóm đúng; GĐ3/4 hiện sub-folder theo loại HĐ khớp sidebar; 2 app SHA-identical 2 file pipeline; `npm run build` ×2 PASS | 2 |
| **K6** | **Admin thu gọn** — ẩn mục nghiệp vụ cũ, chừa HỆ THỐNG + "Quy trình duyệt (Mới)" | FE fe-admin DUY NHẤT: `filterForAdmin`/`isAdminHidden` (`Layout.tsx:182-190`) đảo whitelist; 0 BE 0 mig 0 seed | Ẩn ≠ xóa: client filter, revert 1 commit; KHÔNG đụng `IsVisible` (ăn cả fe-user `GetMyMenuTreeQuery.cs:88`) | Sidebar admin chỉ còn khối HỆ THỐNG (Users/Roles/Permissions/Menu) + Quy trình duyệt (Mới) + 8 nhóm KHKK designer-entry; fe-user KHÔNG đổi (diff bundle fe-user = 0) | 2 |
| **K7** | **Cầu KHKK→HĐ + đường ống HĐ V2** (tái xuất W5+W6 S160 — **đầu wave RE-ĐO đã land chưa**) | BE: bridge `CreateContractFromEvaluationFeatures.cs` đọc `ApprovedAmount` per-Line + pin `ApprovalWorkflowId` V2 + ghi `Line.ContractId` + gộp theo OG-1; `GetEligiblePhases`/inbox V2 (`ContractFeatures.cs:334-351`/`:421-430` nếu còn legacy-only); FE: nút tạo HĐ từ KHKK DaDuyet ×2 app | Khuôn `ResolveV2InboxIdsAsync` (PE + KHKK `ContractSigningPlanFeatures.cs:229` đã có bản mirror); acceptance W6/W7 S160 §7 tái dùng nguyên | Từ phiếu KHKK DaDuyet tạo được HĐ đúng số lượng theo OG-1, `giaTri==ApprovedAmount`; HĐ trình → approver V2 THẤY + inbox; hết kẹt `ConflictException` (`ContractWorkflowService.cs:115-116`); test bridge PASS | 3 |
| **K8** | **DRY-RUN E2E** từ `PE/2026/A/049` (gói "14 Mat PVC, vải địa") — toàn trình 4 GĐ trên prod | 0 code (chỉ chạy + vá lặt vặt); convention ZZTEST cho record mới; bằng chứng screenshot + phiếu thật + cây đúng sub-folder | Checklist S160 §4: HĐ test DaPhatHanh KHÔNG xóa được (guard numeric `ContractFeatures.cs:632-633`) → admin-override hoặc chấp nhận; notification bắn user thật — dặn team; PE test mới per vòng (`pe.ContractId` idempotency) | Phiếu KHKK từ PE/2026/A/049 đi hết trạm nhóm → HĐ → (GĐ3/4 tới đâu nghiệm thu tới đó); cây gói thầu hiện đủ 4 GĐ có leaf; login prod parse `accessToken` | 3 |

**Cụm cho B2:** cụm-1 = K1+K2+K3 (nền data + phiếu + duyệt) · cụm-2 = K4+K5+K6 (UI fan-out — thuần hiển thị, chờ cụm-1 cho field `approvalGroup`) · cụm-3 = K7+K8 (nối GĐ3 + chạy thật). K6 độc lập hoàn toàn — có thể kéo lên sớm nếu owner muốn thấy admin gọn trước.

## E. [OWNER-GATE] list
(ĐANG LÀM)

## F. Rủi ro + guard
(ĐANG LÀM)
