# spec — KHKK UI mirror Duyệt NCC (3 panel đồng-nhất)

> **Pipeline H21:** engine propose → **lead verify** → **lead ghi (file này)** → worker thực-thi.
> **Nguồn:** `/fable-real investigator-codebase` (Fable 5) + `/fable-clone reviewer` ×3 lăng-kính (Opus).
> **Trạng-thái review:** feasibility **8 HELD/6 BROKE** · regression **8 HELD/4 BROKE/10 bỏ-sót** · fidelity **~12 HELD/6 BROKE/3 bỏ-sót** *(2 mục cuối lane fidelity chết dở — verdict do lead derive từ §7 bảng của chính nó, KHÔNG phải lane tự kết; phép đo §6 còn thiếu thì lead đã tự chạy, kết-quả ở ②-D dưới)*.
> **Đọc kèm:** `sub-investigator-codebase-invest-s171.md` (bản-đồ đầy-đủ) · 3 file `sub-reviewer-lens-*.md` (phản-biện).

---

## ① TÍNH-NĂNG / ĐỀ-BÀI

Đưa màn **Kế hoạch Ký kết HĐ (KHKK — giai-đoạn 2)** về **cùng bố-cục 3 panel** với **Duyệt NCC (PE — giai-đoạn 1)**.

**Owner (verbatim):**
> "điều chỉnh lại giao diện chỗ KKHĐ lại nhé, lấy của Duyệt NCC làm mẫu: Panel 1: Hình gửi 1 · Panel 3: Duyệt và History tương tự (Hình 2) · Panel 2 - Giữa: Cấu trúc tương tự như duyệt NCC (Phần content nhập phía trên, duyệt ghi chú phía dưới) - Hình 3."
> "cách bố trí toàn diện cho tất cả, **giống nhất** với duyệt NCC, **chỉ khác đúng phần khác, tương tự thì giống nhau, đồng nhất**."

**Vì sao việc này chính-đáng (lý-do KHÔNG nằm trong ảnh):** GĐ1 (`PurchaseEvaluationsListPage.tsx:381,397`) và GĐ3 (`ContractsListPage.tsx:106,125`) đều đã là `h-[calc(100vh-4rem)]` + 3 cột. GĐ2 là `space-y-5` cuộn-body 2 panel (`KhkkListPage.tsx:161,197`) ⇒ **mắt-xích lệch duy nhất trong 4 giai-đoạn**. Đây là **đổi lớp kiến-trúc**, không phải sửa CSS.

### Phân-loại cuối (sau phản-biện) — 29 GIỐNG-được · **3** KHÁC-buộc-phải-khác · 2 ĐÃ GIỐNG · **2 CHỜ-ANH**

Bản invest báo 6 miễn-trừ. Sau khi áp đúng luật owner (*"chỉ khác đúng phần khác"* ⇒ mỗi dòng KHÁC là **một lần xin miễn-trừ**, gánh nặng chứng-minh thuộc người xin), **3 dòng không đứng vững**:

| Dòng | Bản invest phán | Chốt cuối | Vì sao |
|---|---|---|---|
| P1-4 nhóm N1-N8 | KHÁC-buộc | 🔄 **GIỐNG-được** | Khác **nguồn dữ-liệu**, KHÔNG khác **bố-trí** — chính nó tự viết *"CHỖ ĐẶT phải giống"*. PE để `Select` quy-trình ở khối filter panel 1; KHKK để `Select` nhóm **đúng ô đó**. Dán nhãn KHÁC làm phồng mẫu-số miễn-trừ |
| P2-7 inline-edit vs readOnly | KHÁC-buộc | 🔄 **GIỐNG-được** | `readOnly` là **prop do HOST truyền** (`PurchaseEvaluationsListPage.tsx:663` truyền `readOnly={true}`; `PeDetailTabs.tsx:427` có nhánh `mode`). Mệnh-đề *"muốn giống thì phải dựng workspace"* **sai** — host chỉ cần truyền `readOnly={phiếu ≠ Nháp \|\| user ≠ drafter}`. Đường sửa KHÔNG bị cắt |
| P3-12 bốn return-mode | KHÁC-buộc | 🔄 **CHỜ-ANH (hoãn)** | Tiền-đề *"KHKK là 3 trạm ngắn nên lùi-1-cấp ≈ về-đầu"* — **lead đo, SAI**: `DbInitializer.cs:554` roster = `(StepName, DeptCode, Level1[], Level2[], Level2Finalize)` ⇒ **3 Bước × tối đa 2 Cấp = tới 6 trạm**, và seed `:620` tự ghi *"đội hình **sao từ workflow Duyệt NCC prod**"* ⇒ hình dạng **y hệt PE**. Vế còn lại (*"đổi state machine BE / ngoài phạm-vi"*) là lý-do **chi-phí**, không mua được miễn-trừ ⇒ gọi đúng tên: **hoãn**, không phải buộc-khác |

**3 miễn-trừ THẬT còn lại** (đều có lý-do nghiệp-vụ, không dòng nào lấy *"code đang thế"* làm bằng-chứng):
- **P2-3** nội-dung Section: GĐ2 không có báo-giá/so-sánh NCC (đã chốt ở GĐ1), có căn-cứ hồ-sơ TVGS. Ép 5 Section trùng tên PE sẽ đẻ 2 Section rỗng. *"Giống nhau"* ở đây = giống **KHUNG** (đánh số · `divide-y` · ý-kiến dưới cùng), không phải giống chữ.
- **P3-7** badge `✎ NS PRO/CCM`: quyền nhập ngân-sách per-role là khái-niệm riêng PE (`PeWorkItemBudgets`, Mig 50). KHKK không có cột đó ⇒ badge vô nghĩa.
- **P3-13** bộ chọn giá chốt: KHKK chốt giá **per-Line** ở choke-point BE `ApplyApprovedValuesOnFinalize` — không có "1 giá" để người duyệt chọn (khác **cardinality**, không khác ý-thích).

### 🔴 Miễn-trừ THỨ 7 — KHÔNG ĐƯỢC KHAI (fidelity F-5), phải hỏi anh

§3 bản invest phán **P3-16** *"empty-state panel 3 (hộp dashed + `X` + 'Quy trình duyệt sẽ hiện khi chọn phiếu.') = **GIỐNG-được**, Ảnh 1 có"*. Nhưng §5 bố-cục đích lại ghi *"panel 3 chưa chọn → `PipelineTreePanel currentStage={2}`"*, và acceptance A-8e **đóng đinh bản thay-thế**. ⇒ Một dòng đã phán GIỐNG-được bị **đảo âm-thầm giữa §3 và §5**, đúng vào khối anh nhìn thấy ở Ảnh 1.
Theo luật owner, dạng này nguy nhất: **không phải khác — mà là khác mà không nói.**
✅ **ĐÃ ĐÓNG — owner chốt 2026-08-04 (QĐ-2):** panel 3 khi chưa chọn = **CHỈ hộp dashed y PE**, quay về đúng Ảnh 1. Xem bảng *3 QUYẾT-ĐỊNH OWNER* cuối file.

