IMPLFE-K5: DONE

# Lane FE K5 — cây toàn-trình per-gói: GĐ2 mở 8 sub-folder NHÓM (S166, 2026-08-01)

Nền: K4a+K4b đã commit `88e7ecf`. SPEC PIN: spec-cụm2 vá-9 + checklist K5 + forward-provision B3-A5.

## §0 Kế hoạch đo trước khi gõ
- [ ] Grep CHÍNH XÁC bộ file pipeline trong `fe-user/src` (spec nói "4 file" — đừng tin, đếm)
- [ ] Đọc `usePipelineStages.ts`: shape DTO builder đang ăn, có `lines[].contractId` không
- [ ] Đọc component render folder: tiền lệ folder GĐ "SẮP" (hiện-cả-khi-rỗng)
- [ ] SHA baseline từng file ×2 app

## §1 ĐO ĐĨA (ground truth) — TRƯỚC khi gõ

### 1.1 Bộ file pipeline = ĐÚNG 4, và cả 4 ĐANG SHA-pair ×2 app
| file | sha256 baseline (×2 app IDENTICAL) |
|---|---|
| `hooks/usePipelineStages.ts` | `7144eeb787a8ff8b5ed861d7278fe31450162c23da51dafb6266a3a9196ab148` |
| `components/pipeline/PipelineStageFolders.tsx` | `d449cc8dae55c55aab4a8bd4521e113361e0859de93bdac4a4e7099f8cecdc2d` |
| `components/pipeline/PipelineTreePanel.tsx` | `d70ac315bae485e6e0f1fad8327caf063b4b22cabde53c59d9847b0d407e73d3` |
| `components/pe/PePipelineStrip.tsx` | `6fb7337e4bb9c60a579e1dc561f6742785e9526cd8fd4b4bce593eec1c8dda65` |
2 call-site của `buildStages`: `PipelineTreePanel.tsx:303` và `PurchaseEvaluationsListPage.tsx:524` (cái sau truyền `stage1Content`).

### 1.2 Tiền lệ "hiện cả khi rỗng" CÓ SẴN — bám đúng nó, không đẻ khuôn mới
`PipelineStageFolders.tsx:149-153`: `count > 0 ? <badge số> : <SoonChip/>`; `:166-167` thân rỗng in *"Chưa có dữ liệu ở giai đoạn này."*; `:95` `muted` làm xám nhãn. Sub-folder nhóm sẽ dùng LẠI đúng 3 thứ này (chip "Sắp" + dòng rỗng + muted), không thêm token màu mới.

### 1.3 🔴 FORWARD-PROVISION B3-A5: `lines[].contractId` **KHÔNG TỒN TẠI** — không bịa
Đo 4 nơi, cả 4 đều âm:
1. `types/purchaseEvaluation.ts` — `PeListItem:112-…` có **`contractId: string | null`** (`:126`) và **0 hit chữ `lines`** trong TOÀN file.
2. `Domain/PurchaseEvaluations/PurchaseEvaluation.cs:31` — `public Guid? ContractId` (1 FK đơn, không phải tập).
3. `types/contracts.ts` `ContractListItem:84-101` — **KHÔNG có `purchaseEvaluationId`** ⇒ không thể đi ngược từ index HĐ về phiếu.
4. `Domain/Contracts/Contract.cs` — **0 hit** `PurchaseEvaluationId`/`SourcePe`.

🔴 **Bằng chứng đắt nhất, chính BE tự khai** (`CreateContractFromEvaluationFeatures.cs:143-145`):
```
// Idempotency: pe.ContractId = HĐ ĐẦU (chặn tạo lại). N HĐ liên-danh truy qua changelog +
// Contract (FE nhận list contractIds). 1 phiếu = 1 link chính + N hợp đồng.
pe.ContractId = contracts[0].Id;
```
Endpoint create TRẢ `contractIds: string[]` (FE nhận ở `PeDetailTabs.tsx:2186`) nhưng **chỉ HĐ ĐẦU được ghi ngược vào phiếu**; HĐ 2..N chỉ còn dấu vết trong changelog. ⇒ Đúng như đề bài lo: **HĐ thứ 2+ VÔ HÌNH trên cây** — và đó là giới hạn DỮ LIỆU, không phải lỗi builder.
⇒ Vế wire được lượt này: **đổi HÌNH DẠNG builder từ "đọc 1 id" sang "gom một TẬP id"** (union), nguồn thứ hai để sẵn chỗ cắm 1 dòng. Vế KHÔNG wire được: chính nguồn thứ hai (không có field nào để đọc). FLAG-1.

### 1.4 GĐ3/GĐ4 "by `c.type`" — kiểm, KHÔNG đụng
Builder hiện KHÔNG gom GĐ3/4 theo loại HĐ; `c.type` chỉ dùng ở `onOpen` GĐ4 → `/hard-copies?type=${c.type}` (`:240`, khớp `HardCopiesPage.tsx:64-67`). Đang đúng ⇒ giữ nguyên, chỉ đổi phần lấy id sang tập (1.3).

