# SPEC — Thêm N hạng mục (giá + ghi chú) & cột DỰ ÁN trong ma trận ngân sách (05-08-2026)

> Nguồn: `/fable-real investigator-codebase` single deep-pass (`wf_a574cff1-6b9`, 1 lane, 169K tok, 38 tool-use, ~12′).
> Engine **propose-only**; file này **LEAD ghi SAU khi verify** (H21 honest-note (c)).

---

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

**Anh chốt (verbatim, tin nhắn cuối):** *"Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi"*
(kèm ảnh khoanh đỏ mục **2. Giá đề xuất theo NCC trúng thầu**, mũi tên chỉ cột **HẠNG MỤC**).

⇒ **Phạm vi lượt này = A.** Mục B ghi lại để không rơi, **chưa land**.

**A. Dòng hạng mục (LÀM NGAY):** 1 NCC được **N dòng hạng mục**, mỗi dòng **nhập tay giá + ghi chú**.
🔑 Câu này **mở khoá bế tắc trước đó**: lead từng dừng vì hỏi *"tách N hạng mục thì CHIA tiền NCC thế nào"*.
Câu trả lời là **không có luật chia — người nhập tay từng dòng**. Hết chặn.

**B. Cột DỰ ÁN (CHƯA LÀM — chờ anh chốt 3 câu ở §③):** 11 ô `—` trong ma trận ngân sách của phiếu Duyệt NCC,
nuôi bằng Σ các dòng hạng mục bên KHKK.

### 🔴 3 đính-chính engine bắt được — lead ghi lại vì 2 cái đầu là SAI CỦA LEAD

| # | Lead nói | Sự thật đo được | Nguồn |
|---|---|---|---|
| a | *"cột DỰ ÁN là DI-CHỨNG module Budget cũ bị Mig 50 rút ruột"* | **SAI.** Cột sinh ở `e33481e` **19-06** (S76/Mig 56) — **SAU** Mig 50 (13-06) **6 ngày**, và **cùng commit** với comment ý-định *"DỰ ÁN: hiển thị FE-only (chưa wire BE — sau mới có NGƯỜI DỰ ÁN NHẬP)"* ⇒ **CHỖ-CHỪA-SẴN có chủ đích**, không phải xác chết | `PeWorkItemBudget.cs:19` + git `e33481e` |
| b | *"cột DỰ ÁN hardcode `—` ở ≥3 chỗ"* | **THIẾU.** Thật = **11 ô/app**: Block A `PeDetailTabs.tsx:1415·1426·1447` + Block B `:1535·1589·1599·1619·1629·1639·1664·1677`; fe-user mirror y hệt | pattern-count 19/19 cả 2 app |
| c | *(lead không biết)* | 🔑 **Tầng NS cấp dự-án ĐÃ CÓ MỘT NỬA**: `Project.BudgetTotal` sinh từ **Mig 2 — day-1**, wired TRỌN 2 đầu (BE CRUD `ProjectFeatures.cs:18·82·107·129·154` + FE cột **"Ngân sách"** `ProjectsPage.tsx:117` + form `:205`). Chỉ chưa nối vào ma trận PE, và **DỮ-LIỆU RỖNG: 61/69 dự án NULL**, 8 cái có số đều là demo-seed gated-OFF-prod | `Domain/Master/Project.cs:12` · `DbInitializer.cs:3212-3219` |

🔴 **(c) rất có thể CHÍNH LÀ thứ anh nói** — *"bên mục ngân sách có mục dự án chưa điền vào"*.
Ô đó **có thật, nhập được, và đang trống** ở 61/69 dự án. Đây là lớp **"cơ-chế đúng, dữ-liệu không có"** lần thứ 7.

🔸 Kết-luận hệ-quả của lead (*"phải dựng một tầng, không phải vá 1 cột FE"*) **vẫn đứng** — chỉ tiền-đề đổ.

---

## ② CÁCH IMPLEMENT

### A — Dòng hạng mục N-dòng + giá + ghi chú (lượt này)

**BE — 0 migration** (unique `(PlanId, SupplierId, CatalogEntryId)` lọc `IsDeleted=0` đã có từ Mig 71):