---

## ② CÁCH IMPLEMENT

### ②-A. Quyết-định kiến-trúc: **Phương-án A (tạo `components/khkk/`, chép khuôn)** — GIỮ, nhưng **THAY LÝ-DO**

Bản invest bác phương-án B (generic-hoá component PE dùng chung) bằng lập-luận: *"phải **hoặc** ép BE KHKK precompute `status`, **hoặc** nhồi adapter 2 chiều — **cả hai đều đụng đường duyệt PE đang chạy prod"*.

🔴 **Lập-luận này BROKE (feasibility §5.2) — nhị-phân giả.** Có đường (iii) nó không xét: KHKK **đã có sẵn** hàm suy status 15 dòng thuần-tuý không side-effect (`KhkkWorkflowPanel.tsx:106-120`); chỉ cần KHKK **tự chuẩn-hoá cây thô → shape có `status`** trước khi truyền vào component generic — adapter **MỘT chiều, nằm TRỌN trong nhánh KHKK, 0 dòng PE bị sửa**.

✅ **Nhưng QUYẾT-ĐỊNH A vẫn đứng, vì một lý-do khác mạnh hơn:** trích `StepOpinionsBox` ra khỏi `PeDetailTabs.tsx:705-774` **buộc phải sửa file trong `components/pe/`** — mâu-thuẫn trực-tiếp với acceptance **A-10** của chính bản đó (*"`git diff --name-only` → 0 path khớp `components/pe/`"*). Cộng ≥6 nhánh riêng của PE (NS PRO/CCM · price-picker · 4 return-mode · CCM-done · skip-to-final · DeptApprovals legacy) ⇒ generic sẽ đầy `if (module === …)`.

> 🔴 **Ghi vào đầu mỗi file `components/khkk/` (điều-kiện thoát, chống nợ im-lặng):**
> *"Khối ý-kiến cấp duyệt là bản thứ 3 của cùng một hình (PE `PeDetailTabs.tsx:705-774` · HĐ `components/contracts/WorkflowHistoryPanel.tsx`). Gộp được khi và chỉ khi BE 3 module thống-nhất shape `status` precompute."*

### ②-B. 🔴 Panel 1 = **`PipelineTreePanel`** — OWNER CHỐT 2026-08-04 (bác đề-xuất đảo của lead)

> **QUYẾT-ĐỊNH OWNER (ratified):** anh chọn **giữ đúng đề-xuất bản invest** — panel 1 = cây toàn-trình `PipelineTreePanel currentStage={2}`. Lead đã trình phương-án đảo (danh-sách riêng KHKK) **kèm đủ 4 rủi-ro đo được ngay trên thẻ chọn**; anh đọc và vẫn chọn cây. ⇒ **Đây là quyết-định có thông-tin đầy-đủ, thi-hành nguyên vẹn.**
>
> 🔴 **NHƯNG: chọn HÌNH ≠ chấp-nhận VỠ.** 4 rủi-ro dưới đây **không biến mất vì owner chọn cây** — chúng chuyển từ *"lý-do đổi thiết-kế"* thành ***việc kỹ-thuật BẮT BUỘC phải xử***. Mỗi cái có mitigation cưỡng-hành + acceptance riêng. **CẤM** thi-hành cây mà bỏ mitigation — làm thế là biến quyết-định của owner thành cái cớ cho một hồi-quy im-lặng.

#### 4 rủi-ro + mitigation CƯỠNG-HÀNH (đi kèm, không tách rời)

| Rủi-ro | Bằng-chứng | 🔧 Mitigation BẮT BUỘC |
|---|---|---|
| **M-1 🔴 CAO — `canPe` khoá cây** ⇒ user **chỉ có quyền KHKK** (VD người duyệt nhóm N5) thấy panel 1 **TRỐNG**, trong khi API `GET /contract-signing-plans` gate `KeHoachKyKet.Read` **vẫn cho họ đọc**. Display-layer khoá authz-layer đang mở — mặt còn lại gotcha #82 | `usePipelineStages.ts:55,57,83-84` (`canPe` = 3 key PE) · `PipelineTreePanel.tsx:117 enabled:canPe` · `:222-226` in *"Bạn chưa có quyền xem phiếu Duyệt NCC…"* | 🔴 **FALLBACK `!canPe` → render DANH-SÁCH KHKK phẳng** (card, cùng khuôn, không cây) trong đúng ô panel 1, nguồn `GET /contract-signing-plans`. Cây là **lớp làm giàu**, KHÔNG phải **điều-kiện để thấy phiếu**. ⇒ acceptance **C-5** (runtime, tài-khoản KHKK-only) |
| **R-11 trần 200 phiếu** ⇒ gói thầu rơi khỏi 200 mới nhất thì **mọi phiếu KHKK của gói đó biến mất**, và cây **không có "trang 2"** | `PipelineTreePanel.tsx:22 PE_INDEX_PAGE_SIZE=200` · `:112` · cảnh-báo `:252-256` | 🔴 Giữ **ô search + Select trạng-thái server-side** trong khối filter panel 1 → khi lọc, đi thẳng `GET /contract-signing-plans` (**không qua cây**) ⇒ luôn có đường tới phiếu cũ. **Và** dòng cảnh-báo `:252-256` **PHẢI hiện**, không được nuốt |
| **M-2 🔴 CAO — cây tự văng khỏi màn 3-panel**: bấm node KHKK ⇒ `navigate('/khkk/'+id)` rời trang vừa dựng | `usePipelineStages.ts:211` (×2 app) | 🔴 Đổi `onOpen` thành **callback do host truyền**: `onOpen: opts?.onSelectKhkk ?? (() => navigate(…))` — host `/khkk/list` truyền `id => setParam({id})`. Giữ nguyên hành-vi cũ cho **mọi call-site khác** (mặc-định navigate) ⇒ 0 hồi-quy trang PE/GĐ3 |
| **B1 🔴 `content` nuốt `groups` im lặng**: `PipelineStageFolders.tsx:285-287` đặt `content` **TRƯỚC** `groups` trong chuỗi tam-nguyên ⇒ truyền `stage2Content` thì **8 ngăn N1-N8 + ngăn "(chưa phân nhóm)" biến mất, không lỗi, không cảnh-báo**; `:95-97` quên `stage2Count` ⇒ badge về **0** | tự đo `grep -n -C4` | 🔴 **KHAI TRADE-OFF, không im:** trong cây của KHKK, folder GĐ2 hiện **card phiếu** thay **8 ngăn nhóm** — chấp nhận được **vì `Select` nhóm N1-N8 đã có ở khối filter panel 1** (P1-4). **BẮT BUỘC** truyền `stage2Count` cùng lúc. **Additive**: `PurchaseEvaluationsListPage` không truyền `stage2Content` ⇒ **trang PE giữ nguyên 8 ngăn** ⇒ acceptance **E-2** |

