Compare commits

...

2 Commits

Author SHA1 Message Date
65f73a96c9 [CLAUDE] FE-Admin: KHKK an dong goc chua gan hang muc, TONG = tong hang muc that (FE-only, 0-mig)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m48s
Anh chot qua 2 cau hoi:
  - dong goc: "Van sinh, nhung AN + khong tinh TONG"  (khong xoa)
  - TONG:     "TONG = tong hang muc that"
Va truoc do: "luc moi khoi tao chi can 1 dong nay thoi" (= dong nhap).

DONG GOC la gi (doc BE truoc khi dung):
  Features.cs:526 tu sinh 1 dong cho MOI NCC trung thau, mang snapshot PeReferenceAmount
  = tong bao gia duoc chon ben phieu PE, va CatalogEntryId = NULL CO CHU DICH (luc tao
  chua biet se ky theo hang muc SP-002 nao). Khong phai dong rac — la dong CHO DIEN.

Da lam (FE-only, 0 migration, 0 file BE):
  - An dong goc CHUA GAN khoi bang + khoi TONG. Gan hang muc cho no thi no HIEN LAI va
    vao TONG nhu dong thuong. STT danh theo danh sach DA LOC (dung index mang goc la
    thung so ngay dong dau).
  - Dong nhap TU MO khi con NCC chua co dong nao hien => phieu vua tao hien dung 1 dong
    nhap nhu anh muon. Gan xong 1 hang muc thi tu dong.
  - O chon NCC CHI hien khi that su khong suy ra duoc (>=2 NCC chua voi toi). Phieu 1 NCC
    khong thay o nay.
  - Nut "Huy" chi hien khi nguoi dung tu bam `+`. Luc dong nhap TU MO thi Huy la nut chet
    (bam xong effectiveAddFor tinh lai va mo y nhu cu) — bay nut khong lam duoc gi con te
    hon khong bay.

