From b811e1125d3409d175e8b80d55441372b935fe03 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Wed, 5 Aug 2026 20:54:15 +0700 Subject: [PATCH] [CLAUDE] Contract: KHKK them N hang muc (gia+ghi chu) + gop muc 3-4 file dinh kem (0-mig) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...-muc-nhieu-dong-va-cot-du-an-05-08-2026.md | 86 ++++ .../src/components/khkk/KhkkDetailContent.tsx | 481 +++++++++++------- .../src/components/khkk/KhkkDetailContent.tsx | 481 +++++++++++------- .../ContractSigningPlansController.cs | 39 +- .../ContractSigningPlanFeatures.cs | 228 ++++++++- 5 files changed, 946 insertions(+), 369 deletions(-) create mode 100644 .claude/workflows/runs/2026-08-05-S175-duan-budget-column/spec-hang-muc-nhieu-dong-va-cot-du-an-05-08-2026.md diff --git a/.claude/workflows/runs/2026-08-05-S175-duan-budget-column/spec-hang-muc-nhieu-dong-va-cot-du-an-05-08-2026.md b/.claude/workflows/runs/2026-08-05-S175-duan-budget-column/spec-hang-muc-nhieu-dong-va-cot-du-an-05-08-2026.md new file mode 100644 index 0000000..8c0bd3e --- /dev/null +++ b/.claude/workflows/runs/2026-08-05-S175-duan-budget-column/spec-hang-muc-nhieu-dong-va-cot-du-an-05-08-2026.md @@ -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? diff --git a/fe-admin/src/components/khkk/KhkkDetailContent.tsx b/fe-admin/src/components/khkk/KhkkDetailContent.tsx index ba32896..b1cdcbc 100644 --- a/fe-admin/src/components/khkk/KhkkDetailContent.tsx +++ b/fe-admin/src/components/khkk/KhkkDetailContent.tsx @@ -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(null) + // [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp. + const [previewAtt, setPreviewAtt] = useState(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)} - {moneyOrDash(l.peReferenceAmount)} - {moneyOrDash(l.proposedAmount)} + {/* 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". */} + + {l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)} + + {/* [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. */} + + {isDraft && !l.contractId ? ( + { + 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)} + {moneyOrDash(l.approvedAmount)} - {orDash(l.note)} + + {isDraft && !l.contractId ? ( +
+ { + 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 && ( + + )} +
+ ) : orDash(l.note)} + ))} + + {/* [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 && ( +
+ Thêm hạng mục cho: + {[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => ( + + ))} +
+ )} + + {addFor && ( +
+
+ Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'} +
+
+ + + +
+ + +
+
+
+ )} )}
Giá tham chiếu là 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 có giá tham chiếu (hiện “—”) — số tham chiếu là của cả NCC, đã nằm ở dòng gốc.
- {/* ─────────────── 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. */}
} - 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 ? ( - - ) : undefined - } - > - {plan.dossierItems.length === 0 ? ( -
Chưa khai căn cứ nào.
- ) : ( -
- - - - - - - - {isDraft && } - - - - - - - - - {isDraft && } - - - - {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 ( - - - - - - - - {isDraft && ( - - )} - - {/* [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 && ( - - - - )} - - ) - })} - -
LoạiTên căn cứTrạng tháiTVGSGhi chúThao tác
{orDash(DOSSIER_KIND_LABELS[d.kind])}{d.name} - - {DOSSIER_STATUS_LABELS[d.status]} - - - {orDash(d.tvgsName)} - {d.tvgsResultAt && ({formatDateTime(d.tvgsResultAt)})} - {orDash(d.note)} -
- {/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `
-
-
- {files.map((a) => ( - - - - {formatSize(a.fileSize)} - {isDraft && ( - - )} - - ))} -
-
-
- )} -
- - {/* ─────────────── 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 : ( - +
+ {isDraft && ( + + )} + +
) } > - {planAttachments.length === 0 ? ( -
Chưa có file đính kèm.
+ {mergedRows.length === 0 ? ( +
Chưa có căn cứ hồ sơ hay file đính kèm nào.
) : ( -
    - {planAttachments.map((a) => ( -
  • -
    -
    {a.fileName}
    -
    {formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}
    -
    -
    - +
    + {mergedRows.map((r) => ( + r.rk === 'file' ? ( +
    + + {r.att.fileName} + {formatSize(r.att.fileSize)} + + {r.item ? (DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ') : 'Hồ sơ phiếu'} + + {r.item && ( + + {r.item.name} + + )} + {formatDateTime(r.att.createdAt)} + {isPreviewable(r.att.fileName) && ( + + )} + {!readOnly && ( )}
    -
  • + ) : ( + /* 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. */ +
    + + {r.item.name} + + {DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ'} + + + {DOSSIER_STATUS_LABELS[r.item.status]} + + chưa có file + {isDraft && ( + <> + + + + + )} +
    + ) ))} -
+ + )} + {previewAtt && ( + setPreviewAtt(null)} + /> )}
diff --git a/fe-user/src/components/khkk/KhkkDetailContent.tsx b/fe-user/src/components/khkk/KhkkDetailContent.tsx index ba32896..b1cdcbc 100644 --- a/fe-user/src/components/khkk/KhkkDetailContent.tsx +++ b/fe-user/src/components/khkk/KhkkDetailContent.tsx @@ -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(null) + // [S175] File đang xem-trước inline trong khối "File đính kèm" đã gộp. + const [previewAtt, setPreviewAtt] = useState(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)} - {moneyOrDash(l.peReferenceAmount)} - {moneyOrDash(l.proposedAmount)} + {/* 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". */} + + {l.peReferenceAmount === 0 ? DASH : formatVnd(l.peReferenceAmount)} + + {/* [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. */} + + {isDraft && !l.contractId ? ( + { + 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)} + {moneyOrDash(l.approvedAmount)} - {orDash(l.note)} + + {isDraft && !l.contractId ? ( +
+ { + 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 && ( + + )} +
+ ) : orDash(l.note)} + ))} + + {/* [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 && ( +
+ Thêm hạng mục cho: + {[...new Map(plan.lines.map(l => [l.supplierId, l])).values()].map((l) => ( + + ))} +
+ )} + + {addFor && ( +
+
+ Thêm hạng mục cho: {addFor.supplierName ?? '(không rõ NCC)'} +
+
+ + + +
+ + +
+
+
+ )} )}
Giá tham chiếu là 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 có giá tham chiếu (hiện “—”) — số tham chiếu là của cả NCC, đã nằm ở dòng gốc.
- {/* ─────────────── 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. */}
} - 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 ? ( - - ) : undefined - } - > - {plan.dossierItems.length === 0 ? ( -
Chưa khai căn cứ nào.
- ) : ( -
- - - - - - - - {isDraft && } - - - - - - - - - {isDraft && } - - - - {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 ( - - - - - - - - {isDraft && ( - - )} - - {/* [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 && ( - - - - )} - - ) - })} - -
LoạiTên căn cứTrạng tháiTVGSGhi chúThao tác
{orDash(DOSSIER_KIND_LABELS[d.kind])}{d.name} - - {DOSSIER_STATUS_LABELS[d.status]} - - - {orDash(d.tvgsName)} - {d.tvgsResultAt && ({formatDateTime(d.tvgsResultAt)})} - {orDash(d.note)} -
- {/* [S175 · O4] Đính kèm cho ĐÚNG căn cứ này. `
-
-
- {files.map((a) => ( - - - - {formatSize(a.fileSize)} - {isDraft && ( - - )} - - ))} -
-
-
- )} -
- - {/* ─────────────── 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 : ( - +
+ {isDraft && ( + + )} + +
) } > - {planAttachments.length === 0 ? ( -
Chưa có file đính kèm.
+ {mergedRows.length === 0 ? ( +
Chưa có căn cứ hồ sơ hay file đính kèm nào.
) : ( -
    - {planAttachments.map((a) => ( -
  • -
    -
    {a.fileName}
    -
    {formatSize(a.fileSize)} · {formatDateTime(a.createdAt)}
    -
    -
    - +
    + {mergedRows.map((r) => ( + r.rk === 'file' ? ( +
    + + {r.att.fileName} + {formatSize(r.att.fileSize)} + + {r.item ? (DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ') : 'Hồ sơ phiếu'} + + {r.item && ( + + {r.item.name} + + )} + {formatDateTime(r.att.createdAt)} + {isPreviewable(r.att.fileName) && ( + + )} + {!readOnly && ( )}
    -
  • + ) : ( + /* 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. */ +
    + + {r.item.name} + + {DOSSIER_KIND_LABELS[r.item.kind] ?? 'Căn cứ'} + + + {DOSSIER_STATUS_LABELS[r.item.status]} + + chưa có file + {isDraft && ( + <> + + + + + )} +
    + ) ))} -
+ + )} + {previewAtt && ( + setPreviewAtt(null)} + /> )}
diff --git a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs index 326a73e..0b66712 100644 --- a/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/ContractSigningPlansController.cs @@ -123,7 +123,30 @@ public class ContractSigningPlansController( public async Task 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> 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 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") diff --git a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs index eb9de85..8c49a8d 100644 --- a/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs +++ b/src/Backend/SolutionErp.Application/ContractSigningPlans/ContractSigningPlanFeatures.cs @@ -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 @@ -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(); - 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 rõ tên)"}", + Summary = $"Sửa dòng NCC {supplierName ?? "(không rõ 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; + +public class AddContractSigningPlanLineCommandValidator + : AbstractValidator +{ + 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 +{ + public async Task 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 rõ 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 +{ + 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 rõ tên)"}", }); await db.SaveChangesAsync(ct);