#### 🔸 Ghi lại cho người đọc sau (bối-cảnh quyết-định, KHÔNG phải để lật lại)

Lead từng đề-xuất đảo sang *"panel 1 = danh-sách riêng của KHKK"* với lập-luận: panel 1 của PE **không phải** `PipelineTreePanel` mà là **danh-sách của chính PE** tự gom cây `Năm > Dự án > Hạng mục` từ `yearGroups` (`PurchaseEvaluationsListPage.tsx:220-262`, nguồn = query `pe-list`), tầng 4 mới gọi `buildStages(wg.items, {stage1Content})` (`:524`). Owner **đã nghe và chọn cây** — nên hướng thi-hành là cây + 4 mitigation trên. Đoạn này giữ để người sau hiểu **vì sao có mitigation**, đừng đọc thành lời mời mở lại tranh-luận.

---

*(Phần dưới giữ nguyên phân-tích gốc để tra-cứu — 4 rủi-ro đã có mitigation ở bảng trên.)*

| Nếu panel 1 = `PipelineTreePanel` (bản invest) | Hậu-quả đo được |
|---|---|
| **M-1 🔴 `canPe` khoá cây** — `usePipelineStages.ts:55,57,83-84`: `canPe = ['PurchaseEvaluations','Pe_DuyetNcc_List','Pe_DuyetNccPhuongAn_List'].some(can)`; `PipelineTreePanel.tsx:117 enabled:canPe`, `:222-226` in *"Bạn chưa có quyền xem phiếu Duyệt NCC…"* | Người **chỉ có quyền KHKK** (VD người duyệt nhóm N5) ⇒ `canPe=false` ⇒ **mất TRẮNG danh-sách phiếu của chính mình**, trong khi API `GET /contract-signing-plans` gate `KeHoachKyKet.Read` **vẫn cho phép**. Đây là **display-layer khoá cái authz-layer đang mở** — mặt còn lại của gotcha #82. **Mất chức-năng, không phải mất thẩm-mỹ.** |
| **R-11 trần 200** — `PipelineTreePanel.tsx:22 PE_INDEX_PAGE_SIZE=200`, cây dựng từ **chỉ-mục PE** | Gói thầu rơi khỏi 200 ⇒ **mọi phiếu KHKK của gói đó biến mất khỏi panel 1**. Hôm nay vô hại vì panel 1 là **bảng có phân-trang + tìm-kiếm server-side** (`KhkkListPage.tsx:112-129`, `total` thật từ server). Đổi sang cây = **thay danh-sách-có-phân-trang bằng cây-cắt-cụt**; dòng cảnh-báo 10px xám **không có "trang 2"** |
| **M-2 🔴 cây tự văng khỏi màn** — `usePipelineStages.ts:211 onOpen: () => navigate('/khkk/'+k.id)` (×2 app) | Bấm node KHKK trong panel 1 ⇒ **rời màn 3-panel vừa dựng**, thay vì chọn vào panel 2 |
| **B1 🔴 `content` nuốt `groups`** — `PipelineStageFolders.tsx:285-287` chuỗi tam-nguyên đặt `content` **TRƯỚC** `groups`; `:95-97` `stageCount` khi có `content` đọc `count ?? 0` | Truyền `stage2Content` ⇒ **8 ngăn N1-N8 + ngăn "(chưa phân nhóm)" biến mất, không lỗi, không cảnh-báo**; quên `stage2Count` ⇒ badge về **0** trong khi folder đầy phiếu |

🔴 **Nhưng đọc kỹ mẫu PE thì hoá ra bản invest chép nhầm ĐỐI-TƯỢNG:** panel 1 của PE **KHÔNG phải** `PipelineTreePanel`. Nó là **danh-sách CỦA CHÍNH PE**, tự gom cây `Năm > Dự án > Hạng mục` từ `yearGroups` (`PurchaseEvaluationsListPage.tsx:220-262`, nguồn = query `pe-list`), rồi tầng 4 mới gọi `buildStages(wg.items, {stage1Content: <ul>…</ul>})` (`:524`) để re-parent phiếu vào folder GĐ1.

⇒ **Mirror TRUNG-THÀNH = KHKK panel 1 dựng cây từ DANH-SÁCH CỦA CHÍNH KHKK**, đổ card vào folder **GĐ2** — đúng đối-xứng với PE đổ vào GĐ1. Và làm đúng thì **M-1 · R-11 · M-2 tan hết**, vì nguồn dữ-liệu là `GET /contract-signing-plans` (đã gate `KeHoachKyKet.Read`, có phân-trang server, có `total` thật), **không đụng `canPe`, không đụng trần 200, không có `navigate` của hook**.

**Về B1 (`content` nuốt `groups`) — trade-off PHẢI KHAI, không được để im:**
- Thêm `stage2Content`/`stage2Count` vào `BuildStagesOptions` là **additive**: `PurchaseEvaluationsListPage` không truyền nên **giữ nguyên 8 ngăn** ⇒ **trang PE KHÔNG bị đụng**.
- Trong **cây của chính KHKK**, truyền `stage2Content` ⇒ folder GĐ2 hiện **card phiếu** thay cho **8 ngăn nhóm**. Chấp nhận được **vì bộ lọc nhóm N1-N8 đã có mặt dạng `Select` ở khối filter panel 1** (P1-4) — không mất đường tới nhóm nào.
- 🔴 **BẮT BUỘC:** truyền `stage2Count` cùng lúc (nếu không badge = 0), và **acceptance A-10 phải mở rộng path** (xem ③).

### ②-C. Bố-cục đích