### 1.5 Ghi chú kiểu dữ liệu
`KHKK_APPROVAL_GROUPS` là tuple `as const` ⇒ `.includes(g: number)` sẽ đỏ TS; phải ép `as readonly number[]`. `k.approvalGroup` khai `number` nhưng payload cũ trong cache React Query có thể thiếu (K2 để `!= null` guard ở 2 page) ⇒ lọc bằng `typeof g === 'number'`. `import.meta.env` có sẵn (5 call-site), dùng `.DEV` cho assert Σ.

## §2 THỰC THI — 2/4 file pipeline đổi (2 file kia KHÔNG cần đụng, xem §3C)

### F1 — `components/pipeline/PipelineStageFolders.tsx` ×2 (+165 / −54)
- Type mới `PipelineGroup {key,label,leaves,onOpenAll?}` + `PipelineStage.groups?`.
  🔴 `stage.leaves` GIỮ vai trò danh sách PHẲNG canonical (nguồn duy nhất để đếm — `stageCount`, `hideEmpty`, chip số đều không phải sửa); `groups` chỉ **chia lại chính nó**. Nhờ vậy bất-biến Σ là chuyện CẤU TRÚC chứ không phải kỷ luật.
- Tách `LeafList` (dùng chung thân folder ⟂ thân ngăn — 0 nhân bản markup) + thêm `GroupFolder`.
- `GroupFolder`: `<details>` mặc định ĐÓNG (8 ngăn mở sẵn = tường chữ ở cột trái 19rem); summary = chevron + **nút tên ngăn** (`preventDefault`+`stopPropagation` ⇒ đi trang mà không kèm một nhịp đóng/mở nhấp nháy) + chip số **hoặc `SoonChip` khi rỗng**; thân rỗng in "Chưa có phiếu ở nhóm này."
- Tái dùng 100% token cũ (`SoonChip`, `style.head/count/code`, `muted`→`slate-500` giữ tương phản AA) — **0 màu mới, 0 icon mới**.

### F2 — `hooks/usePipelineStages.ts` ×2 (+122 / −54)
- **GĐ2 chia ngăn:** `toKhkkLeaf` tách ra dùng 2 nơi; phân hoạch theo `k.approvalGroup`; 8 ngăn dựng từ `KHKK_APPROVAL_GROUPS` + nhãn `khkkGroupMenuLabel(n)` (types, K4a); `onOpenAll → /khkk/list?group=${n}` khớp ĐÚNG URL leaf sidebar K4b ⇒ bấm ngăn hay bấm menu ra cùng trang, và trang tự sáng đúng mục.
- **Hiện đủ 8 kể cả rỗng** (`.map` trên hằng, không `.filter`) — mirror tiền lệ folder GĐ chưa có gì.
- **Ngăn "(chưa phân nhóm)"** chỉ mọc khi THẬT SỰ có phiếu lạc (nhóm null/undefined/thiếu field/ngoài 1..8). Khác 8 ngăn kia: 8 nhóm là khung nghiệp vụ cố định, đây là chỗ chứa bất thường — hiện thường trực một ngăn luôn rỗng là nhiễu.
- **Bất-biến Σ:** bảo đảm bằng vòng phân hoạch; thêm chuông `import.meta.env.DEV && console.warn` phòng người sau sửa vòng lặp thành lọc-trùng/lọc-sót.
- **Forward-provision B3-A5:** `contractIdsOfPackage = new Set<string>()` gom id (union) rồi mới dựng GĐ3/GĐ4; chỗ cắm nguồn thứ hai để sẵn kèm ghi chú vì-sao + trích nguyên văn BE. `seen` cũ bỏ (Set tự khử trùng). Hành vi GĐ3/4 **0 đổi** (cùng tập HĐ, cùng thứ tự, cùng dedup).
- **GĐ3/GĐ4 theo `c.type`:** kiểm — builder KHÔNG gom theo loại; `c.type` chỉ dùng ở `onOpen` GĐ4 → `/hard-copies?type=` (khớp `HardCopiesPage.tsx:64-67`). Đang đúng ⇒ **KHÔNG đụng** (đúng lệnh mục 2).

## §3 CHỨNG (mọi phép đo kèm CONTROL)

**A. Phân hoạch + Σ + "hiện đủ 8"** — chạy CHÍNH khối text đã ship (`indexOf('const khkkByGroup')` → slice → `new Function`; 4 phép thế khai minh bạch: 3 bỏ type-annotation + `import.meta.env.DEV`→cờ tiêm, mỗi phép thế đều assert khớp trước khi chạy — không khớp thì script tự chết chứ không âm thầm chạy tiếp). **15/15 OK:**
```
CA1 rải đều      8 ngăn · Σ 5=5 · nhóm-2 có 2 · nhóm-3 RỖNG VẪN HIỆN · 0 cảnh báo
                 route /khkk/list?group=1 … ?group=8 · nhãn "4. nhan-N4" đúng khuôn helper
CA2 phiếu lạc    9 ngăn (8 + "(chưa phân nhóm)") · gom đủ 4 ca lạc (null/9/undefined/thiếu field) · Σ 5=5
CA3 gói rỗng     VẪN 8 ngăn rỗng · KHÔNG đẻ ngăn lạc thừa
CA4 CONTROL-ÂM   tiêm lỗi nhân-đôi vào chính đoạn ⇒ console.warn NỔ:
                 "Σ ngăn (4) ≠ Σ folder (2) — có phiếu bị nhân đôi hoặc rơi mất khi chia nhóm."
```
CA4 là thứ chứng chuông Σ **có răng** — không có nó thì "0 cảnh báo" ở CA1-3 vô nghĩa (im lặng vì đúng ⟂ im lặng vì câm).