🔴 HE QUA DAY CHUYEN TU BAT DUOC — an thoi la KHONG DU:
  ContractSigningPlanWorkflowService.cs:176 chan trinh duyet khi con BAT KY dong nao
  CatalogEntryId == null. An dong goc di thi nguoi soan khong con o nao de gan
  => PHIEU KET VINH VIEN, khong trinh duyet duoc. Grep consumer moi loi ra (dong :1066).
  Cach ra KHONG can migration, KHONG can noi rao xoa cua BE:
    hang muc DAU TIEN cua mot NCC => GAN THANG vao dong goc cua NCC do (PUT), thay vi de
    dong moi (POST). Dung vong doi ma Features.cs:530-534 thiet ke ("moi NULL, nguoi soan
    gan sau"). Dong goc het NULL => hien ra thanh dong 1 => submit-guard thong.
  Cau chan cung da viet lai: cau cu "con N dong chua gan hang muc" tro vao dong KHONG CO
  tren man => doi thanh "con N nha cung cap chua co hang muc nao — them hang muc cho ho".

🔴 BAT BIEN TIEN GIU NGUYEN: tong PeReferenceAmount KHONG doi (dong goc van giu so PE cua
  no, chi doi ProposedAmount) => BudgetManualAmount cua HD sinh sau
  (CreateContractFromSigningPlanFeatures.cs:200) van dung bang so PE. Day chinh la ly do
  KHONG chon huong xoa dong goc, va cung la ly do khong phai dung toi rao BE Features.cs:875.

Do duoc:
  - tsc -b exit 0 · build 2 app exit 0 · eslint file nay exit 0 · SHA256 2 app IDENTICAL.
  - dist 2 app chua MARKER moi 'chua co hang muc nao' = 1/1 (build 700ms la do cache Vite,
    da doi chung file dist vua ghi trong 3 phut => output that, khong phai ban cu).
  - CONTROL DUONG '— Chon hang muc —' = 2/2 ca 2 dist => phep do dang chay.

Diff CHI 2 file FE, 0 file BE => .NET test khong the bi anh huong (khai ro).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:54:54 +07:00
0367dafae8 wal: flush 20260806T2237 2026-08-06 22:37:50 +07:00
3 changed files with 249 additions and 30 deletions

View File

@ -6,7 +6,7 @@ goal: cửa-sổ 6 (S178) — UAT màn KHKK chạy tiếp: **đợt-7 `99370de`
chain: chain:
- [x] nối mạch /tiep @S178: Sàn-3 **2/5** tín-hiệu MẠNH kêu (① 2 orphan-run · ⑤ ORPHAN-L) · tick 51→52 · **3 LỆCH** ground-truth (residual đã bị hook commit · cửa thư tái-dựng ĐƯỢC · ORPHAN-L lệch -1 CỐ-HỮU do PAUSE-3 kép) · `_tiep-5.md` cân sổ - [x] nối mạch /tiep @S178: Sàn-3 **2/5** tín-hiệu MẠNH kêu (① 2 orphan-run · ⑤ ORPHAN-L) · tick 51→52 · **3 LỆCH** ground-truth (residual đã bị hook commit · cửa thư tái-dựng ĐƯỢC · ORPHAN-L lệch -1 CỐ-HỮU do PAUSE-3 kép) · `_tiep-5.md` cân sổ
- [x] 🧊 **đợt-1..6 cửa-sổ 5 NÉN thành con-trỏ** (đã ship + 6/6 deploy VERIFIED, chi-tiết sống ở `_context-s-11.md` PAUSE-6 mục (1) + 6 commit): `530af22`·`b346b41`·`cd79d1c`·`4704aaf`·`1fa079d`·`ca9208f`. Mốc bundle cuối cửa-sổ 5 = `BG-xXFql`·`DyRJmp0R`. **Mig 72** + backfill 5/5 có mã · test **645/645** · gotcha **#82** vá lệch 2 tầng - [x] 🧊 **đợt-1..6 cửa-sổ 5 NÉN thành con-trỏ** (đã ship + 6/6 deploy VERIFIED, chi-tiết sống ở `_context-s-11.md` PAUSE-6 mục (1) + 6 commit): `530af22`·`b346b41`·`cd79d1c`·`4704aaf`·`1fa079d`·`ca9208f`. Mốc bundle cuối cửa-sổ 5 = `BG-xXFql`·`DyRJmp0R`. **Mig 72** + backfill 5/5 có mã · test **645/645** · gotcha **#82** vá lệch 2 tầng
- [x] **đợt-7 `99370de`** (S178 — anh: *"giữ lại chỗ chọn hạng mục - số tiền - thêm hủy thôi, bỏ luôn chỗ ghi chú luôn"*): GỠ ô Ghi chú CẢ 2 chỗ (dòng đã có + dòng thêm) · dọn `addNote` + `note` khỏi 2 mutation · vá chú-thích `:556` trỏ vào chính ô vừa gỡ. 🔴 **SUÝT DÍNH BẪY CHUỖI DÙNG CHUNG LẦN 4**: `Ghi chú (tùy chọn)` ở dist = 2 KHÔNG phải gỡ hỏng — `PeWorkflowPanel:869` + `HardCopiesPage:371` cùng dùng; chuỗi phân biệt KHKK là bản **CÓ dấu `…`**. Nguồn 2→0 · dist 0/0 · control dương 2/2. Thao tác append-theo-STT anh mô tả ĐÃ đúng sẵn (`onSuccess setAddFor(null)`+`invalidate`, BE `OrderBy(CreatedAt)`) - [x] **đợt-7 `99370de`** (S178 — anh: *"giữ lại chỗ chọn hạng mục - số tiền - thêm hủy thôi, bỏ luôn chỗ ghi chú luôn"*): GỠ ô Ghi chú CẢ 2 chỗ (dòng đã có + dòng thêm) · dọn `addNote` + `note` khỏi 2 mutation · vá chú-thích `:556` trỏ vào chính ô vừa gỡ. 🔴 **SUÝT DÍNH BẪY CHUỖI DÙNG CHUNG LẦN 4**: `Ghi chú (tùy chọn)` ở dist = 2 KHÔNG phải gỡ hỏng — `PeWorkflowPanel:869` + `HardCopiesPage:371` cùng dùng; chuỗi phân biệt KHKK là bản **CÓ dấu `…`**. Nguồn 2→0 · dist 0/0 · control dương 2/2. Thao tác append-theo-STT anh mô tả ĐÃ đúng sẵn (`onSuccess setAddFor(null)`+`invalidate`, BE `OrderBy(CreatedAt)`). ✅ **DEPLOY VERIFIED 2-vế ×2 app** `CZzRTDJS`·`D1cL-UbV`: KHKK-placeholder 0/0 · control dương 2/2 ×2 phép · bundle 1,79MB/1,71MB (KHÔNG phải 900B SPA-fallback)
- [x] 🔴 **4 phép đo của LEAD hỏng rồi tự bắt + 1 vượt trần sổ**: ① dò thư quét `../AI_INFRA…` (repo không ở đó ⇒ `all=0` là RỖNG-VÌ-HỎI-SAI-CHỖ) · ② `HEAD~1` ≠ commit-trước-của-mình (hook `wal:` chèn giữa ⇒ so bản mới với CHÍNH NÓ) · ③ đọc DLL sai heap + lệch khung UTF-16 + quoting `\"` không phải PowerShell · ④ kỳ-vọng chuỗi=0 sai **3 lần** (`STT`·`Giá đề xuất`=6·`bg-brand-50/40`=12) vì đều DÙNG CHUNG · ⑤ WAL phình 73/40. **CONTROL DƯƠNG cứu cả 4** - [x] 🔴 **4 phép đo của LEAD hỏng rồi tự bắt + 1 vượt trần sổ**: ① dò thư quét `../AI_INFRA…` (repo không ở đó ⇒ `all=0` là RỖNG-VÌ-HỎI-SAI-CHỖ) · ② `HEAD~1` ≠ commit-trước-của-mình (hook `wal:` chèn giữa ⇒ so bản mới với CHÍNH NÓ) · ③ đọc DLL sai heap + lệch khung UTF-16 + quoting `\"` không phải PowerShell · ④ kỳ-vọng chuỗi=0 sai **3 lần** (`STT`·`Giá đề xuất`=6·`bg-brand-50/40`=12) vì đều DÙNG CHUNG · ⑤ WAL phình 73/40. **CONTROL DƯƠNG cứu cả 4**
- [x] `/pause` @PAUSE-6: `_pause-6.md` + `_context` PAUSE-6 (🔴 lead chèn NHẦM ĐẦU FLOW — `_context` là mới-nhất-ở-CUỐI, NGƯỢC `_mind`; đã sửa) + MIND-7 (`ctx-curator` bắt **7 điểm**, vá đủ, máy `dat=11 TRUOT=0`) + nén MIND-1 (51.260B→48.884B) + `_snapshot-1` `e1942a5` - [x] `/pause` @PAUSE-6: `_pause-6.md` + `_context` PAUSE-6 (🔴 lead chèn NHẦM ĐẦU FLOW — `_context` là mới-nhất-ở-CUỐI, NGƯỢC `_mind`; đã sửa) + MIND-7 (`ctx-curator` bắt **7 điểm**, vá đủ, máy `dat=11 TRUOT=0`) + nén MIND-1 (51.260B→48.884B) + `_snapshot-1` `e1942a5`
- [!] 🔴 **residual-write:** `ctx-curator` GHI 2 tệp `.claude/agent-memory/ctx-curator/` (G-015). 🔴 **ĐÃ ĐỔI TRẠNG-THÁI @S178:** hook nuốt vào `ee21056``porcelain` nay RỖNG, giữ/revert giờ cần **revert-commit** chứ không discard nữa (đúng bẫy timing `session-start.md:285`) - [!] 🔴 **residual-write:** `ctx-curator` GHI 2 tệp `.claude/agent-memory/ctx-curator/` (G-015). 🔴 **ĐÃ ĐỔI TRẠNG-THÁI @S178:** hook nuốt vào `ee21056``porcelain` nay RỖNG, giữ/revert giờ cần **revert-commit** chứ không discard nữa (đúng bẫy timing `session-start.md:285`)
@ -26,7 +26,8 @@ chain:
next: vá **SỬA-1** (4 site, ba-chấm → hai-chấm `porcelain` lọc `??`) → SỬA-5a → 2/3/4/5b/5c/6a/6b → re-review next: vá **SỬA-1** (4 site, ba-chấm → hai-chấm `porcelain` lọc `??`) → SỬA-5a → 2/3/4/5b/5c/6a/6b → re-review
verify: verify:
- `curl -s https://admin.solutions.com.vn/ | grep -oE 'index-[A-Za-z0-9_-]+\.js'` → mốc CŨ (trước đợt-7) = `BG-xXFql`·`DyRJmp0R`; dist local của `99370de` = **`C1x59woX`·`Cn2Ctxap`** ⇒ prod khớp 2 hash này = đợt-7 ĐÃ TỚI - `curl -s https://admin.solutions.com.vn/ | grep -oE 'index-[A-Za-z0-9_-]+\.js'` → mốc đợt-7 = **`CZzRTDJS`·`D1cL-UbV`**; khác = đợt SAU đã tới
- 🔴 **BÀI MỚI S178 — hash CI ≠ hash dist LOCAL cùng commit**: lead đoán prod sẽ ra `C1x59woX`·`Cn2Ctxap` (dist local `99370de`), thực tế CI ra `CZzRTDJS`·`D1cL-UbV`. ⇒ **CẤM neo "đã deploy" vào hash-khớp-local**; neo đúng = *(a)* hash ĐỔI so prod cũ **+** *(b)* byte-level nội-dung kèm control dương. Nếu đã neo (a)+(b) thì lệch hash local là VÔ HẠI
- 🔴 đo đợt-7 phải dùng chuỗi **`Ghi chú (tùy chọn)…` CÓ dấu `…`** (đích 0). Bản KHÔNG dấu `…` = **control dương phải CÒN 2** (`PeWorkflowPanel:869`+`HardCopiesPage:371` dùng chung — đừng đọc thành gỡ hỏng) - 🔴 đo đợt-7 phải dùng chuỗi **`Ghi chú (tùy chọn)…` CÓ dấu `…`** (đích 0). Bản KHÔNG dấu `…` = **control dương phải CÒN 2** (`PeWorkflowPanel:869`+`HardCopiesPage:371` dùng chung — đừng đọc thành gỡ hỏng)
- `grep -rn 'origin/main\.\.\.HEAD' .claude/agents/ | wc -l`**4** = SỬA-1 CHƯA vá (đích: 0) - `grep -rn 'origin/main\.\.\.HEAD' .claude/agents/ | wc -l`**4** = SỬA-1 CHƯA vá (đích: 0)
- `ls -d .claude/agent-memory/*-reviewer-style 2>/dev/null | wc -l`**0** = SỬA-5a chưa làm (đích: 3) - `ls -d .claude/agent-memory/*-reviewer-style 2>/dev/null | wc -l`**0** = SỬA-5a chưa làm (đích: 3)

View File

@ -42,7 +42,7 @@ import {
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput, KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto, type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue, type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput, type UpsertKhkkDossierItemInput,
} from '@/types/khkk' } from '@/types/khkk'
@ -196,6 +196,39 @@ export function KhkkDetailContent({
onChanged?.() onChanged?.()
} }
// ─── [S178 — anh: "lúc mới khởi tạo chỉ cần 1 dòng này thôi"] ───────────────────────
// DÒNG GỐC CHƯA GÁN = dòng hệ thống tự sinh cho mỗi NCC trúng thầu (`Features.cs:526`),
// mang SNAPSHOT `peReferenceAmount` = Σ báo giá được chọn bên phiếu PE, và `catalogEntryId`
// để NULL CÓ CHỦ ĐÍCH (lúc tạo chưa biết sẽ ký theo hạng mục SP-002 nào).
// 🔴 Anh chốt: **VẪN SINH, chỉ ẨN + không tính TỔNG** — KHÔNG xoá. Vì xoá là mất 3 thứ:
// số PE gốc, dấu "NCC này từng trúng thầu", và `BudgetManualAmount` của HĐ sinh ra sau
// (`CreateContractFromSigningPlanFeatures.cs:200` = Σ `PeReferenceAmount` các dòng).
// BE cũng đang chặn cứng việc xoá nó (`Features.cs:875`) — ẩn ở FE nên KHÔNG phải nới rào.
// ⇒ Gán hạng mục cho dòng gốc thì nó HIỆN LẠI và vào TỔNG như dòng thường.
const isUnassignedSeed = (l: KhkkLineDto) => l.peReferenceAmount !== 0 && !l.catalogEntryId
const visibleLines = plan.lines.filter((l) => !isUnassignedSeed(l))
// 🔴 HỆ QUẢ DÂY CHUYỀN phải xử, không thì ẩn xong là KẸT: nút `+` bám vào TỪNG DÒNG (nó lấy
// NCC từ chính dòng đó — anh chốt vậy ở đợt-3 khi bỏ khối "Thêm hạng mục cho: [NCC]").
// Ẩn dòng ⇒ mất luôn nút ⇒ NCC đó KHÔNG CÒN ĐƯỜNG VÀO.
// Điều kiện đúng KHÔNG phải "bảng rỗng" mà là "còn NCC nào chưa có dòng nào HIỆN" — phiếu
// liên-danh nhiều NCC (PE multi-winner, Mig 58/59) mà chỉ xét bảng-rỗng thì NCC thứ 2 mất
// đường vào ngay khi NCC thứ nhất có 1 dòng.
const visibleSupplierIds = new Set(visibleLines.map((l) => l.supplierId))
const unreachableSuppliers = [
...new Map(
plan.lines
.filter((l) => isUnassignedSeed(l) && !visibleSupplierIds.has(l.supplierId))
.map((l) => [l.supplierId, l.supplierName] as const),
).entries(),
]
// Dòng nhập TỰ MỞ khi còn NCC chưa với tới được ⇒ phiếu vừa tạo hiện đúng 1 dòng nhập như
// ảnh anh gửi. Gán xong 1 hạng mục thì NCC đó hết "unreachable" ⇒ dòng nhập tự đóng, quay
// về nếp cũ (bấm `+` trên dòng).
const effectiveAddFor = addFor ?? (isDraft && unreachableSuppliers.length > 0
? { supplierId: unreachableSuppliers[0][0] }
: null)
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa). // Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
const catalogOptions = useQuery({ const catalogOptions = useQuery({
@ -238,6 +271,28 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)), onError: (e) => toast.error(getErrorMessage(e)),
}) })
// ─── [S178] NHẬN dòng gốc thay vì đẻ dòng mới ───────────────────────────────────────
// 🔴 VÌ SAO PHẢI CÓ: ẩn dòng gốc chưa gán (anh chốt) mà không có đường này thì phiếu KẸT
// VĨNH VIỄN — submit-guard BE `ContractSigningPlanWorkflowService.cs:176` chặn trình khi
// còn BẤT KỲ dòng nào `CatalogEntryId == null`, mà dòng đó nay đã bị ẩn nên người soạn
// không còn ô nào để gán. Ẩn ở FE thôi là CHƯA ĐỦ.
// ⇒ Cách ra KHÔNG cần migration, cũng KHÔNG cần nới rào xoá của BE: hạng mục ĐẦU TIÊN của
// một NCC thì GÁN THẲNG vào chính dòng gốc của NCC đó (đúng vòng đời mà `Features.cs:530-534`
// thiết kế: "mồi NULL, người soạn gán sau"). Dòng gốc hết NULL ⇒ hiện ra thành dòng 1 ⇒
// submit-guard thông. Các hạng mục SAU mới đẻ dòng mới (`PeReferenceAmount = 0`).
// 🔴 BẤT BIẾN TIỀN GIỮ NGUYÊN: Σ `PeReferenceAmount` không đổi (dòng gốc vẫn giữ số PE của
// nó) ⇒ `BudgetManualAmount` của HĐ sinh sau (`CreateContractFromSigningPlanFeatures.cs:200`)
// vẫn đúng bằng số PE. Đây là lý do KHÔNG chọn hướng xoá dòng gốc.
const adoptSeedLine = useMutation({
mutationFn: async (p: { lineId: string; catalogEntryId: string; proposedAmount: number }) =>
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
catalogEntryId: p.catalogEntryId,
proposedAmount: p.proposedAmount,
}),
onSuccess: () => { toast.success('Đã thêm hạng mục'); setAddFor(null); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteLine = useMutation({ const deleteLine = useMutation({
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`), mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() }, onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
@ -543,7 +598,10 @@ export function KhkkDetailContent({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{plan.lines.map((l, i) => ( {/* [S178] `visibleLines`, KHÔNG phải `plan.lines`: dòng gốc chưa gán hạng mục
bị ẩn (xem khối tính ở trên). STT vẫn là thứ tự HIỂN THỊ nên đánh theo
danh sách ĐÃ LỌC — dùng index của mảng gốc sẽ thủng số ngay dòng đầu. */}
{visibleLines.map((l, i) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60"> <tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
{/* [S177] STT = thứ tự HIỂN THỊ, không phải id nghiệp vụ. BE đã sắp {/* [S177] STT = thứ tự HIỂN THỊ, không phải id nghiệp vụ. BE đã sắp
`OrderBy(CreatedAt).ThenBy(Id)` nên thứ tự này ổn định giữa 2 lần tải. */} `OrderBy(CreatedAt).ThenBy(Id)` nên thứ tự này ổn định giữa 2 lần tải. */}
@ -662,7 +720,7 @@ export function KhkkDetailContent({
trùng", đúng thứ chú-thích ở đó cấm. trùng", đúng thứ chú-thích ở đó cấm.
⇒ Dòng này là **dòng nháp phía client**: trông và xếp cột y hệt dòng thật, ⇒ Dòng này là **dòng nháp phía client**: trông và xếp cột y hệt dòng thật,
nhưng chỉ gọi API MỘT LẦN khi đã có hạng mục ⇒ máy giữ nguyên bất biến. */} nhưng chỉ gọi API MỘT LẦN khi đã có hạng mục ⇒ máy giữ nguyên bất biến. */}
{isDraft && addFor && ( {isDraft && effectiveAddFor && (
// [S177 — anh: "bỏ cả khối dòng nhập"] Bỏ VẺ KHỐI RIÊNG (nền brand + viền // [S177 — anh: "bỏ cả khối dòng nhập"] Bỏ VẺ KHỐI RIÊNG (nền brand + viền
// brand) — dùng đúng class của dòng dữ liệu thật ở trên, để nó đọc thành // brand) — dùng đúng class của dòng dữ liệu thật ở trên, để nó đọc thành
// "một dòng nữa của bảng" chứ không phải "một hộp lạ chen vào giữa bảng". // "một dòng nữa của bảng" chứ không phải "một hộp lạ chen vào giữa bảng".
@ -680,6 +738,22 @@ export function KhkkDetailContent({
nhớ mình vừa bấm `+` ở dòng nào. Anh đã được nêu đánh đổi này và chốt nhớ mình vừa bấm `+` ở dòng nào. Anh đã được nêu đánh đổi này và chốt
gỡ. Nếu về sau vướng: rẻ nhất là chèn dòng nhập NGAY DƯỚI dòng vừa bấm gỡ. Nếu về sau vướng: rẻ nhất là chèn dòng nhập NGAY DƯỚI dòng vừa bấm
(vị trí tự nói NCC) thay vì thêm lại chữ. */} (vị trí tự nói NCC) thay vì thêm lại chữ. */}
{/* [S178] Ô chọn NCC — CHỈ hiện khi thật sự không suy ra được: còn ≥2 NCC
chưa có dòng nào hiện. Phiếu 1 NCC (ca thường) KHÔNG thấy ô này, đúng
"máy rộng, UI hẹp". Thiếu nó thì phiếu liên-danh nhiều NCC sẽ có NCC
thứ 2 không bao giờ với tới được sau khi dòng gốc bị ẩn. */}
{unreachableSuppliers.length > 1 && !visibleSupplierIds.has(effectiveAddFor.supplierId) && (
<select
value={effectiveAddFor.supplierId}
onChange={(e) => setAddFor({ supplierId: e.target.value })}
aria-label="Nhà cung cấp của dòng mới"
className="mb-1 h-7 w-full rounded-md border border-slate-200 bg-white px-2 text-[11px] text-slate-600 focus-visible:border-brand-500 focus-visible:outline-none"
>
{unreachableSuppliers.map(([sid, sname]) => (
<option key={sid} value={sid}>{sname ?? '(NCC không tên)'}</option>
))}
</select>
)}
<select <select
value={addCatalogId} value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)} onChange={(e) => setAddCatalogId(e.target.value)}
@ -710,16 +784,40 @@ export function KhkkDetailContent({
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button <Button
size="sm" size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending} disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0
onClick={() => addLine.mutate({ || addLine.isPending || adoptSeedLine.isPending}
supplierId: addFor.supplierId, // [S178] 2 đường, chọn theo việc NCC này còn dòng gốc chưa gán hay không:
// còn → GÁN vào dòng gốc (PUT) ⇒ hết dòng NULL ⇒ submit-guard thông
// hết → đẻ dòng mới (POST) như cũ
// Tìm lại dòng gốc ngay tại đây (không cache) vì `plan.lines` đã tươi
// sau mỗi `invalidate()`; giữ biến ngoài dễ trỏ vào dòng vừa bị gán.
onClick={() => {
const seed = plan.lines.find(
(x) => isUnassignedSeed(x) && x.supplierId === effectiveAddFor.supplierId)
if (seed) {
adoptSeedLine.mutate({
lineId: seed.id,
catalogEntryId: addCatalogId, catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount), proposedAmount: Number(addAmount),
})} })
} else {
addLine.mutate({
supplierId: effectiveAddFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
})
}
}}
> >
{addLine.isPending ? 'Đang thêm…' : 'Thêm'} {addLine.isPending || adoptSeedLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button> </Button>
{/* [S178] "Hủy" CHỈ hiện khi dòng nhập do người dùng MỞ (bấm `+`).
Lúc nó TỰ MỞ vì còn NCC chưa với tới được thì Hủy là nút chết —
bấm xong `effectiveAddFor` tính lại và mở y như cũ. Bày một nút
không làm được gì còn tệ hơn không bày. */}
{addFor && (
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button> <Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -743,7 +841,11 @@ export function KhkkDetailContent({
ô hạng mục và con số Tổng rơi lệch một cột so với cột Giá bên trên. */} ô hạng mục và con số Tổng rơi lệch một cột so với cột Giá bên trên. */}
<td colSpan={2} className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td> <td colSpan={2} className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td>
<td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800"> <td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800">
{formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))} {/* [S178 — anh chốt "TỔNG = Σ hạng mục thật"] Cộng theo `visibleLines`:
dòng gốc chưa gán KHÔNG vào TỔNG, nếu không thì con số trên màn không
khớp các dòng nhìn thấy và người dùng sẽ đọc thành lỗi. Người soạn tách
dần hạng mục thì TỔNG bò lên dần tới số PE. */}
{formatVnd(visibleLines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
</td> </td>
<td /> <td />
</tr> </tr>
@ -1004,8 +1106,15 @@ export function KhkkDetailContent({
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
const noWorkflow = !plan.approvalWorkflowId const noWorkflow = !plan.approvalWorkflowId
const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0 const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0
// 🔴 [S178] Câu chặn phải nói THỨ NGƯỜI DÙNG NHÌN THẤY. Dòng gốc chưa gán nay bị
// ẩn (anh chốt), nên câu cũ "còn N dòng chưa gán hạng mục" trỏ vào dòng KHÔNG CÓ
// trên màn ⇒ người dùng đi tìm mãi không ra. Khi mọi dòng chưa-gán đều là dòng gốc
// bị ẩn thì nói theo NCC + đúng thao tác gỡ kẹt (thêm hạng mục cho NCC đó).
const unassignedAllHidden = unassigned > 0
&& plan.lines.filter(l => !l.catalogEntryId).every(isUnassignedSeed)
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.' const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.' : plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
: unassignedAllHidden ? `Còn ${unassigned} nhà cung cấp chưa có hạng mục nào — thêm hạng mục cho họ trước khi trình.`
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.` : unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
: undefined : undefined
return ( return (

View File

@ -42,7 +42,7 @@ import {
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS, KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput, KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto, type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue, type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
type UpsertKhkkDossierItemInput, type UpsertKhkkDossierItemInput,
} from '@/types/khkk' } from '@/types/khkk'
@ -196,6 +196,39 @@ export function KhkkDetailContent({
onChanged?.() onChanged?.()
} }
// ─── [S178 — anh: "lúc mới khởi tạo chỉ cần 1 dòng này thôi"] ───────────────────────
// DÒNG GỐC CHƯA GÁN = dòng hệ thống tự sinh cho mỗi NCC trúng thầu (`Features.cs:526`),
// mang SNAPSHOT `peReferenceAmount` = Σ báo giá được chọn bên phiếu PE, và `catalogEntryId`
// để NULL CÓ CHỦ ĐÍCH (lúc tạo chưa biết sẽ ký theo hạng mục SP-002 nào).
// 🔴 Anh chốt: **VẪN SINH, chỉ ẨN + không tính TỔNG** — KHÔNG xoá. Vì xoá là mất 3 thứ:
// số PE gốc, dấu "NCC này từng trúng thầu", và `BudgetManualAmount` của HĐ sinh ra sau
// (`CreateContractFromSigningPlanFeatures.cs:200` = Σ `PeReferenceAmount` các dòng).
// BE cũng đang chặn cứng việc xoá nó (`Features.cs:875`) — ẩn ở FE nên KHÔNG phải nới rào.
// ⇒ Gán hạng mục cho dòng gốc thì nó HIỆN LẠI và vào TỔNG như dòng thường.
const isUnassignedSeed = (l: KhkkLineDto) => l.peReferenceAmount !== 0 && !l.catalogEntryId
const visibleLines = plan.lines.filter((l) => !isUnassignedSeed(l))
// 🔴 HỆ QUẢ DÂY CHUYỀN phải xử, không thì ẩn xong là KẸT: nút `+` bám vào TỪNG DÒNG (nó lấy
// NCC từ chính dòng đó — anh chốt vậy ở đợt-3 khi bỏ khối "Thêm hạng mục cho: [NCC]").
// Ẩn dòng ⇒ mất luôn nút ⇒ NCC đó KHÔNG CÒN ĐƯỜNG VÀO.
// Điều kiện đúng KHÔNG phải "bảng rỗng" mà là "còn NCC nào chưa có dòng nào HIỆN" — phiếu
// liên-danh nhiều NCC (PE multi-winner, Mig 58/59) mà chỉ xét bảng-rỗng thì NCC thứ 2 mất
// đường vào ngay khi NCC thứ nhất có 1 dòng.
const visibleSupplierIds = new Set(visibleLines.map((l) => l.supplierId))
const unreachableSuppliers = [
...new Map(
plan.lines
.filter((l) => isUnassignedSeed(l) && !visibleSupplierIds.has(l.supplierId))
.map((l) => [l.supplierId, l.supplierName] as const),
).entries(),
]
// Dòng nhập TỰ MỞ khi còn NCC chưa với tới được ⇒ phiếu vừa tạo hiện đúng 1 dòng nhập như
// ảnh anh gửi. Gán xong 1 hạng mục thì NCC đó hết "unreachable" ⇒ dòng nhập tự đóng, quay
// về nếp cũ (bấm `+` trên dòng).
const effectiveAddFor = addFor ?? (isDraft && unreachableSuppliers.length > 0
? { supplierId: unreachableSuppliers[0][0] }
: null)
// Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa). // Danh mục hạng mục SP-002 — chỉ tải khi phiếu còn sửa được (tránh 1 request thừa).
const catalogOptions = useQuery({ const catalogOptions = useQuery({
@ -238,6 +271,28 @@ export function KhkkDetailContent({
onError: (e) => toast.error(getErrorMessage(e)), onError: (e) => toast.error(getErrorMessage(e)),
}) })
// ─── [S178] NHẬN dòng gốc thay vì đẻ dòng mới ───────────────────────────────────────
// 🔴 VÌ SAO PHẢI CÓ: ẩn dòng gốc chưa gán (anh chốt) mà không có đường này thì phiếu KẸT
// VĨNH VIỄN — submit-guard BE `ContractSigningPlanWorkflowService.cs:176` chặn trình khi
// còn BẤT KỲ dòng nào `CatalogEntryId == null`, mà dòng đó nay đã bị ẩn nên người soạn
// không còn ô nào để gán. Ẩn ở FE thôi là CHƯA ĐỦ.
// ⇒ Cách ra KHÔNG cần migration, cũng KHÔNG cần nới rào xoá của BE: hạng mục ĐẦU TIÊN của
// một NCC thì GÁN THẲNG vào chính dòng gốc của NCC đó (đúng vòng đời mà `Features.cs:530-534`
// thiết kế: "mồi NULL, người soạn gán sau"). Dòng gốc hết NULL ⇒ hiện ra thành dòng 1 ⇒
// submit-guard thông. Các hạng mục SAU mới đẻ dòng mới (`PeReferenceAmount = 0`).
// 🔴 BẤT BIẾN TIỀN GIỮ NGUYÊN: Σ `PeReferenceAmount` không đổi (dòng gốc vẫn giữ số PE của
// nó) ⇒ `BudgetManualAmount` của HĐ sinh sau (`CreateContractFromSigningPlanFeatures.cs:200`)
// vẫn đúng bằng số PE. Đây là lý do KHÔNG chọn hướng xoá dòng gốc.
const adoptSeedLine = useMutation({
mutationFn: async (p: { lineId: string; catalogEntryId: string; proposedAmount: number }) =>
api.put(`/contract-signing-plans/${id}/lines/${p.lineId}`, {
catalogEntryId: p.catalogEntryId,
proposedAmount: p.proposedAmount,
}),
onSuccess: () => { toast.success('Đã thêm hạng mục'); setAddFor(null); invalidate() },
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteLine = useMutation({ const deleteLine = useMutation({
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`), mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() }, onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
@ -543,7 +598,10 @@ export function KhkkDetailContent({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{plan.lines.map((l, i) => ( {/* [S178] `visibleLines`, KHÔNG phải `plan.lines`: dòng gốc chưa gán hạng mục
bị ẩn (xem khối tính ở trên). STT vẫn là thứ tự HIỂN THỊ nên đánh theo
danh sách ĐÃ LỌC — dùng index của mảng gốc sẽ thủng số ngay dòng đầu. */}
{visibleLines.map((l, i) => (
<tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60"> <tr key={l.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/60">
{/* [S177] STT = thứ tự HIỂN THỊ, không phải id nghiệp vụ. BE đã sắp {/* [S177] STT = thứ tự HIỂN THỊ, không phải id nghiệp vụ. BE đã sắp
`OrderBy(CreatedAt).ThenBy(Id)` nên thứ tự này ổn định giữa 2 lần tải. */} `OrderBy(CreatedAt).ThenBy(Id)` nên thứ tự này ổn định giữa 2 lần tải. */}
@ -662,7 +720,7 @@ export function KhkkDetailContent({
trùng", đúng thứ chú-thích ở đó cấm. trùng", đúng thứ chú-thích ở đó cấm.
⇒ Dòng này là **dòng nháp phía client**: trông và xếp cột y hệt dòng thật, ⇒ Dòng này là **dòng nháp phía client**: trông và xếp cột y hệt dòng thật,
nhưng chỉ gọi API MỘT LẦN khi đã có hạng mục ⇒ máy giữ nguyên bất biến. */} nhưng chỉ gọi API MỘT LẦN khi đã có hạng mục ⇒ máy giữ nguyên bất biến. */}
{isDraft && addFor && ( {isDraft && effectiveAddFor && (
// [S177 — anh: "bỏ cả khối dòng nhập"] Bỏ VẺ KHỐI RIÊNG (nền brand + viền // [S177 — anh: "bỏ cả khối dòng nhập"] Bỏ VẺ KHỐI RIÊNG (nền brand + viền
// brand) — dùng đúng class của dòng dữ liệu thật ở trên, để nó đọc thành // brand) — dùng đúng class của dòng dữ liệu thật ở trên, để nó đọc thành
// "một dòng nữa của bảng" chứ không phải "một hộp lạ chen vào giữa bảng". // "một dòng nữa của bảng" chứ không phải "một hộp lạ chen vào giữa bảng".
@ -680,6 +738,22 @@ export function KhkkDetailContent({
nhớ mình vừa bấm `+` ở dòng nào. Anh đã được nêu đánh đổi này và chốt nhớ mình vừa bấm `+` ở dòng nào. Anh đã được nêu đánh đổi này và chốt
gỡ. Nếu về sau vướng: rẻ nhất là chèn dòng nhập NGAY DƯỚI dòng vừa bấm gỡ. Nếu về sau vướng: rẻ nhất là chèn dòng nhập NGAY DƯỚI dòng vừa bấm
(vị trí tự nói NCC) thay vì thêm lại chữ. */} (vị trí tự nói NCC) thay vì thêm lại chữ. */}
{/* [S178] Ô chọn NCC — CHỈ hiện khi thật sự không suy ra được: còn ≥2 NCC
chưa có dòng nào hiện. Phiếu 1 NCC (ca thường) KHÔNG thấy ô này, đúng
"máy rộng, UI hẹp". Thiếu nó thì phiếu liên-danh nhiều NCC sẽ có NCC
thứ 2 không bao giờ với tới được sau khi dòng gốc bị ẩn. */}
{unreachableSuppliers.length > 1 && !visibleSupplierIds.has(effectiveAddFor.supplierId) && (
<select
value={effectiveAddFor.supplierId}
onChange={(e) => setAddFor({ supplierId: e.target.value })}
aria-label="Nhà cung cấp của dòng mới"
className="mb-1 h-7 w-full rounded-md border border-slate-200 bg-white px-2 text-[11px] text-slate-600 focus-visible:border-brand-500 focus-visible:outline-none"
>
{unreachableSuppliers.map(([sid, sname]) => (
<option key={sid} value={sid}>{sname ?? '(NCC không tên)'}</option>
))}
</select>
)}
<select <select
value={addCatalogId} value={addCatalogId}
onChange={(e) => setAddCatalogId(e.target.value)} onChange={(e) => setAddCatalogId(e.target.value)}
@ -710,16 +784,40 @@ export function KhkkDetailContent({
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button <Button
size="sm" size="sm"
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending} disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0
onClick={() => addLine.mutate({ || addLine.isPending || adoptSeedLine.isPending}
supplierId: addFor.supplierId, // [S178] 2 đường, chọn theo việc NCC này còn dòng gốc chưa gán hay không:
// còn → GÁN vào dòng gốc (PUT) ⇒ hết dòng NULL ⇒ submit-guard thông
// hết → đẻ dòng mới (POST) như cũ
// Tìm lại dòng gốc ngay tại đây (không cache) vì `plan.lines` đã tươi
// sau mỗi `invalidate()`; giữ biến ngoài dễ trỏ vào dòng vừa bị gán.
onClick={() => {
const seed = plan.lines.find(
(x) => isUnassignedSeed(x) && x.supplierId === effectiveAddFor.supplierId)
if (seed) {
adoptSeedLine.mutate({
lineId: seed.id,
catalogEntryId: addCatalogId, catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount), proposedAmount: Number(addAmount),
})} })
} else {
addLine.mutate({
supplierId: effectiveAddFor.supplierId,
catalogEntryId: addCatalogId,
proposedAmount: Number(addAmount),
})
}
}}
> >
{addLine.isPending ? 'Đang thêm…' : 'Thêm'} {addLine.isPending || adoptSeedLine.isPending ? 'Đang thêm…' : 'Thêm'}
</Button> </Button>
{/* [S178] "Hủy" CHỈ hiện khi dòng nhập do người dùng MỞ (bấm `+`).
Lúc nó TỰ MỞ vì còn NCC chưa với tới được thì Hủy là nút chết —
bấm xong `effectiveAddFor` tính lại và mở y như cũ. Bày một nút
không làm được gì còn tệ hơn không bày. */}
{addFor && (
<Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button> <Button variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</Button>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -743,7 +841,11 @@ export function KhkkDetailContent({
ô hạng mục và con số Tổng rơi lệch một cột so với cột Giá bên trên. */} ô hạng mục và con số Tổng rơi lệch một cột so với cột Giá bên trên. */}
<td colSpan={2} className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td> <td colSpan={2} className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-slate-600">Tổng</td>
<td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800"> <td className="px-3 py-2 text-right text-sm font-bold tabular-nums text-brand-800">
{formatVnd(plan.lines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))} {/* [S178 — anh chốt "TỔNG = Σ hạng mục thật"] Cộng theo `visibleLines`:
dòng gốc chưa gán KHÔNG vào TỔNG, nếu không thì con số trên màn không
khớp các dòng nhìn thấy và người dùng sẽ đọc thành lỗi. Người soạn tách
dần hạng mục thì TỔNG bò lên dần tới số PE. */}
{formatVnd(visibleLines.reduce((s, l) => s + (l.proposedAmount ?? 0), 0))}
</td> </td>
<td /> <td />
</tr> </tr>
@ -1004,8 +1106,15 @@ export function KhkkDetailContent({
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
const noWorkflow = !plan.approvalWorkflowId const noWorkflow = !plan.approvalWorkflowId
const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0 const blocked = noWorkflow || unassigned > 0 || plan.lines.length === 0
// 🔴 [S178] Câu chặn phải nói THỨ NGƯỜI DÙNG NHÌN THẤY. Dòng gốc chưa gán nay bị
// ẩn (anh chốt), nên câu cũ "còn N dòng chưa gán hạng mục" trỏ vào dòng KHÔNG CÓ
// trên màn ⇒ người dùng đi tìm mãi không ra. Khi mọi dòng chưa-gán đều là dòng gốc
// bị ẩn thì nói theo NCC + đúng thao tác gỡ kẹt (thêm hạng mục cho NCC đó).
const unassignedAllHidden = unassigned > 0
&& plan.lines.filter(l => !l.catalogEntryId).every(isUnassignedSeed)
const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.' const why = noWorkflow ? 'Chưa pin quy trình duyệt cho phiếu.'
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.' : plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
: unassignedAllHidden ? `Còn ${unassigned} nhà cung cấp chưa có hạng mục nào — thêm hạng mục cho họ trước khi trình.`
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.` : unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
: undefined : undefined
return ( return (