| # | Việc | Ghi chú |
|---|---|---|
| A1 | `UpdateContractSigningPlanLineCommand` +`ProposedAmount?` +`Note?`, đổi `CatalogEntryId` → **nullable** | Null-safe khuôn `UpdateDraftCommand:559-563`: `null` = không gửi ⇒ giữ nguyên. Nullable ⇒ **call-site FE cũ không phải sửa dòng nào** |
| A2 | `AddContractSigningPlanLineCommand(PlanId, SupplierId, CatalogEntryId, ProposedAmount, Note)` | 🔴 `SupplierId` **PHẢI ∈ NCC đã có trên phiếu** — NCC đến từ winner của PE, không được đẻ NCC mới ở đây. 🔴 `CatalogEntryId` **BẮT BUỘC**: unique index **cố ý không lọc NULL** ⇒ thêm dòng NULL sẽ đụng dòng gốc |
| A3 | `DeleteContractSigningPlanLineCommand(PlanId, LineId)` | 🔴 **CHỈ xoá được dòng THÊM TAY** (`PeReferenceAmount == 0`). Dòng gốc sinh từ PE mang snapshot tham chiếu — xoá là mất số gốc |
| A4 | 3 route: `POST {id}/lines` · `DELETE {id}/lines/{lineId}` · mở rộng body `PUT {id}/lines/{lineId}` | policy `KeHoachKyKet.Update` (gotcha #85: đúng key endpoint) |

🔴 **`PeReferenceAmount` của dòng THÊM TAY = 0, KHÔNG copy số NCC.**
Tham chiếu là Σ báo giá được chọn của **cả NCC**, đã nằm trọn ở dòng gốc; lặp lại là **đếm đôi tiền dự án**.

**FE (mirror 2 app):** mục 2 thêm nút `+ Thêm hạng mục` theo NCC · ô nhập **giá đề xuất** + **ghi chú** inline
(chỉ phase Nháp/Trả lại) · nút xoá cho dòng thêm tay.

### B — Cột DỰ ÁN (CHƯA LÀM)

Phương án engine chốt = **live-derive, 0 migration, 0 endpoint mới**: JOIN `Lines → Plan WHERE Plan.ProjectId = pe.ProjectId`,
2 tầng mirror S134 (CHÍNH-XÁC = Σ `ApprovedAmount` plan `DaDuyet` ⟂ TẠM-TÍNH = Σ `ProposedAmount` plan đang mở).
Đã BÁC: ledger-table riêng (+1 mig, số derive được lại đi lưu) · cột `DuAn*` trên `PeWorkItemBudgets`
(**sai trục khoá** — bảng per-`WorkItemId` mà chứa số per-`Project`; và `CatalogEntryId` SP-002 **không map được** `WorkItemId`).

🔴 **Rào tiền phải xử khi land B:** nhánh **frozen** `PurchaseEvaluationFeatures.cs:946-959` dựng DTO **bằng tay** —
quên field mới thì phiếu `DaDuyet` hiện DỰ ÁN = 0 **câm lặng** (đúng lớp *"vắng mặt trông giống ổn"*).

---

## ③ CHECKLIST — acceptance ĐO ĐƯỢC

| # | Deliverable | Acceptance |
|---|---|---|
| 1 | A1 land | `grep -c 'ProposedAmount' ContractSigningPlanFeatures.cs` tăng; call-site FE cũ (`catalogEntryId` đơn lẻ) vẫn build |
| 2 | A2 rào NCC | POST với `supplierId` KHÔNG thuộc phiếu → **409**, message nêu rõ |
| 3 | A2 rào trùng | POST trùng cặp (Plan, Supplier, CatalogEntry) → **409** chứ không phải 500 unique-violation |
| 4 | A3 rào dòng gốc | DELETE dòng có `PeReferenceAmount != 0` → **409** |
| 5 | Tiền không đếm đôi | Dòng thêm tay có `PeReferenceAmount == 0` — verify bằng đọc DB/DTO sau khi tạo |
| 6 | Mirror | `sha256sum` từng cặp file FE 2 app — **khớp 100%** |
| 7 | Build | `npm run build` ×2 app + `dotnet build` — 0 error. 🔴 Đo **NỘI DUNG** bundle, đừng tin thời-gian build |
| 8 | Deploy | bundle prod 2 vế: chuỗi mới **có** ∧ chuỗi cũ **vắng**, kèm control-dương. Dùng `grep -o \| wc -l`, KHÔNG `grep -c` (bundle 1 mega-dòng) |
| 9 | 🔴 Mắt anh | Soi thật **375 / 1366 / 1440** trên prod — mọi kết luận giao diện tới giờ là ĐỌC MÃ |

### 🔴 3 câu treo — cần anh chốt TRƯỚC khi land mục B

1. **Σ KHKK đổ vào Ô NÀO** trong 11 ô? (engine đề xuất: hàng **5 – lũy kế** Block B, 2 tầng chính-xác + tạm-tính khuôn amber S134; còn hàng **NS full** Block A đọc `Project.BudgetTotal`)
2. **Phiếu đã duyệt**: cột DỰ ÁN **live** hay **đóng băng** theo snapshot?
3. Sửa thẳng `ProposedAmount` (delta tự-audit = `ProposedAmount − PeReferenceAmount`) là đủ, hay cần field **"điều chỉnh"** riêng?