```
/khkk/list  ← host 3-panel; khuôn ContractsListPage.tsx (GĐ3, 255 dòng) — KHÔNG chép
              PurchaseEvaluationsListPage.tsx (807 dòng, mang theo deleted/pendingMe/awId/overlay)
              🔸 CẢNH-BÁO: ContractsListPage.tsx CHỈ TỒN-TẠI Ở fe-admin (regression M-6).
                 Port sang fe-user = VIẾT MỚI, không phải copy.

├─ Panel 1  400px   components/khkk/KhkkListPanel.tsx      ← OWNER CHỐT: cây PipelineTreePanel
│     khối filter: search icon-trong-ô · Select trạng-thái · Select nhóm N1-N8
│                  (search/trạng-thái đi SERVER-SIDE, KHÔNG qua cây — mitigation R-11)
│     cây: PipelineTreePanel currentStage={2}
│          📅 Năm > 📁 Dự án > 🧱 Hạng mục > folder GĐ2 = card phiếu KHKK
│          onOpen = callback host (KHÔNG navigate — mitigation M-2)
│          stage2Content + stage2Count truyền CẶP (mitigation B1)
│     🔴 FALLBACK !canPe → danh-sách KHKK phẳng cùng ô (mitigation M-1)
│     🔴 GIỮ dòng cảnh-báo "Cây tải 200 phiếu mới nhất…" (PipelineTreePanel.tsx:252-256)
│     card 3 hàng: [chip ⚑ Kết thúc·Phòng N] + tên gói thầu + badge trạng-thái
│                  / mã font-mono + timestamp / 👤 người soạn + Σ tiền
│
├─ Panel 2  1fr     components/khkk/KhkkDetailContent.tsx
│     chưa chọn → EmptyState "Chọn phiếu ở danh sách"   🔴 F-1, xem ②-E
│     <PePipelineStrip current={2} />
│     <div className="divide-y divide-slate-200">
│       Section 1. Thông tin kế hoạch
│       Section 2. Dòng giá per-NCC trúng thầu (N NCC)
│       Section 3. Căn cứ hồ sơ (b.8-9)
│       Section 4. File đính kèm
│       Section 5. Ý KIẾN CẤP DUYỆT (sign-off theo workflow)   ← Ảnh 3 khoanh đỏ
│     readOnly = prop TỪ HOST (P2-7 đã lật): true khi phiếu ≠ Nháp HOẶC user ≠ drafter
│
└─ Panel 3  360px   components/khkk/KhkkWorkflowPanel.tsx   (dời từ pages/)
      chưa chọn → ✅ OWNER CHỐT: **CHỈ hộp dashed y PE** (khuôn PurchaseEvaluationsListPage.tsx:669-676
                  — border-dashed + icon X + "Quy trình duyệt sẽ hiện khi chọn phiếu.")
                  🔴 KHÔNG nhét PipelineTreePanel vào đây (khác GĐ3 — CÓ CHỦ ĐÍCH, vì cây
                     đã sống ở panel 1 theo QĐ owner câu 1 ⇒ nhét lần 2 = trùng)
                  ⇒ miễn-trừ-thứ-7 mà bản invest không khai nay đã ĐÓNG: quay về đúng Ảnh 1
      đã chọn  → 1 "Quy trình duyệt" + meta {code} v{NN} · {name}
                 2 Banner kết-thúc-sớm "… không qua CEO"
                 3 Sơ-đồ Bước → Cấp → NV  (+chip phòng · +⚑ Duyệt thay CEO
                                            · +mờ sau finalize · +liệt-kê NV 🔴 F-3)
                 4 Nút Duyệt / Trả lại / Từ chối + Dialog + nút Xóa
                 5 📎 File đính kèm khi duyệt        (🔴 F-6 — xem ②-E)
                 6 Lịch sử duyệt (N)                 (🔴 B4 — xem ②-E)
                 7 Lịch sử thay đổi
```

`/khkk/:id` **GIỮ NGUYÊN route** (deep-link · bookmark · fallback `<lg`), chỉ đổi ruột thành `KhkkDetailContent` + `KhkkWorkflowPanel` xếp dọc — khuôn `PurchaseEvaluationDetailPage` (`:798-805`).

### ②-D. Việc BE (nhỏ hơn tưởng, nhưng KHÔNG phải "+3 field")

🟢 **Tin tốt đã được verify độc-lập:** dữ-liệu lịch-sử **ĐÃ ĐƯỢC GHI ĐẦY ĐỦ TỪ ĐẦU**. Lane feasibility vào để lật claim này và **không lật được**: `LogSigningPlanTransitionAsync` có **6 call-site phủ TRỌN** mọi transition — `:204` Pending (gửi duyệt) · `:283` `:296` `:311` `:325` Approve · **`:393` Trả lại VÀ Từ chối** (`ApprovalDecision.Reject`). `db.ContractSigningPlanApprovals.Add(...)` ở `:534` nằm **thẳng trong thân hàm, không `if` nào bọc**.
⇒ **Entry "Trả lại → Lùi về Cấp 2" của Ảnh 2 SẼ CÓ dữ-liệu.** Đừng cắt ngân-sách mục này vì nghi ngờ.

🔴 **Đường-dẫn đúng** (bản invest ghi thiếu lớp, người sau sẽ mở trượt):
`src/Backend/SolutionErp.Infrastructure/Services/ContractSigningPlanWorkflowService.cs` — **KHÔNG** phải `Application/ContractSigningPlans/`.

| # | Việc | Ghi-chú bắt buộc |
|---|---|---|
| BE-1 | `ContractSigningPlanDetailDto` (`ContractSigningPlanFeatures.cs:145-181`, record positional 37 tham-số) **+**: `WorkflowVersion` · `EndedByLevelFinalize` · `FinalizeStepName` · `FinalizeLevelName` · `List<ContractSigningPlanApprovalDto> Approvals` | 🟢 Chỉ **1 call-site** `new ContractSigningPlanDetailDto` (`:784`) ⇒ append RẺ |
| BE-1b | 🔴 **`FinalizeStepName`/`FinalizeLevelName` KHÔNG phải "field append"** — `ContractSigningPlan.cs:51` chỉ có `bool EndedByLevelFinalize`, **không có cột nào lưu Bước/Cấp nào đã kết-thúc** ⇒ phải **DẪN XUẤT**: port ~20 dòng projection có **3 subquery** + nhánh 3-ngả từ `PurchaseEvaluationFeatures.cs:665-683` | Bản invest ước "+3 field" = **sai chiều RẺ**. Nó tự cảnh-báo *"dễ ước-lượng sai đắt nhất theo cả hai chiều"* rồi dính chiều còn lại |
| BE-2 | 🔴 **`ContractSigningPlanApprovalDto` CHƯA TỒN-TẠI** (`grep` → **0 hit**) ⇒ đẻ record mới + projection + mirror `types/khkk.ts` ×2 app | A-1 viết như thể chỉ "thêm field" |
| BE-3 | `ApprovedByUserId` **không kèm tên** (`:534-544` 8 field, không có tên) trong khi hàng changelog ngay dưới (`:546-557`) **CÓ** `UserName` denorm | 🔸 Rẻ hơn join Users: 2 bảng ghi **cặp 1-1 cùng unit-of-work cùng `now`** ⇒ lấy tên từ changelog cùng transition, **hoặc** denorm thêm cột tên vào Approval cho khớp anh-em |
| BE-4 | `ContractSigningPlanWorkflowStepDto` **+`DepartmentName`** (join `Departments`) | Nuôi **2 chỗ**: chip phòng panel 3 (P3-4) **và** header nhóm Section 5 (P2-5) |
| BE-5 | `GET /contract-signing-plans/{id}/changelogs` | 🔴 **`[Authorize(Policy = "KeHoachKyKet.Read")]`** — gotcha #85 (đúng key endpoint, KHÔNG OR key con) |
| BE-6 | `.Include`/join `ApprovalWorkflowLevels` cho `Approvals` nếu muốn hiện "Cấp nào" (Ảnh 2 có *"Lùi về **Cấp 2**"*) — `ApprovalWorkflowLevelId` là Guid trần (`:541`) | |
| **BE-7** ✅ | **OWNER CHỐT — file đính kèm khi duyệt, LÀM THẬT:** `ContractSigningPlanAttachment.cs:9-12` **+`ApprovalAttachment = 3`** (append-only, **KHÔNG** đánh số lại `Other=99`) + **endpoint upload** gắn vào luồng dialog duyệt + lưu `purpose=ApprovalAttachment` | 🔴 Endpoint mới ⇒ **`[Authorize(Policy = "KeHoachKyKet.Update")]`** (ghi, không phải Read) — gotcha #85 dùng ĐÚNG key của endpoint, **KHÔNG** OR key con. 🔴 **KHÔNG** chép khuôn authz của `PurchaseEvaluationsController` (xem cảnh-báo BE-5) |

