Compare commits

...

4 Commits

Author SHA1 Message Date
b811e1125d [CLAUDE] Contract: KHKK them N hang muc (gia+ghi chu) + gop muc 3-4 file dinh kem (0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m32s
Nguon: /fable-real investigator-codebase (wf_a574cff1-6b9, 1 lane, 169K tok).
Spec 3-muc: runs/2026-08-05-S175-duan-budget-column/spec-hang-muc-nhieu-dong-va-cot-du-an-05-08-2026.md
Anh chot: "Chi them hang muc -> nhieu hang muc -> gia va ghi chu, vay thoi" + "file 3+4 ->
Hop thanh file dinh kem nhu cua Duyet NCC nhe".

=== 1. THEM N HANG MUC / NCC (0 MIGRATION) ===
🔴 Cau hoi TIEN da co dap an: KHONG co luat chia — NGUOI NHAP TAY tung dong.
Truoc do lead dung lai vi hoi "tach N hang muc thi chia tien NCC the nao"; anh tra loi khong
theo tien, nhap tay. Het chan.

BE — 0 migration (unique 3 cot Plan x Supplier x CatalogEntry co tu Mig 71; may DA rong,
lan nay chi bung UI — dung khuon "may rong, UI hep" anh chot S164):
· UpdateContractSigningPlanLineCommand +ProposedAmount? +Note?, CatalogEntryId -> NULLABLE.
  Null-safe khuon UpdateDraftCommand:559-563 => call-site FE cu (gui dung 1 field) KHONG phai
  sua dong nao.
· AddContractSigningPlanLineCommand (MOI) + route POST {id}/lines.
  🔴 SupplierId PHAI thuoc phieu — NCC den tu winner phieu Duyet NCC; de NCC moi o day = ky
    voi nha thau chua ai chon. Khong thuoc => 409.
  🔴 CatalogEntryId BAT BUOC: unique index CO Y khong loc "IS NOT NULL"
    (ContractSigningPlanLineConfiguration.cs:35-38) => them dong NULL se dung dong goc.
  🔴 PeReferenceAmount = 0, KHONG copy so tham chieu cua NCC. Tham chieu la tong bao gia DUOC
    CHON cua CA NCC, da nam tron o dong goc (Features.cs:512-515). Lap lai o dong them tay =
    DEM DOI tien tham chieu du an. No cung la DAU phan biet dong-goc/dong-them-tay.
· DeleteContractSigningPlanLineCommand (MOI) + route DELETE {id}/lines/{lineId}.
  🔴 CHI xoa duoc dong THEM TAY (PeReferenceAmount == 0). Dong goc mang snapshot tham chieu —
    xoa la mat so goc + mat dau vet NCC tung trung thau.
· Bat TRUNG cap (Plan, Supplier, CatalogEntry) TRUOC SaveChanges => 409 doc duoc, thay vi 500
  unique-violation. Ap cho CA duong them LAN duong doi hang muc (NCC nay co N dong => doi
  trung hang muc dong khac cung NCC la co that).
· Rao ContractId nay che CA TIEN, khong chi hang muc: ProposedAmount la so di vao HD.
· Changelog ghi cap CU->MOI cho tien ("gia de xuat 1.000 -> 2.000 d") — day la tien, nguoi
  duyet phai truy duoc ai doi tu bao nhieu sang bao nhieu, khong chi biet "co sua".
· 3 route deu policy KeHoachKyKet.Update (gotcha #85 — dung key cua endpoint; "tao" o module
  nay nghia la tao PHIEU nen POST line van la Update).

FE: cot GIA DE XUAT + GHI CHU nhap duoc inline (chi Nhap/Tra lai, chi dong chua co HD), luu
onBlur va CHI khi so doi that. Nut "+ Them hang muc" liet ke dung NCC da co tren phieu. Dong
them tay hien "—" o cot tham chieu (khong phai "0 d" — de khong doc nham la tham chieu bang 0).

=== 2. GOP MUC 3 + 4 -> 1 KHOI "FILE DINH KEM" ===
Khuon dong file be tu PE (PeDetailTabs.tsx:3382-3418): 1 danh sach PHANG, moi dong co badge
cho biet file thuoc can cu nao (hoac "Ho so phieu" neu o muc phieu), + eye xem truoc + download.
🔴 Bang can cu 5 cot GO khoi man nhung MAY GIU NGUYEN: entity KhkkDossierItem + 3 endpoint +
changelog con nguyen; kind/status/TVGS/ghi-chu van sua qua nut ✎ (mo dung dialog cu, du 5
truong) va van hien duoi dang badge. Go luon entity de "cho gon giao dien" = mat du-lieu BE
dang ghi va nguoi duyet dang doc.
Can cu DA KHAI ma chua co file van hien 1 dong (dashed) — khong thi khai xong no bien mat va
nguoi dung tuong bam hut.

=== 3. ENGINE BAC 2 GIA-THUYET CUA LEAD (ghi lai vi la SAI CUA LEAD) ===
(a) Lead: "cot DU AN la DI-CHUNG module Budget cu bi Mig 50 rut ruot" — SAI. Cot sinh o
    e33481e 19-06 (Mig 56), SAU Mig 50 (13-06) 6 ngay, va CUNG COMMIT voi comment y-dinh
    PeWorkItemBudget.cs:19 "DU AN: hien thi FE-only (chua wire BE — sau moi co NGUOI DU AN
    NHAP)" => CHO-CHUA-SAN co chu dich, khong phai xac chet.
(b) Lead do "≥3 cho hardcode —" — THIEU. That = 11 o/app (Block A 3 + Block B 8), mirror 2 app.
(c) 🔑 PHAT HIEN: Project.BudgetTotal DA TON TAI tu Mig 2 (day-1), wired tron 2 dau (BE CRUD
    ProjectFeatures.cs + FE cot "Ngan sach" ProjectsPage.tsx:117 + form :205). Chi chua noi vao
    ma tran PE, va DU-LIEU RONG: Dev 61/69 du an NULL (8 cai co so deu la demo-seed gated-OFF
    prod). Rat co the CHINH LA thu anh noi "ben muc ngan sach co muc du an chua dien vao" —
    lop "co-che dung, du-lieu khong co" lan thu 7.

=== CON NO ===
🔴 Cot DU AN (11 o) CHUA LAND — cho anh chot 3 cau trong spec §③: (1) Sigma KHKK do vao O NAO
trong 11 o; (2) phieu DaDuyet thi cot DU AN live hay dong bang; (3) sua thang ProposedAmount
(delta tu-audit = Proposed − PeReference) du chua hay can field "dieu chinh" rieng.
🔴 CHUA soi mat prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3).
🔸 Test-before cho lop luy-ke la BAT BUOC khi land cot DU AN (financial-critical, rules §7) —
  lan nay chua cham luy-ke nen chua no test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:54:15 +07:00
bd309fa600 wal: flush 20260805T2031 2026-08-05 20:31:05 +07:00
f3a0d0cca5 wal: flush 20260805T2014 2026-08-05 20:14:10 +07:00
2855326767 wal: flush 20260805T2007 2026-08-05 20:07:13 +07:00
7 changed files with 1035 additions and 374 deletions

View File

