Compare commits
2 Commits
f622218d83
...
65f73a96c9
| Author | SHA1 | Date | |
|---|---|---|---|
| 65f73a96c9 | |||
| 0367dafae8 |
@ -6,7 +6,7 @@ goal: cửa-sổ 6 (S178) — UAT màn KHKK chạy tiếp: **đợt-7 `99370de`
|
||||
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] 🧊 **đợ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] `/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`)
|
||||
@ -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
|
||||
|
||||
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)
|
||||
- `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)
|
||||
|
||||
@ -42,7 +42,7 @@ import {
|
||||
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||
KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
|
||||
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
|
||||
type UpsertKhkkDossierItemInput,
|
||||
} from '@/types/khkk'
|
||||
|
||||
@ -196,6 +196,39 @@ export function KhkkDetailContent({
|
||||
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).
|
||||
const catalogOptions = useQuery({
|
||||
@ -238,6 +271,28 @@ export function KhkkDetailContent({
|
||||
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({
|
||||
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
|
||||
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
|
||||
@ -543,7 +598,10 @@ export function KhkkDetailContent({
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
{/* [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. */}
|
||||
@ -662,7 +720,7 @@ export function KhkkDetailContent({
|
||||
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,
|
||||
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
|
||||
// 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".
|
||||
@ -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
|
||||
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ữ. */}
|
||||
{/* [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
|
||||
value={addCatalogId}
|
||||
onChange={(e) => setAddCatalogId(e.target.value)}
|
||||
@ -710,16 +784,40 @@ export function KhkkDetailContent({
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
|
||||
onClick={() => addLine.mutate({
|
||||
supplierId: addFor.supplierId,
|
||||
catalogEntryId: addCatalogId,
|
||||
proposedAmount: Number(addAmount),
|
||||
})}
|
||||
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0
|
||||
|| addLine.isPending || adoptSeedLine.isPending}
|
||||
// [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,
|
||||
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 variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</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. */}
|
||||
<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">
|
||||
{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 />
|
||||
</tr>
|
||||
@ -1004,10 +1106,17 @@ export function KhkkDetailContent({
|
||||
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
|
||||
const noWorkflow = !plan.approvalWorkflowId
|
||||
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.'
|
||||
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
|
||||
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
|
||||
: undefined
|
||||
: 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.`
|
||||
: undefined
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -42,7 +42,7 @@ import {
|
||||
KHKK_APPROVAL_GROUP_LABELS, KHKK_PHASE_BADGE, KHKK_PHASE_LABELS,
|
||||
KhkkAttachmentPurpose, KhkkPhase, KhkkTransitionAction, type KhkkTransitionInput,
|
||||
type DossierItemKindValue, type DossierItemStatusValue, type KhkkAttachmentDto,
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkPhaseValue,
|
||||
type KhkkDetailDto, type KhkkDossierItemDto, type KhkkLineDto, type KhkkPhaseValue,
|
||||
type UpsertKhkkDossierItemInput,
|
||||
} from '@/types/khkk'
|
||||
|
||||
@ -196,6 +196,39 @@ export function KhkkDetailContent({
|
||||
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).
|
||||
const catalogOptions = useQuery({
|
||||
@ -238,6 +271,28 @@ export function KhkkDetailContent({
|
||||
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({
|
||||
mutationFn: async (lineId: string) => api.delete(`/contract-signing-plans/${id}/lines/${lineId}`),
|
||||
onSuccess: () => { toast.success('Đã xoá hạng mục'); invalidate() },
|
||||
@ -543,7 +598,10 @@ export function KhkkDetailContent({
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
{/* [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. */}
|
||||
@ -662,7 +720,7 @@ export function KhkkDetailContent({
|
||||
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,
|
||||
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
|
||||
// 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".
|
||||
@ -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
|
||||
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ữ. */}
|
||||
{/* [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
|
||||
value={addCatalogId}
|
||||
onChange={(e) => setAddCatalogId(e.target.value)}
|
||||
@ -710,16 +784,40 @@ export function KhkkDetailContent({
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0 || addLine.isPending}
|
||||
onClick={() => addLine.mutate({
|
||||
supplierId: addFor.supplierId,
|
||||
catalogEntryId: addCatalogId,
|
||||
proposedAmount: Number(addAmount),
|
||||
})}
|
||||
disabled={!addCatalogId || addAmount === '' || Number(addAmount) < 0
|
||||
|| addLine.isPending || adoptSeedLine.isPending}
|
||||
// [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,
|
||||
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 variant="outline" size="sm" onClick={() => setAddFor(null)}>Hủy</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>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</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. */}
|
||||
<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">
|
||||
{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 />
|
||||
</tr>
|
||||
@ -1004,10 +1106,17 @@ export function KhkkDetailContent({
|
||||
const unassigned = plan.lines.filter(l => !l.catalogEntryId).length
|
||||
const noWorkflow = !plan.approvalWorkflowId
|
||||
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.'
|
||||
: plan.lines.length === 0 ? 'Phiếu chưa có dòng NCC nào.'
|
||||
: unassigned > 0 ? `Còn ${unassigned} dòng chưa gán hạng mục.`
|
||||
: undefined
|
||||
: 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.`
|
||||
: undefined
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user