> 🔴 **BE-5 — CẢNH-BÁO TẠI CHỖ, không chỉ ở checklist (regression M-5):** mẫu PE mà lệnh *"mirror PE"* trỏ tới **CHÍNH LÀ một lỗ #82**:
> `PurchaseEvaluationsController.cs:15 [Authorize]` **trần (any-auth)** + `:281 [HttpGet("{id:guid}/changelogs")]` **KHÔNG có** `[Authorize(Policy=…)]` ⇒ endpoint changelogs của PE hiện chỉ cần **đăng-nhập bất-kỳ** là đọc được lịch-sử phiếu.
> **Implementer trung-thành sẽ chép nguyên — đó là cách #82 lan sang KHKK. ĐỪNG CHÉP.** (Vào sổ riêng: PE changelogs = ứng-viên #82 thứ hai bên cạnh `POST /api/contracts` đã treo.)

### ②-E. 5 khối bị bỏ sót / bẫy hiển-thị — PHẢI làm, không được im

| # | Việc | Vì sao |
|---|---|---|
| **F-1** 🔴 | **EmptyState panel 2 "Chọn phiếu ở danh sách"** + 2 nhánh `Đang tải…` / `Không tìm thấy phiếu.` — mẫu `PurchaseEvaluationsListPage.tsx:644-656` | Bản invest **ghi sự-thật này ở §1.2 rồi đánh rơi**: 0 dòng §3, 0 chỗ §5, 0 acceptance §7. Host 3-panel mà panel 2 trống trơn = **màn hình trắng giữa**, sai Ảnh 1 ở đúng trạng-thái **ĐẦU TIÊN** người dùng thấy |
| **F-2** 🔴 | **Chip `⚑ Kết thúc · Phòng N` trên card panel 1** (`PeFinalizeChip`) | Anh **nhìn thấy nó trong Ảnh 3**; P1-5 liệt-kê card thiếu đúng con chip này. Và nó **0 công BE** — cột `EndedByLevelFinalize` đã có (Mig 71). Bỏ sót khối vừa-thấy-trong-ảnh vừa-rẻ-nhất |
| **F-3** 🔴 | **Liệt-kê NV trong mỗi Cấp**: người duyệt thật **in đậm**, NV cùng cấp còn lại `/ Tên` mờ — mẫu `PeWorkflowPanel.tsx:453-465` | Ảnh 2 mục 3 có. §3 chỉ có dòng cho *badge vai*, **không có dòng cho bản-thân danh-sách NV** ⇒ panel 3 sẽ **không giống Ảnh 2** dù A-8a/b/c/d PASS hết |
| **F-6** ✅ | **📎 File đính kèm khi duyệt — OWNER CHỐT: LÀM THẬT, cho upload.** ⇒ **BE-7** (dưới): `ContractSigningPlanAttachment.cs:9-12` **+`ApprovalAttachment = 3`** (enum append-only, đúng comment nhà *"Append-only khi cần thêm mục đích mới"*) + **endpoint upload trong dialog duyệt** + FE khối đọc `purpose=ApprovalAttachment`, tự-ẩn khi rỗng (khuôn `PeWorkflowPanel.tsx:928-967`) | Chọn đường này ⇒ khối **SỐNG THẬT** trên UAT, và bẫy *"UI chết mà acceptance vẫn xanh"* **tự tan** — vì nay có đường ghi. Acceptance **D-9** đổi sang runtime: upload 1 file lúc duyệt ⇒ khối phải HIỆN |
| **B4** 🔴 | **`Decision = Pending` của hành-vi GỬI duyệt (`:204-207`) nằm THẲNG trong `Approvals`** ⇒ "Lịch sử duyệt (N)" của KHKK sẽ có **nhiều dòng hơn** số lần thật sự bấm Duyệt/Trả/Từ-chối. PE chống bằng merge + dedupe 5s-bucket + synthetic-reject (`PeDetailTabs.tsx:3152-3192`) | Không xử ⇒ số `(N)` của KHKK **không cùng nghĩa** với số `(N)` của PE — trong khi cả đề-bài là *"cho đồng nhất"*. **Phải quyết: lọc `Pending` (giống PE) hay hiện đủ (nhật-ký).** Khuyến-nghị: **lọc**, cho khớp mẫu |
| **B3** | Changelog ghi **10 chỗ** (bản invest kể 8). Chỗ bị bỏ đáng kể: **`CreateContractFromSigningPlanFeatures.cs:226`** = bắc-cầu **KHKK→HĐ (K7)** | ⇒ "Lịch sử thay đổi" sẽ hiện **entry tạo Hợp đồng** — loại dòng FE **chưa có nhãn**. Thiếu ⇒ hiện chuỗi thô hoặc rơi im lặng |

### ②-F. 4 bẫy hồi-quy — làm sai là vỡ cái đang chạy