**B. Build ×2 PASS, 0 TS error** (`tsc -b` chạy riêng trước khi mirror cũng exit 0)
| app | modules | bundle js | css | time |
|---|---|---|---|---|
| fe-user | — | `index-CZUcTUUg.js` | `index-CHYZ6n3Q.css` | 19.81s |
| fe-admin | 1961 | `index-BZWvx0Y8.js` 1,725.90 kB (gzip 429.20) | `index-AgG72YRZ.css` 86.93 kB | 10.88s |
Warning >500KB + `INEFFECTIVE_DYNAMIC_IMPORT` = PRE-EXISTING.

**C. SHA-pair 4/4 file pipeline ×2 app**
| file | trước K5 | sau K5 | ×2 |
|---|---|---|---|
| `hooks/usePipelineStages.ts` | `7144eeb7…` | **`edff99314e8b75de39b0123bcdad5d98d8081a5bd294e2e05b1ef784094a19ac`** | IDENTICAL |
| `pipeline/PipelineStageFolders.tsx` | `d449cc8d…` | **`bed52f39d595c86a24afd27180146871d52e29943731ffb52f7caf23e66de936`** | IDENTICAL |
| `pipeline/PipelineTreePanel.tsx` | `d70ac315…` | `d70ac315…` KHÔNG ĐỔI | IDENTICAL |
| `pe/PePipelineStrip.tsx` | `6fb7337e…` | `6fb7337e…` KHÔNG ĐỔI | IDENTICAL |
🔴 2 file cuối **cố ý không đổi**: `PipelineTreePanel` chỉ gọi `buildStages(wg.items)` rồi đưa cho `PipelineStageFolders` — ngăn nhóm chảy qua nó mà không cần biết; `PePipelineStrip` là DẢI 4 giai đoạn, không phải cây. Hai hash bằng baseline = **đối chứng dương** cho phép đo (nếu phép so hash sai thì 2 dòng này cũng phải đổi). diff-stat đối xứng 165+/122+ ×2 app.

**D. Rào** — `git status`: đúng 4 file (2 cặp). 0 `src/Backend/**`, 0 `KhkkListPage`/`KhkkCreatePage`/`Layout.tsx` (K4b đã chốt), **0 commit**.

## §4 FLAG cho lead

**FLAG-1 (MAJOR — chặn hoàn thiện B3-A5, cần lead quyết + lane BE):** `lines[].contractId` **không tồn tại ở bất cứ đâu** (4 phép đo §1.3, cả FE lẫn BE). Union hôm nay chỉ có 1 nguồn ⇒ với phiếu liên danh đẻ N hợp đồng, **HĐ thứ 2+ vẫn vô hình trên cây** — tôi đã dựng đúng hình dạng để cắm, nhưng KHÔNG bịa field. Bằng chứng BE tự khai: `CreateContractFromEvaluationFeatures.cs:143-145` `pe.ContractId = contracts[0].Id` + "N HĐ liên-danh truy qua changelog". Hai đường lead chọn: **(a)** PE list DTO trả `contractIds: string[]` (rẻ — dữ liệu đã có, chỉ là không được ghi ngược) · **(b)** K7 `Line.ContractId` ghi-luôn (đắt hơn, nhưng đúng hạt "1 dòng phiếu → 1 HĐ" mà OG-1 UI-1-1 mô tả). Cắm vào builder = **1 dòng**, chỗ đã đánh dấu.

**FLAG-2 (MINOR — đã quyết, báo để khỏi tưởng sót):** ngăn nhóm mặc định **ĐÓNG**. 8 ngăn mở sẵn dưới mỗi gói thầu, nhân với N gói trên cây cột trái = không đọc được gì. Nếu owner muốn mở sẵn ngăn có phiếu thì 1 dòng (`defaultOpen = leaves.length > 0`) — không tự quyết vì đó là UX.

**FLAG-3 (MINOR — giới hạn kế thừa, không phải mới):** đếm ngăn vẫn nằm trong cửa sổ index 200 (`INDEX_PAGE_SIZE`) — gói có nhiều phiếu KHKK thì `truncated` đã cảnh báo sẵn ở chân folder cha, nhưng **cảnh báo đó không lặp lại ở từng ngăn**. Nếu thấy cần thì nhắc, tôi thêm.

IMPLFE-K5-END