@ -11,9 +11,13 @@ chain:
- [x] đợt A ship `a1b9cbb`: vá 401 tải file · vá `<lg` ngõ cụt mobile · trả nút duyệt mobile · gỡ SoonChip (3 ca 3 cách) · gỡ nút Từ chối · grid 260/320
- [x] cổng C2 @PAUSE-2 KÊU (A\B={frontend-designer}) → lead seed 1 entry → A\B=∅, sổ 16.373→19.869 B (control-dương: S173 có ở 10 sổ)
- [x] deploy `a1b9cbb` VERIFIED 2-vế ×2 app @S175: hash ĐỔI (admin `BXwdUVmD``Dlhi3eF1` · eoffice `DdcCH2oZ``B9PVjJnU`) ∧ literal gỡ `Từ chối kế hoạch (khoá phiếu)`=0/0, control-dương `Tải xuống`=10/10
- [ ] đợt B (32 mục bê style PE): panel duyệt 13 · nội dung 6 P0 + 8 P1 · panel list 13 delta (có `slate-400`=2.56:1 dưới WCAG) · host
- [ ] đợt D: O6 bấm-card-PE-giữ-màn · O3 hạng-mục N-dòng + SUM (**0-migration**) · O4 shopdrawing (**FE-only**) · O7 nút edit · O8 cây 2 cấp
- [ ] 🔴 HỎI ANH trước khi viết BE cho O3: cách đọc "sum tổng" — đây là TIỀN (lane L5 dặn)
- [x] đợt B ship `43e1bd7`: B1 panel duyệt (gỡ card-accent CLASS · emerald · glyph · Eye · badge 3-ngả · bỏ gập +GỠ `enabled` kẻo query câm) · B2 6 P0 (table-fixed 880 · orDash · Lock) · B3 (đầu cột · `hideSearch` gộp 2 ô tìm · 3 nấc · WCAG slate-500 ×7 · icon phân trang · chip baseline) · B4 (2 comment sai) — mirror 7/7 sha256, build XANH ×2
- [x] deploy `43e1bd7` VERIFIED 2-vế ×2 app: hash ĐỔI (admin `Dlhi3eF1``O5Z5H2FK` · eoffice `B9PVjJnU``CD72NWts`) ∧ chuỗi MỚI `Ghi chú của bạn`=1/1 · `Cây toàn trình`=3/3 ∧ chuỗi CŨ `Ý kiến của bạn`=0/0; control-dương `Tải xuống`=10/10 (title= giữ được) · `Ý kiến cấp duyệt`=6/6 (CỐ Ý không đổi)
- [ ] 🔴 CHƯA soi mắt prod 375/1366/1440 (5 lane NO-SCREENSHOT, gotcha #3) — mọi kết luận đợt B là ĐỌC MÃ
- [ ] 🅿️ PARK (anh chưa giao làm — chỉ ĐO để biết trạng-thái): đường **KHKK → lũy kế NGÂN SÁCH chưa tồn tại**. `PeBudgetAccumulator.cs` (140 dòng) chỉ đọc 3 bảng PE (`PurchaseEvaluations` · `…Suppliers` · `…Quotes`); `grep -c 'ContractSigningPlan'` = **0** — control-dương: chuỗi đó có ở **7 file** khác trong Application layer ∧ `PurchaseEvaluations` = 6 hit trong chính file này ⇒ grep sống, 0-hit là THẬT. Trường "điều chỉnh tăng tiền" anh nói = `AdjustmentAmount` / `ProAdjustmentAmount` (*NS V0/hiệu chỉnh tăng giảm*, cho phép ÂM) per cặp Dự án × Hạng mục, sửa qua `PUT /purchase-evaluations/{id}/budget/{pro|ccm}`
- [x] đợt D + C3 ship `a0ccac3` + deploy VERIFIED 2-vế ×2 app (admin `O5Z5H2FK``BTWwiQTV` · eoffice `CD72NWts``Bn8xEtmU`; 5 chuỗi mới =1/1, control-dương cây =1/1): O6 `onOpenPe` + xem PE tại chỗ (`?pe=`, loại-trừ `?id=`) · O4 gửi `dossierItemId` (rail BE có sẵn, FE chưa từng gửi) · O7 bút chì + chế-độ sửa · O8 cây 2 cấp · C3 value slate-900 · O3 **vế (2)** 2 tổng toàn phiếu
- [x] 🔴 **ANH CHỐT @S175 — O3 vế (1) GIỮ 1-1, KHÔNG LÀM**: *"Cái này ko theo tiền, hiện cho 1-1 trước đã"* (anh nói 2 lần, mở+đóng tin nhắn). ⇒ **KHÔNG viết route POST/DELETE line**, KHÔNG đổi dialog cầu HĐ sang checkbox. Đúng khuôn **máy rộng / UI hẹp** anh chốt S164: schema để rộng sẵn (unique 3 cột Mig 71), UI giữ ca hẹp. 🔸 Cơ-chế cho ca "nhiều hơn 1" anh chỉ: **KHÔNG tách dòng mà ĐIỀU CHỈNH TĂNG TIỀN**, đi qua mục NGÂN SÁCH — *"bên mục ngân sách có mục dự án chưa điền vào; nếu tăng tiền lũy kế thì phía KHKK sẽ điền vào đấy"*
- [x] 🔴 ANH CHỐT @S175 cách đọc "sum tổng" O3 (gate TIỀN đã mở): **① trục = CHỈ TỔNG TOÀN PHIẾU** (KHÔNG có dòng tổng per-NCC) · **② hiện 2 TỔNG RIÊNG** cạnh nhau (Σ giá đề xuất ⟂ Σ giá duyệt), đặt PHÍA TRÊN bảng
- [ ] thăng luật E4 vế ➊ lên khối auto-inject `harness-eval/MEMORY.md ## Luật cứng` (đang 3 bullet, luật nằm dưới vùng planner mù)
- [ ] ghi synthesis cho run-folder **S173** `2026-08-05-S173-bookend-open` (vẫn ORPHAN, 0 flat synthesis)
- [ ] 5 điểm governance đầu phiên CHƯA ĐỘNG: `STATUS:473` 86→87 · gạch 5 slot HANDOFF · `ROTATE vrapp` · re-stamp Carry 14 slug · slot (70) anchor
@ -21,10 +25,12 @@ chain:
- [ ] kéo 2 thư `2026-08-05-Governance-loi-do-luong-phan-1/2` — 🔄 @S175 nay ĐÃ CÓ ở `AI_INFRA/broadcasts/outbox/all/` (trước = 0 hit); 4× `upgrade-pack-phased` đã ở `inbox/ai_infra/`, chỉ thiếu adap-report
- [ ] monthly drift-audit quá hạn · dọn 9 dir rỗng `runs/.../agent-memory/*` @session-end
next: đợt B (bê style PE) → hỏi anh câu "sum tổng" TRƯỚC khi viết BE cho O3 → đợt D → [đuôi, đừng rơi] thăng luật E4 ➊ · synthesis S173 · 5 điểm governance
next: 🔴 HỎI ANH cách chia tiền N-hạng-mục (chặn O3 vế 1) + anh soi mắt prod 375/1366/1440 → [đuôi, đừng rơi] thăng luật E4 ➊ · synthesis S173 · 5 điểm governance · kéo 2 thư + adap-report
verify:
- `curl -s https://admin.solutions.com.vn/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js'` → khác `index-Dlhi3eF1.js` = đợt SAU đã tới (mốc @S175)
- `curl -s https://admin.solutions.com.vn/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js'` → khác `index-BTWwiQTV.js` = đợt SAU đã tới (mốc @S175 sau đợt D)
- 🔴 LUẬT ĐO bundle prod: `grep -o '<chuỗi>' | wc -l`, **KHÔNG** `grep -c` — bundle minify là 1 mega-dòng nên `-c` trả 0/1 chứ không phải số lần (cắn thật @S175)
- `grep -cE 'className=.*card-accent' fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx` → 0 = panel TRẦN. 🔴 đo `className=.*`, KHÔNG đo token trần: comment giải-thích có nhắc tên class (use ⟂ mention)
- `grep -c '<SoonChip' fe-admin/src/components/pipeline/PipelineStageFolders.tsx` → 0 (control-dương: `grep -c 'SoonChip'` → 2, chỉ còn trong comment)
- `grep -c "responseType" fe-admin/src/components/khkk/KhkkWorkflowPanel.tsx` → 2 = A1 đã vá
- `ls .claude/workflows/runs/2026-08-05-S173-bookend-open/*-synthesis.md` → no match = S173 CÒN orphan

View File

@ -0,0 +1,78 @@
# run — cột DỰ ÁN trong bảng ngân sách (S175, 2026-08-05)
**Engine:** `/fable-real` (LỆNH-A, per-invocation H21) — **1 vai × SINGLE top-model deep-pass**.
**Vai anh gán:** `investigator-codebase` · **Đề-bài anh gõ:** *"invest case cột DỰ ÁN trong bảng ngân sách → sau đó điều chỉnh lại"*
> 🔴 Engine **propose-only**. Spec-file 3-mục do **LEAD ghi SAU khi verify** (honest-note (c)).
---
## Bối cảnh (lead nạp — anh nói gì)
Ảnh anh gửi ×2: bảng **B. THỰC HIỆN** trong phiếu **Duyệt NCC**, khoanh đỏ **TOÀN BỘ cột DỰ ÁN** (ảnh-2) —
cột đó đang `—` ở mọi dòng per-cột, trong khi PRO và CCM đều có số.
Lời anh (verbatim, 2 lượt trả lời AskUser):
- *"Không nó có 1 mục riêng 3 thằng này là 3 thằng riêng, hiện dự án chưa có thì khi chọn hạng mục → nó cộng vào mục đó"*
- *"Không phải ý là nó đã có sẵn cái bảng ngân sách ở giao diện NCC → Còn thiếu của Dự án → Giờ chọn hạng mục điền tiền sau đó nó sẽ cộng thêm vào mục đó"*
- Trước đó: *"nếu nhiều hơn thì điều chỉnh tăng tiền, bên mục ngân sách có mục dự án chưa điền vào. Nếu tăng tiền lũy kế thì phía KHKK sẽ điền vào đấy"*
🔴 **ANH GỠ NÚT THẮT (tin nhắn thứ 3, giữa lượt — ĐÂY LÀ CÂU CHỐT, đọc kỹ trước mọi thứ khác):**
> *"phần trên duyệt NCC → ngân sách bảng đó mang xuống → KHKK → nhập từng hạng mục sau đó cộng vào ô của dự án"*
**Luồng đích 4 nhịp:** ① bảng ngân sách ĐANG SỐNG ở Duyệt NCC (GĐ1) → ② **MANG XUỐNG** KHKK (GĐ2)
→ ③ ở KHKK **nhập tiền THEO TỪNG HẠNG MỤC** → ④ các dòng đó **CỘNG vào ô DỰ ÁN**.
🔑 **Câu này hoà-giải mâu-thuẫn tưởng-như-vô-lý ở 2 lượt trước.** "3 thằng RIÊNG" **không** có nghĩa DỰ ÁN
là ô nhập tay cấp dự án — mà nghĩa là **NGUỒN của nó khác hẳn**: PRO/CCM là 2 vai nhập cho *gói thầu này*,
còn DỰ ÁN được nuôi bằng **các dòng hạng mục nhập BÊN KHKK**. Vì nguồn khác nên nó là "thằng riêng";
vì là cộng-dồn nên "chọn hạng mục điền tiền → cộng vào mục đó". **Cả hai vế đều đúng cùng lúc.**
Khớp luôn câu anh nói lượt trước: *"Nếu tăng tiền lũy kế thì phía KHKK sẽ điền vào đấy."*
🔴 **Hệ-quả cho vai:** câu hỏi KHÔNG còn là "A tự-cộng hay B nhập-tay". Nó là:
**bảng ngân sách MANG XUỐNG KHKK như thế nào, ai được nhập, nhập ở đâu, và đường cộng ngược lên ô DỰ ÁN
đi qua bảng/khoá nào** — trong khi `PeBudgetAccumulator` hiện **0 hit** `ContractSigningPlan` (đã đo).
## Đo sơ-bộ của lead (để vai khỏi lặp — VERIFY LẠI, đừng tin suông)
| Đã đo | Kết quả | Nguồn |
|---|---|---|
| DTO `PeBudgetSummary` có trường cấp DỰ ÁN? | **KHÔNG** — mọi trường khoá theo cặp (Dự án × Hạng mục) | `fe-admin/src/types/purchaseEvaluation.ts:312-339` |
| Comment ý-định lúc dựng cột | ghi thẳng `DỰ ÁN "—"` | `PeDetailTabs.tsx:1503` |
| Comment DTO | *"DỰ ÁN = FE hiển thị-only (chưa wire BE)"* | `purchaseEvaluation.ts:311` |
| Cột DỰ ÁN hardcode `—` ở đâu | ≥3 chỗ | `PeDetailTabs.tsx:1535 · 1664 · 1677` |
| `PeBudgetAccumulator` gom từ bảng nào | CHỈ 3 bảng PE; `grep -c 'ContractSigningPlan'` = **0** (control-dương: chuỗi đó có ở 7 file khác Application layer) | `PeBudgetAccumulator.cs` |
| Entity ngân sách | `PeWorkItemBudget` = 1 record / cặp `ProjectId` × `WorkItemId`; có `ProInitial/ProAdjustment` + `Initial/Adjustment` | `Domain/PurchaseEvaluations/PeWorkItemBudget.cs:27-36` |
🔴 **MANH MỐI LỚN NHẤT — vai PHẢI đào:** module **Budget (Ngân sách dự án) CŨ đã bị XÓA HẲN ở Mig 50 (S61)**
(CLAUDE.md: *"XÓA module Budget cũ, thay bằng `PeWorkItemBudgets` ngân sách per-gói-thầu"*; FE `Bg_*` gỡ hết).
⇒ Giả-thuyết cần chứng/bác: **cột DỰ ÁN là DI-CHỨNG của module cũ** — chỗ ngồi còn, ruột bị rút.
Nếu đúng, "điều chỉnh lại" của anh = **dựng lại tầng ngân sách cấp DỰ ÁN**, không phải vá 1 cột FE.
## taskList snapshot
```json
[
{
"role": "investigator-codebase",
"tier": "fable",
"task": "Điều tra TRỌN luồng 4 nhịp anh chốt: bảng ngân sách ở Duyệt NCC → MANG XUỐNG KHKK → nhập tiền TỪNG HẠNG MỤC ở KHKK → CỘNG vào ô DỰ ÁN. Cụ thể: (1) truy lịch-sử vì sao cột DỰ ÁN tồn tại mà rỗng (Mig 50 xóa module Budget cũ · Mig 56/59 dựng ma-trận 3 cột) — DI-CHỨNG hay CHỖ-CHỪA-SẴN; (2) đo tàn-dư schema/DTO/FE của ngân sách cấp DỰ ÁN còn sót; (3) dựng ĐƯỜNG DỮ-LIỆU đích: KHKK nhập per-hạng-mục thì GHI VÀO ĐÂU (tái dùng PeWorkItemBudgets khoá ProjectId×WorkItemId? hay bảng mới?), ai được nhập (role/phase gate), và ô DỰ ÁN CỘNG NGƯỢC LÊN bằng query nào — nhớ PeBudgetAccumulator hiện 0 hit ContractSigningPlan; (4) chốt 1 phương-án kèm GIÁ (số migration · số endpoint · số file FE), so với ít nhất 1 phương-án thay-thế đã cân-nhắc rồi BÁC; (5) chỉ ĐÍCH DANH mọi file:line phải sửa; (6) nêu rõ mọi rào TIỀN có thể vỡ (freeze snapshot phiếu DaDuyet S133 · lũy kế tạm-tính S134 · choke-point ApplyApprovedValuesOnFinalize) mà thay đổi này có thể đụng."
}
]
```
## Stages
- [x] P1 — lead scaffold run-folder + taskList snapshot
- [ ] P2 — engine deep-pass (propose-only) → return
- [ ] P3 — lead VERIFY return + ghi `spec-*.md` 3-mục
- [ ] P4 — thực-thi theo spec ("sau đó điều chỉnh lại")
## wf run-id
`wf_a574cff1-6b9` — 1 agent · `investigator-codebase` · tier `fable` · propose-only.
🔸 **Lần phóng ĐẦU FAIL SẠCH (0 agent · 0 token · 22ms):** guard `checkpointApproved chưa set`
(anti-accidental-fire) chặn ngay cửa. Đúng thiết-kế — em-main phải BÁO {số agent · vai · task}
rồi mới set cờ. Đã báo, set `checkpointApproved:true`, phóng lại cùng `resumeFromRunId`.
**Ghi lại làm vết: guard này CÓ RĂNG, không phải trang-trí.**

View File

@ -0,0 +1,86 @@
# 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?

View File

@ -18,11 +18,14 @@
// Không dựng workspace riêng cho việc này.
// [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`) — đó là code chết ngay ngày đầu.
import { Fragment, useState, type ReactNode } from 'react'
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
import { Button } from '@/components/ui/Button'
@ -158,6 +161,13 @@ export function KhkkDetailContent({
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
// [S175] NCC đang mở form "thêm hạng mục" (null = không mở form nào).
const [addFor, setAddFor] = useState<{ supplierId: string; supplierName: string | null } | null>(null)
const [addCatalogId, setAddCatalogId] = useState('')
const [addAmount, setAddAmount] = useState('')
const [addNote, setAddNote] = useState('')
const phase = plan.phase as KhkkPhaseValue
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
@ -198,6 +208,32 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)),
})
// [S175 — anh chốt "nhiều hạng mục → giá và ghi chú"] 3 mutation cho DÒNG hạng mục.
// 🔴 Body null-safe: chỉ gửi field ĐANG sửa; field không gửi thì BE giữ nguyên
// (`UpdateContractSigningPlanLineCommand` — khuôn bug-class #73/S42).
const saveLineField = useMutation({
mutationFn: async (p: { lineId: string; proposedAmount?: number; note?: string }) =>
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
proposedAmount: p.proposedAmount,
note: p.note,
}),
onSuccess: () => { toast.success('Đã lưu dòng'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const addLine = useMutation({
mutationFn: async (p: { supplierId: string; catalogEntryId: string; proposedAmount: number; note?: string }) =>
api.post(`/contract-signing-plans/${id}/lines`, p),
onSuccess: () => { toast.success('Đã thêm hạng mục'); setAddFor(null); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteLine = useMutation({
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
@ -293,6 +329,25 @@ export function KhkkDetailContent({
}
}
// [S175 · anh chốt] GỘP mục 3 (Căn cứ hồ sơ) + mục 4 (File đính kèm) thành MỘT khối
// "File đính kèm" khuôn Duyệt NCC (`PeDetailTabs.tsx:3382-3418`): 1 danh sách PHẲNG,
// mỗi dòng = 1 file có badge cho biết nó thuộc căn cứ nào.
// 🔴 CHỈ gộp phần HIỂN THỊ — máy căn cứ hồ sơ GIỮ NGUYÊN (entity `KhkkDossierItem`,
// 3 endpoint POST/PUT/DELETE dossier-items, changelog). Xoá máy đi để "cho gọn giao diện"
// là mất `kind`/`status`/TVGS — những thứ BE đang ghi và người duyệt đang đọc.
// Căn cứ ĐÃ KHAI mà CHƯA có file vẫn phải hiện 1 dòng, nếu không thì khai xong nó biến mất
// và người dùng tưởng bấm hụt.
type MergedRow =
| { rk: 'file'; att: KhkkAttachmentDto; item: KhkkDossierItemDto | null }
| { rk: 'empty'; item: KhkkDossierItemDto }
const mergedRows: MergedRow[] = []
for (const d of plan.dossierItems) {
const files = dossierAttachments.get(d.id) ?? []
if (files.length === 0) mergedRows.push({ rk: 'empty', item: d })
else for (const a of files) mergedRows.push({ rk: 'file', att: a, item: d })
}
for (const a of planAttachments) mergedRows.push({ rk: 'file', att: a, item: null })
// 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
// này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
// là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` /
@ -525,213 +580,289 @@ export function KhkkDetailContent({
</>
) : orDash(l.tenHangMuc)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{moneyOrDash(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{moneyOrDash(l.proposedAmount)}</td>
{/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham
chiếu của NCC (đã nằm trọn ở dòng gốc), lặp lại là đếm đôi. Hiện
em-dash mờ thay vì "0 đ" để không đọc nhầm thành "tham chiếu bằng 0". */}
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
{l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)}
</td>
{/* [S175] GIÁ ĐỀ XUẤT nhập tay — anh chốt "nhiều hạng mục → giá và ghi chú".
Lưu khi rời ô (onBlur) và chỉ khi SỐ ĐỔI THẬT, tránh bắn 1 request mỗi
lần click vào rồi click ra. */}
<td className="px-3 py-2 text-right tabular-nums text-brand-800">
{isDraft && !l.contractId ? (
<input
type="number"
min={0}
step={1000}
defaultValue={l.proposedAmount}
disabled={saveLineField.isPending}
aria-label={`Giá đề xuất dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
onBlur={(e) => {
const v = Number(e.target.value)
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
}}
className="h-8 w-full min-w-[7rem] rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
/>
) : moneyOrDash(l.proposedAmount)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{moneyOrDash(l.approvedAmount)}</td>
<td className="px-3 py-2 text-slate-600">{orDash(l.note)}</td>
<td className="px-3 py-2 text-slate-600">
{isDraft && !l.contractId ? (
<div className="flex items-center gap-1">
<input
type="text"
maxLength={1000}
defaultValue={l.note ?? ''}
placeholder="Ghi chú…"
disabled={saveLineField.isPending}
aria-label={`Ghi chú dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
onBlur={(e) => {
const v = e.target.value.trim()
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
}}
className="h-8 w-full min-w-[6rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
/>
{/* Chỉ dòng THÊM TAY mới xoá được. Dòng gốc sinh từ phiếu Duyệt NCC
mang snapshot tham chiếu — BE cũng chặn, đây chỉ là không mời bấm. */}
{l.peReferenceAmount === 0 && (
<button
type="button"
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
disabled={deleteLine.isPending}
title="Xoá hạng mục này"
className="shrink-0 rounded px-1 py-1 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
) : orDash(l.note)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* [S175 — anh chốt] THÊM HẠNG MỤC cho 1 NCC. 1 NCC được N hạng mục: máy đã cho
phép từ Mig 71 (unique 3 cột `Plan × Supplier × CatalogEntry`), lượt này chỉ
bung UI — đúng khuôn "máy rộng, UI hẹp" anh chốt S164.
🔴 CHỈ liệt kê NCC ĐÃ CÓ trên phiếu: NCC đến từ winner của phiếu Duyệt NCC;
đẻ NCC mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409). */}
{isDraft && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-slate-500">Thêm hạng mục cho:</span>
{[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => (
<button
key={l.supplierId}
type="button"
onClick={() => {
setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName })
setAddCatalogId(''); setAddAmount(''); setAddNote('')
}}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
>
<Plus className="h-3 w-3" /> {l.supplierName ?? '(không rõ NCC)'}
</button>
))}
</div>
)}
{addFor && (
<div className="mt-2 rounded-md border border-brand-200 bg-brand-50/50 p-3">
<div className="mb-2 text-[12px] font-semibold text-brand-800">
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-[14rem] flex-1">
<span className="label-eyebrow">Hạng mục</span>
<select
value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)}
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700"
>
<option value=""> Chọn hạng mục </option>
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
</label>
<label className="w-[9rem]">
<span className="label-eyebrow">Giá đ xuất</span>
<input
type="number" min={0} step={1000}
value={addAmount}
onChange={(e) => setAddAmount(e.target.value)}
placeholder="0"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums"
/>
</label>
<label className="min-w-[10rem] flex-1">
<span className="label-eyebrow">Ghi chú</span>
<input
type="text" maxLength={1000}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
placeholder="(tùy chọn)"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
/>
</label>
<div className="flex gap-1.5">
<Button
size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
onClick={() => addLine.mutate({
supplierId: addFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
note: addNote.trim() || undefined,
})}
>
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
</div>
</div>
</div>
)}
</>
)}
<div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu SNAPSHOT tổng báo giá đưc chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu Nháp/Trả lại mọi dòng phải gán xong mới trình duyệt đưc.
{' '}🔴 Dòng THÊM TAY không giá tham chiếu (hiện &ldquo;&rdquo;) số tham chiếu của cả NCC, đã nằm dòng gốc.
</div>
</Section>
{/* ─────────────── 3 ─────────────── */}
{/* ─────── 3 — GỘP "Căn cứ hồ sơ" + "File đính kèm" (anh chốt S175) ─────── */}
{/* Anh: "file 3+4 -> Hợp thành file đính kèm như của Duyệt NCC nhé".
Khuôn dòng file bê từ PE `PeDetailTabs.tsx:3382-3418`: 1 danh sách PHẲNG, mỗi
dòng có badge cho biết file thuộc căn cứ nào (hoặc "Hồ sơ phiếu" nếu ở mức phiếu).
🔴 Bảng căn cứ 5 cột bị GỠ khỏi màn, nhưng MÁY GIỮ NGUYÊN: `kind`/`status`/TVGS/
ghi-chú vẫn sửa được qua nút ✎ (mở đúng dialog cũ, đủ 5 trường) và vẫn hiện dưới
dạng badge. Gỡ luôn entity để "cho gọn" thì mất dữ-liệu BE đang ghi. */}
<Section
title="3. Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)"
icon={<ClipboardList className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={plan.dossierItems.length}
actions={
isDraft ? (
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
</Button>
) : undefined
}
>
{plan.dossierItems.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa khai căn cứ nào.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[14%]" />
<col className={isDraft ? 'w-[26%]' : 'w-[30%]'} />
<col className="w-[14%]" />
<col className={isDraft ? 'w-[16%]' : 'w-[20%]'} />
<col className={isDraft ? 'w-[18%]' : 'w-[22%]'} />
{isDraft && <col className="w-[12%]" />}
</colgroup>
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
<th className="label-eyebrow px-3 py-2 text-left">Tên căn cứ</th>
<th className="label-eyebrow px-3 py-2 text-left">Trạng thái</th>
<th className="label-eyebrow px-3 py-2 text-left">TVGS</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
{isDraft && <th className="label-eyebrow px-3 py-2 text-right">Thao tác</th>}
</tr>
</thead>
<tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => {
// [S175 · O4] File gắn ĐÚNG căn cứ này (shopdrawing, mẫu vật liệu…).
const files = dossierAttachments.get(d.id) ?? []
return (
<Fragment key={d.id}>
<tr className={cn('border-slate-100 hover:bg-slate-50/60', files.length === 0 && 'border-b last:border-0')}>
<td className="px-3 py-2 text-slate-600">{orDash(DOSSIER_KIND_LABELS[d.kind])}</td>
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2">
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}>
{DOSSIER_STATUS_LABELS[d.status]}
</span>
</td>
<td className="px-3 py-2 text-slate-600">
{orDash(d.tvgsName)}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</td>
<td className="px-3 py-2 text-slate-600">{orDash(d.note)}</td>
{isDraft && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
{/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `<label>` bọc input
ẩn thay vì `<button>`+ref: mỗi dòng cần một ô chọn file riêng,
dùng ref thì phải nuôi N ref cho N dòng. */}
<label
title="Đính kèm file cho căn cứ này (shopdrawing, mẫu vật liệu…)"
className="cursor-pointer rounded px-1.5 py-1 text-teal-600 hover:bg-teal-50"
>
<Upload className="h-3.5 w-3.5" />
<input
type="file"
multiple
className="hidden"
onChange={(e) => onPickFiles(e, d.id)}
/>
</label>
<button
type="button"
onClick={() => setItemForm({
id: d.id, kind: d.kind, supplierId: d.supplierId, name: d.name,
status: d.status, tvgsName: d.tvgsName ?? '', note: d.note ?? '',
})}
className="rounded px-1.5 py-1 text-violet-600 hover:bg-violet-50"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => { if (window.confirm(`Xóa căn cứ "${d.name}"?`)) deleteItem.mutate(d.id) }}
className="rounded px-1.5 py-1 text-red-600 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
{/* [S175 · O4] Dòng-con liệt kê file của căn cứ. Hiện CẢ KHI read-only —
người duyệt cần xem được shopdrawing, chỉ không được thêm/xoá. */}
{files.length > 0 && (
<tr className="border-b border-slate-100 last:border-0">
<td colSpan={isDraft ? 6 : 5} className="bg-slate-50/40 px-3 pb-2">
<div className="flex flex-wrap gap-1.5 pl-3">
{files.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px]"
>
<Paperclip className="h-3 w-3 shrink-0 text-teal-500" aria-hidden />
<button
type="button"
onClick={() => download(a)}
title={`Tải xuống ${a.fileName} (${formatSize(a.fileSize)})`}
className="max-w-[16rem] truncate text-slate-700 hover:text-brand-700 hover:underline"
>
{a.fileName}
</button>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
{isDraft && (
<button
type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
title="Xóa file"
className="shrink-0 rounded text-red-500 hover:bg-red-50"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)}
</Section>
{/* ─────────────── 4 ─────────────── */}
<Section
title="4. File đính kèm"
title="3. File đính kèm (căn cứ hồ sơ / shopdrawing)"
icon={<Paperclip className="h-4 w-4" />}
accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={planAttachments.length}
count={mergedRows.length}
actions={
readOnly ? undefined : (
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
<Upload className="mr-2 h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
<input type="file" multiple className="hidden" onChange={onPickFiles} />
</label>
<div className="flex items-center gap-2">
{isDraft && (
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
</Button>
)}
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
<Upload className="mr-2 h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e)} />
</label>
</div>
)
}
>
{planAttachments.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa file đính kèm.</div>
{mergedRows.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa căn cứ hồ hay file đính kèm nào.</div>
) : (
<ul className="space-y-2">
{planAttachments.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-brand-800">{a.fileName}</div>
<div className="text-xs text-muted-foreground">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
</div>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">Tải xuống</button>
<div className="space-y-1">
{mergedRows.map((r) => (
r.rk === 'file' ? (
<div key={r.att.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-slate-700" title={r.att.fileName}>{r.att.fileName}</span>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(r.att.fileSize)}</span>
<span className={cn(
'shrink-0 rounded px-1 text-[9px] font-medium',
r.item ? 'bg-violet-100 text-violet-700' : 'bg-slate-200 text-slate-600',
)}>
{r.item ? (DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ') : 'Hồ sơ phiếu'}
</span>
{r.item && (
<span className="hidden max-w-[10rem] shrink-0 truncate text-[10px] text-slate-500 sm:inline" title={r.item.name}>
{r.item.name}
</span>
)}
<span className="shrink-0 text-[10px] text-slate-400">{formatDateTime(r.att.createdAt)}</span>
{isPreviewable(r.att.fileName) && (
<button type="button" onClick={() => setPreviewAtt(r.att)} className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50" title="Xem trước">
<Eye className="h-3 w-3" />
</button>
)}
<button type="button" onClick={() => download(r.att)} className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50" title="Tải xuống">
<Download className="h-3 w-3" />
</button>
{!readOnly && (
<button
type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
className="text-xs text-red-600 hover:underline"
onClick={() => { if (window.confirm(`Xóa file "${r.att.fileName}"?`)) deleteAttachment.mutate(r.att.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa file"
>
Xóa
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</li>
) : (
/* Căn cứ ĐÃ KHAI mà chưa có file — vẫn phải hiện, không thì khai xong nó
biến mất và người dùng tưởng bấm hụt. */
<div key={r.item.id} className="flex items-center gap-1.5 rounded border border-dashed border-slate-200 bg-white px-1.5 py-1 text-[11px]">
<ClipboardList className="h-3 w-3 shrink-0 text-violet-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-slate-600" title={r.item.name}>{r.item.name}</span>
<span className="shrink-0 rounded bg-violet-100 px-1 text-[9px] font-medium text-violet-700">
{DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ'}
</span>
<span className={cn('shrink-0 rounded border px-1 text-[9px] font-medium', DOSSIER_STATUS_BADGE[r.item.status])}>
{DOSSIER_STATUS_LABELS[r.item.status]}
</span>
<span className="shrink-0 text-[10px] italic text-slate-400">chưa file</span>
{isDraft && (
<>
<label title="Đính kèm file cho căn cứ này" className="shrink-0 cursor-pointer rounded px-1 text-teal-600 hover:bg-teal-50">
<Upload className="h-3 w-3" />
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e, r.item.id)} />
</label>
<button
type="button"
onClick={() => setItemForm({
id: r.item.id, kind: r.item.kind, supplierId: r.item.supplierId, name: r.item.name,
status: r.item.status, tvgsName: r.item.tvgsName ?? '', note: r.item.note ?? '',
})}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Sửa căn cứ"
>
<Pencil className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => { if (window.confirm(`Xóa căn cứ "${r.item.name}"?`)) deleteItem.mutate(r.item.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa căn cứ"
>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
</div>
)
))}
</ul>
</div>
)}
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</Section>
</div>

View File

@ -18,11 +18,14 @@
// Không dựng workspace riêng cho việc này.
// [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`) — đó là code chết ngay ngày đầu.
import { Fragment, useState, type ReactNode } from 'react'
import { useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClipboardList, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
ClipboardList, Download, Eye, FileCheck, Lock, Paperclip, Pencil, Plus, Save, Trash2, Upload, Users,
} from 'lucide-react'
// [S175] Dùng LẠI dialog xem-trước của PE (đã có prop `basePath` từ đợt B) thay vì dựng bản
// thứ hai cho KHKK — 2 bản = 2 sự thật, sửa 1 chỗ quên chỗ kia.
import { AttachmentPreviewDialog, isPreviewable } from '@/components/pe/AttachmentPreviewDialog'
import { toast } from 'sonner'
import { PePipelineStrip } from '@/components/pe/PePipelineStrip'
import { Button } from '@/components/ui/Button'
@ -158,6 +161,13 @@ export function KhkkDetailContent({
const [ghiChu, setGhiChu] = useState('')
const [hoSoLink, setHoSoLink] = useState('')
const [itemForm, setItemForm] = useState<UpsertKhkkDossierItemInput | null>(null)
// [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp.
const [previewAtt, setPreviewAtt] = useState<KhkkAttachmentDto | null>(null)
// [S175] NCC đang mở form "thêm hạng mục" (null = không mở form nào).
const [addFor, setAddFor] = useState<{ supplierId: string; supplierName: string | null } | null>(null)
const [addCatalogId, setAddCatalogId] = useState('')
const [addAmount, setAddAmount] = useState('')
const [addNote, setAddNote] = useState('')
const phase = plan.phase as KhkkPhaseValue
const draftPhase = phase === KhkkPhase.DangSoanThao || phase === KhkkPhase.TraLai
@ -198,6 +208,32 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)),
})
// [S175 — anh chốt "nhiều hạng mục → giá và ghi chú"] 3 mutation cho DÒNG hạng mục.
// 🔴 Body null-safe: chỉ gửi field ĐANG sửa; field không gửi thì BE giữ nguyên
// (`UpdateContractSigningPlanLineCommand` — khuôn bug-class #73/S42).
const saveLineField = useMutation({
mutationFn: async (p: { lineId: string; proposedAmount?: number; note?: string }) =>
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
proposedAmount: p.proposedAmount,
note: p.note,
}),
onSuccess: () => { toast.success('Đã lưu dòng'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const addLine = useMutation({
mutationFn: async (p: { supplierId: string; catalogEntryId: string; proposedAmount: number; note?: string }) =>
api.post(`/contract-signing-plans/${id}/lines`, p),
onSuccess: () => { toast.success('Đã thêm hạng mục'); setAddFor(null); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteLine = useMutation({
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const upsertItem = useMutation({
mutationFn: async (body: UpsertKhkkDossierItemInput) => {
if (!body.name.trim()) throw new Error('Vui lòng nhập tên căn cứ')
@ -293,6 +329,25 @@ export function KhkkDetailContent({
}
}
// [S175 · anh chốt] GỘP mục 3 (Căn cứ hồ sơ) + mục 4 (File đính kèm) thành MỘT khối
// "File đính kèm" khuôn Duyệt NCC (`PeDetailTabs.tsx:3382-3418`): 1 danh sách PHẲNG,
// mỗi dòng = 1 file có badge cho biết nó thuộc căn cứ nào.
// 🔴 CHỈ gộp phần HIỂN THỊ — máy căn cứ hồ sơ GIỮ NGUYÊN (entity `KhkkDossierItem`,
// 3 endpoint POST/PUT/DELETE dossier-items, changelog). Xoá máy đi để "cho gọn giao diện"
// là mất `kind`/`status`/TVGS — những thứ BE đang ghi và người duyệt đang đọc.
// Căn cứ ĐÃ KHAI mà CHƯA có file vẫn phải hiện 1 dòng, nếu không thì khai xong nó biến mất
// và người dùng tưởng bấm hụt.
type MergedRow =
| { rk: 'file'; att: KhkkAttachmentDto; item: KhkkDossierItemDto | null }
| { rk: 'empty'; item: KhkkDossierItemDto }
const mergedRows: MergedRow[] = []
for (const d of plan.dossierItems) {
const files = dossierAttachments.get(d.id) ?? []
if (files.length === 0) mergedRows.push({ rk: 'empty', item: d })
else for (const a of files) mergedRows.push({ rk: 'file', att: a, item: d })
}
for (const a of planAttachments) mergedRows.push({ rk: 'file', att: a, item: null })
// 🧊 [S174 owner] Section 5 "Ý kiến cấp duyệt" ĐÃ GỠ khỏi panel giữa — anh: "bỏ luôn cái
// này ra luôn". Sơ-đồ Bước/Cấp + chữ ký đã sống ở PANEL 3 (`KhkkWorkflowPanel`), để cả hai
// là kể một chuyện hai lần trên cùng màn hình. Dữ-liệu `plan.levelOpinions` /
@ -525,213 +580,289 @@ export function KhkkDetailContent({
</>
) : orDash(l.tenHangMuc)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{moneyOrDash(l.peReferenceAmount)}</td>
<td className="px-3 py-2 text-right tabular-nums text-brand-800">{moneyOrDash(l.proposedAmount)}</td>
{/* Dòng THÊM TAY có `peReferenceAmount = 0` — BE cố ý KHÔNG copy số tham
chiếu của NCC (đã nằm trọn ở dòng gốc), lặp lại là đếm đôi. Hiện
em-dash mờ thay vì "0 đ" để không đọc nhầm thành "tham chiếu bằng 0". */}
<td className="px-3 py-2 text-right tabular-nums text-slate-600">
{l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)}
</td>
{/* [S175] GIÁ ĐỀ XUẤT nhập tay — anh chốt "nhiều hạng mục → giá và ghi chú".
Lưu khi rời ô (onBlur) và chỉ khi SỐ ĐỔI THẬT, tránh bắn 1 request mỗi
lần click vào rồi click ra. */}
<td className="px-3 py-2 text-right tabular-nums text-brand-800">
{isDraft && !l.contractId ? (
<input
type="number"
min={0}
step={1000}
defaultValue={l.proposedAmount}
disabled={saveLineField.isPending}
aria-label={`Giá đề xuất dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
onBlur={(e) => {
const v = Number(e.target.value)
if (!Number.isFinite(v) || v < 0) { e.target.value = String(l.proposedAmount); return }
if (v !== l.proposedAmount) saveLineField.mutate({ lineId: l.id, proposedAmount: v })
}}
className="h-8 w-full min-w-[7rem] rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums text-brand-800 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
/>
) : moneyOrDash(l.proposedAmount)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-emerald-700">{moneyOrDash(l.approvedAmount)}</td>
<td className="px-3 py-2 text-slate-600">{orDash(l.note)}</td>
<td className="px-3 py-2 text-slate-600">
{isDraft && !l.contractId ? (
<div className="flex items-center gap-1">
<input
type="text"
maxLength={1000}
defaultValue={l.note ?? ''}
placeholder="Ghi chú…"
disabled={saveLineField.isPending}
aria-label={`Ghi chú dòng ${l.tenHangMuc ?? l.supplierName ?? l.id}`}
onBlur={(e) => {
const v = e.target.value.trim()
if (v !== (l.note ?? '')) saveLineField.mutate({ lineId: l.id, note: v })
}}
className="h-8 w-full min-w-[6rem] rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700 focus-visible:border-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30"
/>
{/* Chỉ dòng THÊM TAY mới xoá được. Dòng gốc sinh từ phiếu Duyệt NCC
mang snapshot tham chiếu — BE cũng chặn, đây chỉ là không mời bấm. */}
{l.peReferenceAmount === 0 && (
<button
type="button"
onClick={() => { if (window.confirm(`Xoá hạng mục "${l.tenHangMuc ?? '(chưa gán)'}"?`)) deleteLine.mutate(l.id) }}
disabled={deleteLine.isPending}
title="Xoá hạng mục này"
className="shrink-0 rounded px-1 py-1 text-red-500 hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
) : orDash(l.note)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* [S175 — anh chốt] THÊM HẠNG MỤC cho 1 NCC. 1 NCC được N hạng mục: máy đã cho
phép từ Mig 71 (unique 3 cột `Plan × Supplier × CatalogEntry`), lượt này chỉ
bung UI — đúng khuôn "máy rộng, UI hẹp" anh chốt S164.
🔴 CHỈ liệt kê NCC ĐÃ CÓ trên phiếu: NCC đến từ winner của phiếu Duyệt NCC;
đẻ NCC mới ở đây là ký với nhà thầu chưa ai chọn (BE cũng chặn, 409). */}
{isDraft && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="text-[11px] text-slate-500">Thêm hạng mục cho:</span>
{[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => (
<button
key={l.supplierId}
type="button"
onClick={() => {
setAddFor({ supplierId: l.supplierId, supplierName: l.supplierName })
setAddCatalogId(''); setAddAmount(''); setAddNote('')
}}
className="inline-flex items-center gap-1 rounded border border-brand-300 px-2 py-1 text-[11px] font-medium text-brand-700 transition hover:bg-brand-50"
>
<Plus className="h-3 w-3" /> {l.supplierName ?? '(không rõ NCC)'}
</button>
))}
</div>
)}
{addFor && (
<div className="mt-2 rounded-md border border-brand-200 bg-brand-50/50 p-3">
<div className="mb-2 text-[12px] font-semibold text-brand-800">
Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'}
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-[14rem] flex-1">
<span className="label-eyebrow">Hạng mục</span>
<select
value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)}
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs text-slate-700"
>
<option value=""> Chọn hạng mục </option>
{(catalogOptions.data ?? []).map((c) => (
<option key={c.id} value={c.id}>{c.code} {c.tenVi}</option>
))}
</select>
</label>
<label className="w-[9rem]">
<span className="label-eyebrow">Giá đ xuất</span>
<input
type="number" min={0} step={1000}
value={addAmount}
onChange={(e) => setAddAmount(e.target.value)}
placeholder="0"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-right text-xs tabular-nums"
/>
</label>
<label className="min-w-[10rem] flex-1">
<span className="label-eyebrow">Ghi chú</span>
<input
type="text" maxLength={1000}
value={addNote}
onChange={(e) => setAddNote(e.target.value)}
placeholder="(tùy chọn)"
className="mt-0.5 h-8 w-full rounded-md border border-slate-200 bg-white px-2 text-xs"
/>
</label>
<div className="flex gap-1.5">
<Button
size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
onClick={() => addLine.mutate({
supplierId: addFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
note: addNote.trim() || undefined,
})}
>
{addLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
</div>
</div>
</div>
)}
</>
)}
<div className="mt-3 text-xs text-muted-foreground">
Giá tham chiếu SNAPSHOT tổng báo giá đưc chọn của NCC trên phiếu Duyệt NCC tại thời điểm lập kế hoạch.
{' '}Cột Hạng mục gán ngay tại bảng khi phiếu Nháp/Trả lại mọi dòng phải gán xong mới trình duyệt đưc.
{' '}🔴 Dòng THÊM TAY không giá tham chiếu (hiện &ldquo;&rdquo;) số tham chiếu của cả NCC, đã nằm dòng gốc.
</div>
</Section>
{/* ─────────────── 3 ─────────────── */}
{/* ─────── 3 — GỘP "Căn cứ hồ sơ" + "File đính kèm" (anh chốt S175) ─────── */}
{/* Anh: "file 3+4 -> Hợp thành file đính kèm như của Duyệt NCC nhé".
Khuôn dòng file bê từ PE `PeDetailTabs.tsx:3382-3418`: 1 danh sách PHẲNG, mỗi
dòng có badge cho biết file thuộc căn cứ nào (hoặc "Hồ sơ phiếu" nếu ở mức phiếu).
🔴 Bảng căn cứ 5 cột bị GỠ khỏi màn, nhưng MÁY GIỮ NGUYÊN: `kind`/`status`/TVGS/
ghi-chú vẫn sửa được qua nút ✎ (mở đúng dialog cũ, đủ 5 trường) và vẫn hiện dưới
dạng badge. Gỡ luôn entity để "cho gọn" thì mất dữ-liệu BE đang ghi. */}
<Section
title="3. Căn cứ hồ sơ (mẫu vật liệu / shopdrawing)"
icon={<ClipboardList className="h-4 w-4" />}
accent="var(--color-violet-500)"
head="text-violet-700"
chipBg="var(--color-violet-50)"
chipFg="var(--color-violet-700)"
count={plan.dossierItems.length}
actions={
isDraft ? (
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
</Button>
) : undefined
}
>
{plan.dossierItems.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa khai căn cứ nào.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] table-fixed text-xs">
<colgroup>
<col className="w-[14%]" />
<col className={isDraft ? 'w-[26%]' : 'w-[30%]'} />
<col className="w-[14%]" />
<col className={isDraft ? 'w-[16%]' : 'w-[20%]'} />
<col className={isDraft ? 'w-[18%]' : 'w-[22%]'} />
{isDraft && <col className="w-[12%]" />}
</colgroup>
<thead className="border-b border-slate-200 bg-slate-50/70">
<tr>
<th className="label-eyebrow px-3 py-2 text-left">Loại</th>
<th className="label-eyebrow px-3 py-2 text-left">Tên căn cứ</th>
<th className="label-eyebrow px-3 py-2 text-left">Trạng thái</th>
<th className="label-eyebrow px-3 py-2 text-left">TVGS</th>
<th className="label-eyebrow px-3 py-2 text-left">Ghi chú</th>
{isDraft && <th className="label-eyebrow px-3 py-2 text-right">Thao tác</th>}
</tr>
</thead>
<tbody>
{plan.dossierItems.map((d: KhkkDossierItemDto) => {
// [S175 · O4] File gắn ĐÚNG căn cứ này (shopdrawing, mẫu vật liệu…).
const files = dossierAttachments.get(d.id) ?? []
return (
<Fragment key={d.id}>
<tr className={cn('border-slate-100 hover:bg-slate-50/60', files.length === 0 && 'border-b last:border-0')}>
<td className="px-3 py-2 text-slate-600">{orDash(DOSSIER_KIND_LABELS[d.kind])}</td>
<td className="px-3 py-2 font-medium text-brand-800">{d.name}</td>
<td className="px-3 py-2">
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', DOSSIER_STATUS_BADGE[d.status])}>
{DOSSIER_STATUS_LABELS[d.status]}
</span>
</td>
<td className="px-3 py-2 text-slate-600">
{orDash(d.tvgsName)}
{d.tvgsResultAt && <span className="ml-1 text-[11px] text-slate-400">({formatDateTime(d.tvgsResultAt)})</span>}
</td>
<td className="px-3 py-2 text-slate-600">{orDash(d.note)}</td>
{isDraft && (
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
{/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `<label>` bọc input
ẩn thay vì `<button>`+ref: mỗi dòng cần một ô chọn file riêng,
dùng ref thì phải nuôi N ref cho N dòng. */}
<label
title="Đính kèm file cho căn cứ này (shopdrawing, mẫu vật liệu…)"
className="cursor-pointer rounded px-1.5 py-1 text-teal-600 hover:bg-teal-50"
>
<Upload className="h-3.5 w-3.5" />
<input
type="file"
multiple
className="hidden"
onChange={(e) => onPickFiles(e, d.id)}
/>
</label>
<button
type="button"
onClick={() => setItemForm({
id: d.id, kind: d.kind, supplierId: d.supplierId, name: d.name,
status: d.status, tvgsName: d.tvgsName ?? '', note: d.note ?? '',
})}
className="rounded px-1.5 py-1 text-violet-600 hover:bg-violet-50"
title="Sửa"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => { if (window.confirm(`Xóa căn cứ "${d.name}"?`)) deleteItem.mutate(d.id) }}
className="rounded px-1.5 py-1 text-red-600 hover:bg-red-50"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
)}
</tr>
{/* [S175 · O4] Dòng-con liệt kê file của căn cứ. Hiện CẢ KHI read-only —
người duyệt cần xem được shopdrawing, chỉ không được thêm/xoá. */}
{files.length > 0 && (
<tr className="border-b border-slate-100 last:border-0">
<td colSpan={isDraft ? 6 : 5} className="bg-slate-50/40 px-3 pb-2">
<div className="flex flex-wrap gap-1.5 pl-3">
{files.map((a) => (
<span
key={a.id}
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px]"
>
<Paperclip className="h-3 w-3 shrink-0 text-teal-500" aria-hidden />
<button
type="button"
onClick={() => download(a)}
title={`Tải xuống ${a.fileName} (${formatSize(a.fileSize)})`}
className="max-w-[16rem] truncate text-slate-700 hover:text-brand-700 hover:underline"
>
{a.fileName}
</button>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(a.fileSize)}</span>
{isDraft && (
<button
type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
title="Xóa file"
className="shrink-0 rounded text-red-500 hover:bg-red-50"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)}
</Section>
{/* ─────────────── 4 ─────────────── */}
<Section
title="4. File đính kèm"
title="3. File đính kèm (căn cứ hồ sơ / shopdrawing)"
icon={<Paperclip className="h-4 w-4" />}
accent="var(--color-teal-500)"
head="text-teal-700"
chipBg="var(--color-teal-50)"
chipFg="var(--color-teal-700)"
count={planAttachments.length}
count={mergedRows.length}
actions={
readOnly ? undefined : (
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
<Upload className="mr-2 h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
<input type="file" multiple className="hidden" onChange={onPickFiles} />
</label>
<div className="flex items-center gap-2">
{isDraft && (
<Button variant="outline" size="sm" onClick={() => setItemForm({ ...EMPTY_ITEM })}>
<Plus className="mr-2 h-3.5 w-3.5" /> Thêm căn cứ
</Button>
)}
<label className="inline-flex cursor-pointer items-center rounded-md border border-slate-300 px-2.5 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50">
<Upload className="mr-2 h-3.5 w-3.5" />
{upload.isPending ? 'Đang tải...' : 'Tải file lên'}
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e)} />
</label>
</div>
)
}
>
{planAttachments.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa file đính kèm.</div>
{mergedRows.length === 0 ? (
<div className="text-sm italic text-slate-400">Chưa căn cứ hồ hay file đính kèm nào.</div>
) : (
<ul className="space-y-2">
{planAttachments.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded-lg border border-slate-200 p-2.5 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-brand-800">{a.fileName}</div>
<div className="text-xs text-muted-foreground">{formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}</div>
</div>
<div className="flex shrink-0 gap-2">
<button type="button" onClick={() => download(a)} className="text-xs text-primary hover:underline">Tải xuống</button>
<div className="space-y-1">
{mergedRows.map((r) => (
r.rk === 'file' ? (
<div key={r.att.id} className="flex items-center gap-1.5 rounded bg-slate-50 px-1.5 py-1 text-[11px]">
<Paperclip className="h-3 w-3 shrink-0 text-slate-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-slate-700" title={r.att.fileName}>{r.att.fileName}</span>
<span className="shrink-0 text-[10px] text-slate-400">{formatSize(r.att.fileSize)}</span>
<span className={cn(
'shrink-0 rounded px-1 text-[9px] font-medium',
r.item ? 'bg-violet-100 text-violet-700' : 'bg-slate-200 text-slate-600',
)}>
{r.item ? (DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ') : 'Hồ sơ phiếu'}
</span>
{r.item && (
<span className="hidden max-w-[10rem] shrink-0 truncate text-[10px] text-slate-500 sm:inline" title={r.item.name}>
{r.item.name}
</span>
)}
<span className="shrink-0 text-[10px] text-slate-400">{formatDateTime(r.att.createdAt)}</span>
{isPreviewable(r.att.fileName) && (
<button type="button" onClick={() => setPreviewAtt(r.att)} className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50" title="Xem trước">
<Eye className="h-3 w-3" />
</button>
)}
<button type="button" onClick={() => download(r.att)} className="shrink-0 rounded px-1 text-brand-600 hover:bg-brand-50" title="Tải xuống">
<Download className="h-3 w-3" />
</button>
{!readOnly && (
<button
type="button"
onClick={() => { if (window.confirm(`Xóa file "${a.fileName}"?`)) deleteAttachment.mutate(a.id) }}
className="text-xs text-red-600 hover:underline"
onClick={() => { if (window.confirm(`Xóa file "${r.att.fileName}"?`)) deleteAttachment.mutate(r.att.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa file"
>
Xóa
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</li>
) : (
/* Căn cứ ĐÃ KHAI mà chưa có file — vẫn phải hiện, không thì khai xong nó
biến mất và người dùng tưởng bấm hụt. */
<div key={r.item.id} className="flex items-center gap-1.5 rounded border border-dashed border-slate-200 bg-white px-1.5 py-1 text-[11px]">
<ClipboardList className="h-3 w-3 shrink-0 text-violet-400" aria-hidden />
<span className="min-w-0 flex-1 truncate text-slate-600" title={r.item.name}>{r.item.name}</span>
<span className="shrink-0 rounded bg-violet-100 px-1 text-[9px] font-medium text-violet-700">
{DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ'}
</span>
<span className={cn('shrink-0 rounded border px-1 text-[9px] font-medium', DOSSIER_STATUS_BADGE[r.item.status])}>
{DOSSIER_STATUS_LABELS[r.item.status]}
</span>
<span className="shrink-0 text-[10px] italic text-slate-400">chưa file</span>
{isDraft && (
<>
<label title="Đính kèm file cho căn cứ này" className="shrink-0 cursor-pointer rounded px-1 text-teal-600 hover:bg-teal-50">
<Upload className="h-3 w-3" />
<input type="file" multiple className="hidden" onChange={(e) => onPickFiles(e, r.item.id)} />
</label>
<button
type="button"
onClick={() => setItemForm({
id: r.item.id, kind: r.item.kind, supplierId: r.item.supplierId, name: r.item.name,
status: r.item.status, tvgsName: r.item.tvgsName ?? '', note: r.item.note ?? '',
})}
className="shrink-0 rounded px-1 text-violet-600 hover:bg-violet-50"
title="Sửa căn cứ"
>
<Pencil className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => { if (window.confirm(`Xóa căn cứ "${r.item.name}"?`)) deleteItem.mutate(r.item.id) }}
className="shrink-0 rounded px-1 text-red-500 hover:bg-red-50"
title="Xóa căn cứ"
>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
</div>
)
))}
</ul>
</div>
)}
{previewAtt && (
<AttachmentPreviewDialog
open
basePath="/contract-signing-plans"
evaluationId={plan.id}
attachmentId={previewAtt.id}
fileName={previewAtt.fileName}
onClose={() => setPreviewAtt(null)}
/>
)}
</Section>
</div>

View File

@ -123,7 +123,30 @@ public class ContractSigningPlansController(
public async Task<IActionResult> UpdateLine(
Guid id, Guid lineId, [FromBody] UpdateSigningPlanLineBody body, CancellationToken ct)
{
await mediator.Send(new UpdateContractSigningPlanLineCommand(id, lineId, body.CatalogEntryId), ct);
await mediator.Send(new UpdateContractSigningPlanLineCommand(
id, lineId, body.CatalogEntryId, body.ProposedAmount, body.Note), ct);
return NoContent();
}
/// [S175] Thêm dòng hạng mục. 🔴 Policy `Update` chứ KHÔNG phải `Create`: "tạo" ở module này
/// nghĩa là tạo PHIẾU (khuôn dossier-items `:142`); thêm dòng là SỬA phiếu đang soạn.
/// Gotcha #85 — dùng ĐÚNG key của endpoint, không OR key con.
[HttpPost("{id:guid}/lines")]
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<ActionResult<Guid>> AddLine(
Guid id, [FromBody] AddSigningPlanLineBody body, CancellationToken ct)
{
var lineId = await mediator.Send(new AddContractSigningPlanLineCommand(
id, body.SupplierId, body.CatalogEntryId, body.ProposedAmount, body.Note), ct);
return Ok(lineId);
}
/// [S175] Xoá dòng hạng mục THÊM TAY (handler chặn dòng gốc sinh từ phiếu Duyệt NCC).
[HttpDelete("{id:guid}/lines/{lineId:guid}")]
[Authorize(Policy = "KeHoachKyKet.Update")]
public async Task<IActionResult> DeleteLine(Guid id, Guid lineId, CancellationToken ct)
{
await mediator.Send(new DeleteContractSigningPlanLineCommand(id, lineId), ct);
return NoContent();
}
@ -310,7 +333,19 @@ public class ContractSigningPlansController(
bool? ApplyLevelFinalize = null);
// [S168 K8-blocker] Body gán hạng mục cho dòng — PlanId/LineId lấy từ route (1 nguồn sự thật).
public record UpdateSigningPlanLineBody(Guid CatalogEntryId);
// [S175] +2 field null-safe: `null` = client không gửi ⇒ giữ nguyên. `CatalogEntryId` đổi
// sang nullable ⇒ call-site FE cũ (gửi đúng 1 field) KHÔNG phải sửa.
public record UpdateSigningPlanLineBody(
Guid? CatalogEntryId = null,
decimal? ProposedAmount = null,
string? Note = null);
/// [S175] Thêm dòng hạng mục cho NCC ĐÃ CÓ trên phiếu — anh chốt "nhiều hạng mục → giá và ghi chú".
public record AddSigningPlanLineBody(
Guid SupplierId,
Guid CatalogEntryId,
decimal ProposedAmount,
string? Note = null);
// [K7 S167] Cầu → HĐ. `PlanId` lấy từ route (không nhận trong body — 1 nguồn sự thật).
// `LineIds` nullable ở BODY để client gửi thiếu ra 400 của validator ("chọn ít nhất 1 dòng")

View File

@ -617,10 +617,17 @@ public class UpdateContractSigningPlanDraftCommandHandler(
// auto-gen (`:481-484`) HẸN nhưng chưa từng land (build-order K4b đổi việc sang `?group=`):
// submit-guard vá-5 đã CHẶN line CatalogEntryId=null ⇒ thiếu command này thì MỌI phiếu tạo
// sau K2 kẹt Nháp vĩnh viễn. Dry-run K8 bắt lỗ này trước giờ chạy.
/// [S175 — anh chốt] "Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi."
/// ⇒ 3 field, MỌI field null-safe theo khuôn `UpdateContractSigningPlanDraftCommand:559-563`:
/// `null` = client KHÔNG gửi ⇒ giữ giá trị cũ (bug-class #73/S42 — form rút gọn null-hoá field
/// nó không hiển thị). `CatalogEntryId` đổi từ bắt-buộc sang nullable ⇒ call-site cũ (FE gửi
/// đúng 1 field `catalogEntryId`) KHÔNG phải sửa dòng nào.
public record UpdateContractSigningPlanLineCommand(
Guid PlanId,
Guid LineId,
Guid CatalogEntryId) : IRequest;
Guid? CatalogEntryId = null,
decimal? ProposedAmount = null,
string? Note = null) : IRequest;
public class UpdateContractSigningPlanLineCommandValidator
: AbstractValidator<UpdateContractSigningPlanLineCommand>
@ -629,7 +636,10 @@ public class UpdateContractSigningPlanLineCommandValidator
{
RuleFor(x => x.PlanId).NotEmpty();
RuleFor(x => x.LineId).NotEmpty();
RuleFor(x => x.CatalogEntryId).NotEmpty();
// 🔴 KHÔNG cho giá ÂM. Khác `AdjustmentAmount` của ngân sách (cái đó cho âm vì là
// "hiệu chỉnh tăng GIẢM"); đây là GIÁ ĐỀ XUẤT của một hạng mục — âm là vô nghĩa.
RuleFor(x => x.ProposedAmount).GreaterThanOrEqualTo(0).When(x => x.ProposedAmount.HasValue);
RuleFor(x => x.Note).MaximumLength(1000);
}
}
@ -650,23 +660,55 @@ public class UpdateContractSigningPlanLineCommandHandler(
?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);
// [gate-lineeditor F-6] Rào BE thật (FE khoá chỉ là hiển thị): dòng đã bắc cầu HĐ
// thì hạng mục bất biến — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh.
// thì BẤT BIẾN — đổi sau khi có HĐ = đổi lịch sử pháp lý của HĐ đã sinh.
// 🔴 [S175] Rào này nay che CẢ TIỀN, không chỉ hạng mục: `ProposedAmount` là số đi vào
// HĐ, sửa sau khi HĐ sinh cũng là viết lại lịch sử.
if (line.ContractId != null)
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không đổi hạng mục được nữa.");
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không sửa được nữa.");
// Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API):
// hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu.
var entry = await db.ContractCatalogEntries.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct)
?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId);
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
throw new ConflictException(
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
var changes = new List<string>();
line.CatalogEntryId = entry.Id;
// Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`):
// danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
line.TenHangMuc = entry.TenVi;
if (request.CatalogEntryId is Guid catId)
{
// Fail-fast tầng-gán (submit-guard vẫn là chốt CUỐI chống data cũ/lách API):
// hạng mục phải CÒN SỐNG + đang hoạt động + ĐÚNG nhóm duyệt của phiếu.
var entry = await db.ContractCatalogEntries.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == catId, ct)
?? throw new NotFoundException("ContractCatalogEntry", catId);
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
throw new ConflictException(
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
// 🔴 [S175] Đổi hạng mục có thể ĐỤNG UNIQUE (PlanId, SupplierId, CatalogEntryId):
// NCC nay được N dòng ⇒ đổi dòng này trùng hạng mục dòng khác CÙNG NCC là có thật.
// Bắt TRƯỚC khi SaveChanges để trả 409 đọc được, thay vì 500 unique-violation.
if (plan.Lines.Any(l => l.Id != line.Id && l.SupplierId == line.SupplierId && l.CatalogEntryId == catId))
throw new ConflictException(
$"NCC này đã có dòng cho hạng mục '{entry.TenVi}' — mỗi cặp NCC × hạng mục chỉ 1 dòng.");
line.CatalogEntryId = entry.Id;
// Denorm tên TẠI THỜI ĐIỂM GÁN (khuôn `SignedByFullName` Mig 26 — entity `:25-27`):
// danh mục đổi tên sau KHÔNG viết lại lịch sử phiếu.
line.TenHangMuc = entry.TenVi;
changes.Add($"hạng mục → '{entry.Code} — {entry.TenVi}'");
}
// [S175] Ghi changelog theo cặp CŨ→MỚI: đây là TIỀN, người duyệt phải truy được ai
// đổi từ bao nhiêu sang bao nhiêu, không chỉ biết "có sửa".
if (request.ProposedAmount is decimal amt && amt != line.ProposedAmount)
{
changes.Add($"giá đề xuất {line.ProposedAmount:N0} → {amt:N0} đ");
line.ProposedAmount = amt;
}
if (request.Note != null)
{
// Chuỗi rỗng = client CHỦ ĐỘNG xoá ⇒ null (khuôn `HoSoLink`).
var note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
if (note != line.Note) { changes.Add("ghi chú"); line.Note = note; }
}
if (changes.Count == 0) return; // không có gì đổi ⇒ đừng đẻ changelog rỗng
// [gate-lineeditor F-1] Phiếu liên danh N dòng ⇒ nhật ký PHẢI trỏ được DÒNG NÀO:
// EntityId = line.Id (quy ước mọi changelog thực-thể-con của file) + tên NCC vào Summary.
@ -684,7 +726,159 @@ public class UpdateContractSigningPlanLineCommandHandler(
PhaseAtChange = plan.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Gán hạng mục '{entry.Code} — {entry.TenVi}' cho dòng NCC {supplierName ?? "(không tên)"}",
Summary = $"Sửa dòng NCC {supplierName ?? "(không tên)"}: {string.Join(" · ", changes)}",
});
await db.SaveChangesAsync(ct);
}
}
// ===================== [S175] Thêm / xoá DÒNG hạng mục =============================
// Anh chốt: "Chỉ thêm hạng mục → nhiều hạng mục → giá và ghi chú, vậy thôi."
// 🔴 0 MIGRATION: unique `(PlanId, SupplierId, CatalogEntryId)` filter `IsDeleted=0` đã có
// từ Mig 71 ⇒ 1 NCC × N hạng mục **máy đã cho phép từ lâu**, chỉ UI khoá 1-1. Đây đúng khuôn
// "máy rộng, UI hẹp" anh chốt S164 — nay bung UI, KHÔNG đụng máy.
public record AddContractSigningPlanLineCommand(
Guid PlanId,
Guid SupplierId,
Guid CatalogEntryId,
decimal ProposedAmount,
string? Note = null) : IRequest<Guid>;
public class AddContractSigningPlanLineCommandValidator
: AbstractValidator<AddContractSigningPlanLineCommand>
{
public AddContractSigningPlanLineCommandValidator()
{
RuleFor(x => x.PlanId).NotEmpty();
RuleFor(x => x.SupplierId).NotEmpty();
// 🔴 BẮT BUỘC, khác đường auto-sinh (dòng gốc để null có chủ đích): unique index CỐ Ý
// KHÔNG lọc `CatalogEntryId IS NOT NULL` (`ContractSigningPlanLineConfiguration.cs:35-38`)
// ⇒ thêm dòng NULL sẽ đụng ngay dòng gốc của chính NCC đó.
RuleFor(x => x.CatalogEntryId).NotEmpty();
RuleFor(x => x.ProposedAmount).GreaterThanOrEqualTo(0);
RuleFor(x => x.Note).MaximumLength(1000);
}
}
public class AddContractSigningPlanLineCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<AddContractSigningPlanLineCommand, Guid>
{
public async Task<Guid> Handle(AddContractSigningPlanLineCommand request, CancellationToken ct)
{
var plan = await db.ContractSigningPlans
.Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
// 🔴 NCC phải ĐÃ CÓ trên phiếu. NCC đến từ winner của phiếu Duyệt NCC
// (`ContractSigningPlanFeatures.cs:508-531`); cho đẻ NCC mới ở đây = lách cả vòng
// duyệt NCC, ký hợp đồng với nhà thầu chưa ai chọn.
if (!plan.Lines.Any(l => l.SupplierId == request.SupplierId))
throw new ConflictException(
"NCC không thuộc kế hoạch này — chỉ thêm hạng mục cho NCC đã trúng thầu trên phiếu Duyệt NCC.");
var entry = await db.ContractCatalogEntries.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == request.CatalogEntryId, ct)
?? throw new NotFoundException("ContractCatalogEntry", request.CatalogEntryId);
if (!entry.IsActive || entry.ApprovalGroup != plan.ApprovalGroup)
throw new ConflictException(
$"Hạng mục không hợp lệ — phải đang hoạt động và thuộc nhóm duyệt N{plan.ApprovalGroup} của phiếu.");
// Bắt trùng TRƯỚC SaveChanges ⇒ 409 đọc được thay vì 500 unique-violation.
if (plan.Lines.Any(l => l.SupplierId == request.SupplierId && l.CatalogEntryId == entry.Id))
throw new ConflictException(
$"NCC này đã có dòng cho hạng mục '{entry.TenVi}' — mỗi cặp NCC × hạng mục chỉ 1 dòng.");
var line = new ContractSigningPlanLine
{
ContractSigningPlanId = plan.Id,
SupplierId = request.SupplierId,
CatalogEntryId = entry.Id,
TenHangMuc = entry.TenVi,
// 🔴 = 0, KHÔNG copy số tham chiếu của NCC. `PeReferenceAmount` là Σ báo giá ĐƯỢC
// CHỌN của CẢ NCC đó trên phiếu PE, đã nằm trọn ở dòng gốc (`:512-515`). Lặp lại ở
// dòng thêm tay = ĐẾM ĐÔI tiền tham chiếu của dự án. Nó cũng là DẤU phân biệt
// dòng-gốc ⟂ dòng-thêm-tay mà `Delete` dựa vào để chặn xoá nhầm.
PeReferenceAmount = 0m,
ProposedAmount = request.ProposedAmount,
Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim(),
};
db.ContractSigningPlanLines.Add(line);
var supplierName = await db.Suppliers.AsNoTracking()
.Where(x => x.Id == request.SupplierId)
.Select(x => x.Name)
.FirstOrDefaultAsync(ct);
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
{
ContractSigningPlanId = plan.Id,
EntityType = ContractSigningPlanEntityType.Line,
EntityId = line.Id,
Action = ChangelogAction.Insert,
PhaseAtChange = plan.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Thêm hạng mục '{entry.Code} — {entry.TenVi}' cho NCC {supplierName ?? "(không tên)"} — "
+ $"giá đề xuất {request.ProposedAmount:N0} đ",
});
await db.SaveChangesAsync(ct);
return line.Id;
}
}
public record DeleteContractSigningPlanLineCommand(Guid PlanId, Guid LineId) : IRequest;
public class DeleteContractSigningPlanLineCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<DeleteContractSigningPlanLineCommand>
{
public async Task Handle(DeleteContractSigningPlanLineCommand request, CancellationToken ct)
{
var plan = await db.ContractSigningPlans
.Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.Id == request.PlanId, ct)
?? throw new NotFoundException("ContractSigningPlan", request.PlanId);
ContractSigningPlanScope.EnsureDraftEditable(plan, currentUser);
var line = plan.Lines.FirstOrDefault(l => l.Id == request.LineId)
?? throw new NotFoundException("ContractSigningPlanLine", request.LineId);
if (line.ContractId != null)
throw new ConflictException("Dòng đã được đưa vào hợp đồng — không xoá được.");
// 🔴 CHỈ xoá được dòng THÊM TAY. Dòng gốc (`PeReferenceAmount != 0`) sinh từ winner của
// phiếu Duyệt NCC và mang SNAPSHOT tham chiếu — xoá là mất số gốc, và mất luôn dấu vết
// NCC đó từng trúng thầu. Muốn bỏ NCC thì sửa ở phiếu PE, không phải xoá lén ở đây.
if (line.PeReferenceAmount != 0m)
throw new ConflictException(
"Đây là dòng gốc sinh từ phiếu Duyệt NCC — không xoá được. Chỉ xoá được hạng mục thêm tay.");
var supplierName = await db.Suppliers.AsNoTracking()
.Where(x => x.Id == line.SupplierId)
.Select(x => x.Name)
.FirstOrDefaultAsync(ct);
var tenHangMuc = line.TenHangMuc ?? "(chưa gán hạng mục)";
db.ContractSigningPlanLines.Remove(line);
db.ContractSigningPlanChangelogs.Add(new ContractSigningPlanChangelog
{
ContractSigningPlanId = plan.Id,
EntityType = ContractSigningPlanEntityType.Line,
EntityId = line.Id,
Action = ChangelogAction.Delete,
PhaseAtChange = plan.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Xoá hạng mục '{tenHangMuc}' của NCC {supplierName ?? "(không tên)"}",
});
await db.SaveChangesAsync(ct);