| # | Bẫy | Luật |
|---|---|---|
| **M-4** 🔴 | **A-6 của bản invest kiểm SAI KHOÁ.** Cơ-chế thật: `Layout.tsx:350 TRANSIENT_QUERY_KEYS = {id,q,editHeader,page,phase,awId}`; `:369 ignorable = transient && !pinnedByTarget`. KHKK đọc URL đúng **3 khoá**: `filter`(`:74`) · `view`(`:76`) · `group`(`:79`) — **`phase` KHÔNG hề nằm trên URL** | Ghi `filter`/`view`/`group` vào URL ⇒ **hồi-sinh bug S155** (đứng leaf `?group=1` bấm chip "Đã duyệt" → URL khớp CHÍNH XÁC leaf "Đã duyệt" ⇒ sidebar nhảy). Ghi `phase` ⇒ **vô hại** (transient, 0 leaf ghim). ⇒ Acceptance phải cấm **`filter`/`view`/`group`**, KHÔNG phải `phase` — bản cũ **chặn cái vô hại, cho lọt cái nguy hiểm** |
| **M-2** 🔴 | `usePipelineStages.ts:211 onOpen: () => navigate('/khkk/'+k.id)` (×2 app) | Nếu còn dùng hook cho panel 1 ⇒ đổi thành **callback do host truyền** (`onSelect(id)` → `?id=`), nếu không cây tự văng khỏi màn 3-panel |
| **M-8** | `budgetFrozen` nằm ở **DetailDto**, **KHÔNG có** ở `KhkkListItemDto` (`types/khkk.ts:296-301` — *"CỜ HIỂN THỊ dẫn xuất từ phase, KHÔNG phải cột DB"*) | Card panel 1 in **một** số Σ tiền ⇒ phải **suy từ `phase === DaDuyet`** (đúng như BE suy), **đừng** thêm field vào ListItemDto. Bảng cũ né được vì in **cả hai cột** (vá-8 K2 `:246-251`); chọn sai = tái-hiện đúng lỗi đã sửa |
| **M-7** | Bảng 8 cột (`:241-252`: Mã · Gói thầu · **Dự án** · Trạng thái · **Tổng đề xuất** · **Tổng duyệt** · **Người soạn** · **Ngày tạo**) + 6 `KpiCard` + phân-trang ép vào card 400px | Card 3 hàng chứa **~4-5 mẩu tin** ⇒ **PHẢI có bảng "cột nào chết, chuyển đi đâu"** trong PR. Không có bảng đó = mất-chức-năng-im-lặng |
| **M-10** | `KhkkDetailPage.tsx:258` xóa xong `navigate('/khkk/list')` **rụng `?group=`** | Ném người dùng về "tất cả nhóm". Giữ `?group=` khi quay lại |
| **R-5** | `:97-101` là **set-state-trong-render CÓ CHỦ ĐÍCH** (khuôn React "reset state khi input đổi"), **không phải** code lỗi | Ai refactor cho "sạch" thành `useEffect` sẽ **tái-hiện vá-5** (phải F5 mới đúng). ĐỪNG ĐỘNG |
| **R-8** | KHKK **không có phiếu V1-legacy** (module sinh 2026-07-29) | **KHÔNG** port nhánh fallback V1 của PE (`PeDetailTabs.tsx:434-436`, `PeWorkflowPanel.tsx:481-485`) — code chết ngay ngày đầu |

---

## ③ CHECKLIST (vai · deliverable · acceptance ĐO ĐƯỢC)

**Vai thực-thi:** `implementer-backend` (nhóm 1) → `implementer-frontend` (nhóm 2-3) → `reviewer` (gate) → `cicd-monitor` (deploy).
`APP` ∈ {`fe-admin`, `fe-user`} — **mọi mục FE phải PASS CẢ HAI**.

> 🔴 **4 acceptance của bản invest đã bị bẻ gãy khi CHẠY THẬT — dùng bản sửa dưới đây, KHÔNG dùng bản gốc.** Bài học: *chưa ai chạy thử chính các lệnh acceptance đó*; hai cái được viết riêng để canh hai rủi-ro nguy nhất thì **một cái kiểm nhầm khoá, một cái báo đỏ trên file sạch**.

### Nhóm 0 — trước khi viết dòng code đầu tiên

| # | Việc | Acceptance |
|---|---|---|
| ~~A-0~~ | 🟢 **GỠ CHẶN — feasibility đã trả lời dứt.** `usePipelineLinkage()` **đã có sẵn** query `khkkIndex` + `khkkByPeId: Map<peId, KhkkListItemDto[]>` (`usePipelineStages.ts:123-134,147`); `buildStages` gom bằng `:204`; `toKhkkLeaf` `:205-212`; `stage2` `:265-273` | **Không cần query mới, không cần endpoint mới cho panel 1.** 🔸 Nếu ai vẫn đọc theo A-0 gốc: khoảng đúng là **`:100-320`**, không phải `:150-320` — nguồn thật ở `:123-134` nằm **NGOÀI** khoảng cũ |

### Nhóm 1 — BE

| # | Acceptance |
|---|---|
| **B-1** | `grep -n "record ContractSigningPlanDetailDto" -A 80 src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs` → thấy đủ **5** tên mới |
| **B-2** | `grep -c "ContractSigningPlanApprovalDto" src/Backend` **≥ 2** (định-nghĩa + call-site) — hôm nay là **0** |
| **B-3** | 🔴 **RUNTIME, không phải đọc code:** gọi thật 1 phiếu `DaDuyet` → `approvals[]` có **≥1 phần tử `Decision=Reject`** (chứng chuỗi Trả lại được ghi) **và** `approvedByFullName ≠ null` ∀ phần tử |
| **B-4** | `grep -c "DepartmentName" …WorkflowStepDto` ≥ 1 |
| **B-5** | `grep -n "changelogs" src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs` → 1 hit, **và** dòng `[Authorize(Policy = "KeHoachKyKet.Read")]` đứng ngay trên |
| **B-6** 🔴 | **A-13 THAY BẢN GỐC** (bản gốc **vỡ cả hai đầu**: `:15` là **comment** chứa "Authorize"+"Policy" nên bơm tử-số; và `:17` `:236` `:241` là **comment nhắc tên** `[Authorize]` trần nên vế "0 dòng trần" **FAIL trên file 100% sạch** — citation-trap). Bản sửa **neo đầu dòng, loại comment**: <br>`grep -cE '^\s*\[Http(Get\|Post\|Put\|Delete\|Patch)' <file>` = **R** <br>`grep -cE '^\s*\[Authorize\(Policy' <file>` phải = **1 + R** (hiện 19 = 1+18) <br>`grep -cE '^\s*\[Authorize\]\s*$' <file>` phải = **0** <br>🧪 **Chứng thước có RĂNG:** cắm tạm 1 action `[Authorize]` trần vào **cây tạm** ⇒ số thứ 3 phải nhảy lên 1. Không nhảy = thước mù |

### Nhóm 2 — FE cấu-trúc

| # | Acceptance |
|---|---|
| **C-1** | `ls $APP/src/components/khkk/` → **≥3** file; `grep -rn "pages/khkk/KhkkWorkflowPanel" $APP/src` → **0 hit** |
| **C-2** 🔴 | **A-4 THAY BẢN GỐC** (gốc chấp **mọi con số** kể cả `[200px_1fr_200px]` ⇒ đo "có 3 panel", không đo "giống PE"; và **FAIL nếu ai dùng `rem`** như KHKK đang dùng `19rem/21rem`). Bản sửa **ghim literal**: `grep -c 'lg:grid-cols-\[400px_1fr_360px\]' $APP/src/pages/khkk/KhkkListPage.tsx` = **1**, **và** `grep -c 'h-\[calc(100vh-4rem)\]'` = **1** <br>🔸 *Giải mâu-thuẫn F-7: §5 bản invest ghi `400px` (số PE) nhưng Điều-kiện-1 bảo chép GĐ3 = `340px`. Hiến-pháp owner là **"giống nhất với duyệt NCC"** ⇒ **chốt `400px`** (số PE), khuôn code lấy GĐ3* |
| **C-3** | `grep -c "matchMedia('(min-width: 1024px)')" $APP/src/pages/khkk/KhkkListPage.tsx` ≥1 · `grep -c 'path="/khkk/:id"' $APP/src/App.tsx` = **1** (route CÒN SỐNG) |
| **C-4** 🔴 | **A-6 THAY BẢN GỐC** (gốc cấm `'phase'`/`'group'` — `phase` **không phải khoá URL của KHKK** và **vô hại**; `filter`/`view` **mới là 2 khoá làm vỡ** thì gốc **không nhắc chữ nào** ⇒ `setSearchParams({group, filter:'DaDuyet'})` = **đúng bug S155** vẫn PASS). Bản sửa: **0 hit** `'filter'` / `'view'` / `'group'` bên trong **mọi** lời gọi `setSearchParams`/`setSp`; chỉ `'id'`/`'q'`/`'page'` được phép (∈ `TRANSIENT_QUERY_KEYS`, `Layout.tsx:350`) <br>🧪 **Negative-control:** cắm tạm `setSearchParams({filter:'DaDuyet'})` ⇒ acceptance PHẢI TRƯỢT |
| **C-5** 🔴 | **Mitigation M-1 — acceptance QUAN-TRỌNG NHẤT của cả spec.** Owner chốt panel 1 = cây ⇒ `canPe` **sẽ** khoá fetch cho user KHKK-only ⇒ **BẮT BUỘC có fallback**. <br>🔴 **RUNTIME:** đăng-nhập tài-khoản **chỉ có `KeHoachKyKet.*`** (KHÔNG có `PurchaseEvaluations`) ⇒ panel 1 **vẫn liệt ĐỦ phiếu** (dạng danh-sách phẳng, không cây). <br>🧪 **Negative-control bắt buộc:** gỡ tạm fallback ⇒ acceptance PHẢI TRƯỢT (panel 1 trống). Không trượt = thước mù, vì `canPe` của tài-khoản test có thể đang `true` do thừa quyền |
| **C-6** 🔴 | **Mitigation B1** — truyền `stage2Content` thì **BẮT BUỘC** kèm `stage2Count` (`PipelineStageFolders.tsx:95-97` — thiếu ⇒ badge **0** trong khi folder đầy phiếu). Và **khai trade-off trong PR**: folder GĐ2 của cây KHKK hiện **card phiếu** thay **8 ngăn N1-N8** (lối vào nhóm giữ ở `Select` panel 1) |
| **C-7** 🔴 | **Mitigation M-2** — `grep -c "navigate(\`/khkk/" $APP/src/hooks/usePipelineStages.ts` phải cho thấy `onOpen` nhận **callback tuỳ chọn từ host**, mặc-định giữ `navigate` cũ. <br>🧪 **Chứng 0 hồi-quy:** mở **trang PE** và **trang GĐ3**, bấm node KHKK trên cây ⇒ **vẫn navigate như cũ** (chỉ host `/khkk/list` mới chặn lại thành `?id=`) |
| **C-8** 🔴 | **Mitigation R-11** — ô search + Select trạng-thái panel 1 đi **server-side** thẳng `GET /contract-signing-plans`, **KHÔNG lọc trên cây**: `grep -c "PE_INDEX_PAGE_SIZE\|200" …/KhkkListPanel.tsx` không được là đường duy nhất tới phiếu. **Và** dòng cảnh-báo `PipelineTreePanel.tsx:252-256` *"Cây tải 200 phiếu mới nhất…"* **PHẢI còn hiện** (`grep -c "200 phiếu mới nhất"` ≥1) — nuốt cảnh-báo = giấu giới-hạn |

### Nhóm 3 — FE nội-dung (từng khối của ảnh)

| # | Acceptance |
|---|---|
| **D-1** | `grep -c "PePipelineStrip" …/KhkkDetailContent.tsx` = 1 **kèm** `current={2}` |
| **D-2** 🔴 | **A-7b THAY BẢN GỐC** (gốc đòi **5 hit** `title="N. …"` nhưng chạy trên **chính mẫu PE** chỉ ra **3** — vì Section 2 dùng template-literal `` title={`2. …(${n} hạng mục)`} `` và Section 4 là `<section>` trần ⇒ implementer viết ĐÚNG sẽ **FAIL**, và cách duy nhất để PASS là **bỏ số đếm động cho vừa regex** = Goodhart thuần). Bản sửa **chấp cả literal lẫn template**: `grep -c '<Section title=' file` = **5** **và** `grep -oE 'title=[{"]`?[1-5]\.' file \| wc -l` = **5** |
| **D-3** 🔴 | **A-7d THAY BẢN GỐC** (gốc `grep -c "Ý kiến cấp duyệt" …WorkflowPanel.tsx` = 0 ⇒ **đổi tiêu-đề thành "Ý kiến duyệt" là PASS** trong khi khối vẫn nằm nguyên panel 3 — đo **chuỗi chữ**, mục-tiêu là **vị-trí khối**). Bản sửa đo **luồng dữ-liệu**: `grep -c "opinions" …/KhkkWorkflowPanel.tsx` = **0** **và** `grep -c "levelOpinions\|opinions" …/KhkkDetailContent.tsx` ≥ **1** |
| **D-4** | Section 5 nhóm BƯỚC→PHÒNG, **lưới 2 cột**, meta `x/y cấp · N NV`: `grep -c "md:grid-cols-2"` ≥1 · `grep -c "cấp đã duyệt"` ≥1 · `grep -c "NV tham gia"` ≥1 |
| **D-5** 🔴 | **F-1** — `grep -c "Chọn phiếu ở danh sách" $APP/src/pages/khkk/KhkkListPage.tsx` ≥ **1** (+ 2 nhánh `Đang tải…` / `Không tìm thấy phiếu.`) |
| **D-6** 🔴 | **F-2** — `grep -c "PeFinalizeChip\|Kết thúc" …/KhkkListPanel.tsx` ≥ **1** |
| **D-7** 🔴 | **F-3** — panel 3 liệt-kê NV mỗi Cấp, người duyệt **in đậm** + NV khác `/ Tên` mờ: `grep -c "font-semibold" …/KhkkWorkflowPanel.tsx` ≥1 **kèm** đối-chiếu mắt với Ảnh 2 |
| **D-8** | `grep -c "không qua CEO"` ≥1 · `grep -c "departmentName"` ≥1 · `grep -c "Duyệt thay CEO"` ≥1 · `grep -c "opacity-50"` ≥1 |
| **D-9** ✅🔴 | **A-8c THAY BẢN GỐC** (gốc grep-trên-source ⇒ khối UI CHẾT vẫn PASS 100%). Owner đã chốt **làm thật** ⇒ bẫy đó tan, nhưng thước vẫn phải **RUNTIME**: duyệt 1 phiếu **có upload kèm 1 file** ⇒ khối `📎 File đính kèm khi duyệt` **HIỆN trên màn** với đúng file đó (tên · size · nút xem · nút tải). <br>🧪 Và kiểm **vế tự-ẩn**: phiếu duyệt **không** kèm file ⇒ khối **KHÔNG hiện** (khuôn PE `:928-967`) |
| **D-10** | `grep -c "Lịch sử duyệt"` ≥1 · `grep -c "Lịch sử thay đổi"` ≥1 **và** `grep -c "/changelogs"` ≥1 (khối vô nghĩa nếu không fetch) |
| **D-11** 🔴 | **B4** — `Approvals` lọc bỏ `Decision=Pending` (hoặc khai rõ quyết-định ngược lại): số `(N)` của KHKK phải **cùng nghĩa** với số `(N)` của PE |

### Nhóm 4 — chống hồi-quy

| # | Acceptance |
|---|---|
| **E-1** | Mirror 2 app: `sha256sum fe-admin/src/<p>` = `sha256sum fe-user/src/<p>` ∀ file mới/sửa → **PASS 100%**, không trừ file nào |
| **E-2** 🔴 | **A-10 THAY BẢN GỐC** (gốc liệt 4 path `components/pe/` `pages/pe/` `components/contracts/` `pages/contracts/` — **thiếu đúng 2 path nguy hiểm**: sửa `hooks/usePipelineStages.ts` hoặc `components/pipeline/PipelineStageFolders.tsx` có thể làm **cây trang PE mất 8 ngăn GĐ2** mà A-10 **vẫn PASS 100%**). Bản sửa: giữ 4 path cũ = **0 hit**; **thêm** `hooks/usePipelineStages.ts` + `components/pipeline/` với nghĩa **"chạm ⇒ BẮT BUỘC mở trang PE và trang HĐ đếm lại folder GĐ2 bằng mắt"** (không cấm chạm, nhưng cấm chạm-mà-không-kiểm) |
| **E-3** | `npm --prefix fe-admin run build` **và** `npm --prefix fe-user run build` → exit 0. 🔴 Chỉ tin build chạy **SAU** khi mọi agent nền dừng (gotcha #68) |
| **E-4** | `dotnet test SolutionErp.slnx` → 0 failed |
| **E-5** 🔴 | **UAT trên `fe-user:8080`** (R-1: `Layout.tsx:227-231` `ADMIN_HIDDEN_KEYS` chứa `KeHoachKyKet` ⇒ **menu KHKK không tồn tại bên admin**). Chụp 3 màn khớp Ảnh 1/2/3, **cửa-sổ ẩn-danh** (bài S96 #77: cache ≠ deploy) |
| **E-6** | **fe-admin phải UAT bằng DEEP-LINK** `http://localhost:8082/khkk/list?group=1` — menu ẩn nên không vào được bằng sidebar. Thiếu câu này ⇒ sẽ có báo-cáo **"không thấy menu ⇒ chưa deploy"** giả |
| **E-7** | PR có **bảng "cột nào chết, chuyển đi đâu"** (M-7): 8 cột bảng cũ + 6 KpiCard → card 400px |

---

## ✅ 3 QUYẾT-ĐỊNH OWNER — chốt 2026-08-04, THI-HÀNH NGUYÊN VẸN

| # | Câu hỏi | Owner chốt | Hệ-quả lên spec |
|---|---|---|---|
| **1** | Panel 1 lấy nguồn nào? | 🔴 **`PipelineTreePanel`** (giữ đề-xuất bản invest) — **bác** phương-án đảo của lead | ②-B viết lại: cây + **4 mitigation CƯỠNG-HÀNH** (fallback `!canPe` · search server-side · `onOpen` callback · `stage2Count` cặp) + acceptance **C-5 · C-6 · C-7 · C-8**. 🔴 Lead đã trình đủ 4 rủi-ro **ngay trên thẻ chọn**; owner đọc và vẫn chọn ⇒ **quyết-định có thông-tin đầy-đủ**. Nhưng *chọn HÌNH ≠ chấp-nhận VỠ* — mitigation là **bắt buộc**, không phải tuỳ chọn |
| **2** | Panel 3 khi chưa chọn hiện gì? | ✅ **CHỈ hộp dashed y PE** (giống Ảnh 1 nhất) | Đóng **miễn-trừ-thứ-7** mà bản invest không khai: §5 gốc âm-thầm thay bằng `PipelineTreePanel`, nay quay về đúng Ảnh 1. **Không** nhét cây vào panel 3 (cây đã sống ở panel 1 theo QĐ-1 ⇒ nhét lần 2 là trùng). Khác GĐ3 ở điểm này = **CÓ CHỦ ĐÍCH**, ghi rõ trong PR |
| **3** | File đính kèm khi duyệt — hiện hay upload? | ✅ **Cho upload THẬT** (thêm enum + endpoint) | **BE-7** mới: `+ApprovalAttachment = 3` (append-only) + endpoint upload trong dialog duyệt, policy **`KeHoachKyKet.Update`**. Bẫy *"UI chết mà acceptance vẫn xanh"* (F-6) **tự tan** vì nay có đường ghi; acceptance **D-9** đổi sang runtime 2 chiều (có file ⇒ hiện · không file ⇒ tự ẩn) |

### 🔸 Còn treo (KHÔNG chặn thi-hành, hỏi khi tới)

**P3-12 — 4 return-mode (Cấp / Bước / NV / Drafter).** Bản invest xếp "buộc phải khác" với tiền-đề *"KHKK là 3 trạm ngắn"*; lead đo `DbInitializer.cs:554` ⇒ **3 Bước × ≤2 Cấp = tới 6 trạm**, seed `:620` tự ghi *"sao từ workflow Duyệt NCC prod"* ⇒ **tiền-đề sai, hình dạng y hệt PE**. ⇒ Nó là **hoãn**, không phải buộc-khác. Đợt này **giữ 1 mode** (về Bước 1 · Cấp 1) như hiện tại; nếu anh muốn đủ 4 mode như PE thì đó là **món riêng** (đổi state machine BE), không nhét vào đợt UI này.

---

## Nợ kỹ-thuật khai ngay (không giấu)

- `ContractsListPage.tsx` (khuôn được chép) **CHỈ TỒN-TẠI Ở fe-admin** — port sang fe-user = **viết mới**, không phải copy (M-6).
- Trích-dẫn hỏng trong bản invest, đã sửa ở spec này: path `WorkflowService` thiếu lớp `Infrastructure/Services/` (B6) · `:552` `ResolveActorFullNameAsync` **không trỏ vào gì** (call-site thật `:485`/`:554`, định-nghĩa ở file khác) · `:547` changelog lệch 1 dòng (thật `:546`) · A-0 sai khoảng dòng (B5).
- Lane `reviewer lens-fidelity` **chết dở** (hết giới-hạn phiên): §6 (phép đo P3-12) và §8 (lỗ-hổng tự khai) còn trống, dòng VERDICT còn `?`. **Lead đã tự chạy phép đo §6** (kết-quả ở ①, lật P3-12) nhưng **§8 thì chưa ai làm** ⇒ **danh-sách "chưa đo được" của lăng-kính fidelity hiện KHÔNG TỒN-TẠI**. Đừng đọc bản đó thành "đã soi hết trục trung-thành".
- Chưa đo: `fe-user` twin của `usePipelineStages.ts` / `PipelineStageFolders.tsx` (feasibility C-1) · số phiếu KHKK thật ở prod để biết trần 200 có cắn không (C-4) · `hideEmpty` tương-tác với `content`/`groups` (C-3).
