[CLAUDE] PurchaseEvaluation: luy ke TAM TINH vs CHINH XAC — ComputePendingAsync + 5-field DTO + FE 2-app sub-dong amber/banner man duyet + D4 audit-note (UAT anh Kiet 15-07, 0 mig, test 520→528)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m27s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-17 14:27:19 +07:00
parent e53a69307f
commit 6664a6136a
22 changed files with 1324 additions and 56 deletions

View File

@ -2,7 +2,7 @@
// NCC + Hạng mục + Báo giá stack vertically trong 1 màn hình.
// Duyệt history + Lịch sử thay đổi → moved to Panel 3 (xem PeWorkflowPanel
// → PeApprovalsSection + PeHistorySection).
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useIsFetching, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
@ -1181,12 +1181,13 @@ function BudgetNoteCell({ editable, value, setValue, savedValue, saving, onSave
// [Mig 59] Ô số CHUNG mọi cột (Section B THỰC HIỆN) — 1 giá trị span hết 3 cột Dự án/PRO/CCM
// (dòng 1/2/6 không tách cột). value null → "Chưa chọn".
function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan: number }) {
function BudgetSharedNumCell({ value, colSpan, sub }: { value: number | null; colSpan: number; sub?: ReactNode }) {
return (
<td className="border border-slate-300 px-2 py-1.5 text-right font-mono tabular-nums text-slate-900" colSpan={colSpan}>
{value == null
? <span className="font-sans text-slate-400">Chưa chọn</span>
: <span className={cn(value < 0 && 'font-semibold text-red-600')}>{fmtVnd(value)}</span>}
{sub}
</td>
)
}
@ -1194,8 +1195,8 @@ function BudgetSharedNumCell({ value, colSpan }: { value: number | null; colSpan
// [Mig 59] Ô giá trị 1 cột (PRO/CCM) Section B — display read-only. value null → "—".
// signed → fmtVndSigned (âm trong ngoặc). pct → dòng % phụ dưới. asCell bọc <td> sẵn
// (cho dòng 5/6/7/8 ccm); KHÔNG asCell → chỉ nội dung (nhúng vào <td> editable dòng 3).
function BudgetColValue({ value, pct, signed = false, asCell = false }: {
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean
function BudgetColValue({ value, pct, signed = false, asCell = false, sub }: {
value: number | null; pct?: string | null; signed?: boolean; asCell?: boolean; sub?: ReactNode
}) {
const body = value == null
? <span className="text-slate-300"></span>
@ -1208,8 +1209,8 @@ function BudgetColValue({ value, pct, signed = false, asCell = false }: {
</>
)
return asCell
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}</td>
: <>{body}</>
? <td className="border border-slate-300 px-2 py-1.5 text-right align-top">{body}{sub}</td>
: <>{body}{sub}</>
}
// [Mig 59] Ô "So sánh" 1 cột (PRO/CCM) Section B — = 0 → text "Bằng ngân sách" (reuse
@ -1317,6 +1318,24 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
// [S134 anh Kiệt FDC] Lũy kế TẠM TÍNH — phần góp từ phiếu cùng gói thầu CHƯA duyệt
// (?? guard data cũ chưa có field). Chỉ hiện khi phiếu CHƯA freeze (số DaDuyet là
// snapshot bất biến S133 — KHÔNG thêm tạm-tính). N8: row1/5 dùng pendingSubmitted*,
// row2/6 dùng pendingSelected*.
const pendingSubmittedTotal = bs.pendingSubmittedTotal ?? 0
const pendingSubmittedCount = bs.pendingSubmittedCount ?? 0
const pendingSelectedTotal = bs.pendingSelectedTotal ?? 0
const pendingSelectedCount = bs.pendingSelectedCount ?? 0
const pendingPriorPes = bs.pendingPriorPes ?? []
const showPending = !bs.budgetFrozen && (pendingSubmittedCount > 0 || pendingSelectedCount > 0 || pendingPriorPes.length > 0)
// Sub-dòng amber "Tạm tính" — LUÔN kèm chữ "Tạm tính", KHÔNG bao giờ thay số chính xác.
const pendingSub = (base: number, delta: number, count: number, withDetail: boolean) => (
<div className="mt-0.5 text-[10px] font-sans italic text-amber-700">
Tạm tính: {fmtVnd(base + delta)}
{withDetail && ` (+${fmtVnd(delta)} từ ${count} phiếu chưa duyệt)`}
</div>
)
// [Mig 59] Mỗi cột tính ĐỘC-LẬP theo "NS kỳ này" + "full" của CỘT đó. Helper trả về
// bộ row3/5/7/8/9 + 3 so-sánh cho 1 cột. periodAmount = NS kỳ này (live); colFull =
// full cột; remaining = giá trị TH dự kiến còn lại (null → default row7).
@ -1495,12 +1514,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{/* 1 — Ngân sách trình duyệt trước (CHUNG) */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">1. Ngân sách trình duyệt trước</td>
<BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1} />
<BudgetSharedNumCell colSpan={3} value={bs.previousSubmittedCount === 0 ? null : row1}
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(row1, pendingSubmittedTotal, pendingSubmittedCount, true) : undefined} />
</tr>
{/* 2 — Kỳ trước đã chọn thầu (CHUNG) */}
<tr>
<td className="border border-slate-300 px-3 py-1.5 text-slate-700">2. Kỳ trước đã chọn thầu</td>
<BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2} />
<BudgetSharedNumCell colSpan={3} value={bs.previousSelectedCount === 0 ? null : row2}
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row2, pendingSelectedTotal, pendingSelectedCount, true) : undefined} />
</tr>
{/* 3 — Ngân sách kỳ này (PER-cột, editable PRO/CCM) + % /full cột */}
<tr>
@ -1573,8 +1594,10 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<div className="text-[10px] text-slate-400">= 1 + 3</div>
</td>
<td className="border border-slate-300 px-2 py-1.5 text-right align-middle text-slate-300"></td>
<BudgetColValue asCell value={pro.r5} />
<BudgetColValue asCell value={ccm.r5} />
<BudgetColValue asCell value={pro.r5}
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(pro.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
<BudgetColValue asCell value={ccm.r5}
sub={showPending && pendingSubmittedCount > 0 ? pendingSub(ccm.r5, pendingSubmittedTotal, pendingSubmittedCount, false) : undefined} />
</tr>
{/* 6 — Lũy kế thực hiện (CHUNG = 2 + 4) */}
<tr>
@ -1582,7 +1605,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
6. Lũy kế thực hiện
<div className="text-[10px] text-slate-400">= 2 + 4</div>
</td>
<BudgetSharedNumCell colSpan={3} value={row6} />
<BudgetSharedNumCell colSpan={3} value={row6}
sub={showPending && pendingSelectedCount > 0 ? pendingSub(row6, pendingSelectedTotal, pendingSelectedCount, false) : undefined} />
</tr>
<tr>
<td className="border border-slate-300 px-3 py-1.5 pl-6 align-top text-slate-700">
@ -1654,6 +1678,28 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
</tbody>
</table>
</div>
{/* [S134] Danh sách phiếu trước cùng gói thầu CHƯA duyệt (informational — N9:
PLAIN TEXT + badge, KHÔNG link vì click phiếu Nháp/chưa-duyệt → 403 S89). */}
{showPending && pendingPriorPes.length > 0 && (
<div className="border-t border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-6 text-amber-800">
<span className="font-medium">Phiếu trước cùng gói chưa duyệt: </span>
{pendingPriorPes.map((p, i) => (
<span key={p.id}>
{i > 0 && ', '}
<span className="font-mono">{p.maPhieu ?? '(chưa có mã)'}</span>{' '}
<span className={cn(
'rounded-full px-1.5 py-0.5 text-[10px] font-medium',
p.phase === 10 ? 'bg-amber-100 text-amber-800'
: p.phase === 98 ? 'bg-orange-100 text-orange-800'
: p.phase === 1 ? 'bg-slate-200 text-slate-700'
: 'bg-amber-100 text-amber-800',
)}>
{PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`}
</span>
</span>
))}
</div>
)}
</div>
)
}

View File

@ -130,6 +130,17 @@ export function PeWorkflowPanel({
isStepAfterFinalize(stepOrder)
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
&& finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder)
// [S134 anh Kiệt FDC] Phiếu trước cùng gói thầu CHƯA duyệt (informational nudge — KHÔNG
// hard-block). M2: banner "cần duyệt phiếu trước" chỉ có nghĩa khi ĐANG chờ duyệt (phase
// ChoDuyet); phiếu Nháp/TraLai/TuChoi cũng có pending fill nhưng banner vô nghĩa ở đó.
const pendingPriors = evaluation.budgetSummary?.pendingPriorPes ?? []
const showPendingBanner = pendingPriors.length > 0
&& !evaluation.budgetSummary?.budgetFrozen
&& !readOnly
&& evaluation.phase === PurchaseEvaluationPhase.ChoDuyet
const pendingPriorsText = pendingPriors
.map(p => `${p.maPhieu ?? '(chưa có mã)'} (${PurchaseEvaluationPhaseLabel[p.phase] ?? `Phase ${p.phase}`})`)
.join(', ')
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
// [S117 anh Kiệt FDC] Khi PRO có CẢ Min & Max → thêm ứng viên "duyệt theo khoảng"
// (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max].
@ -436,6 +447,14 @@ export function PeWorkflowPanel({
</div>
)}
{/* [S134] Banner cảnh báo phiếu trước cùng gói thầu CHƯA duyệt (M2: chỉ khi phase
ChoDuyet). Informational — KHÔNG disable nút duyệt. */}
{showPendingBanner && (
<div className="rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} cần duyệt phiếu trước trước phiếu này.
</div>
)}
{next.length > 0 && !readOnly && (
<div>
<Label className="text-xs">Hành đng:</Label>
@ -522,6 +541,13 @@ export function PeWorkflowPanel({
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
</>}
>
{/* [S134] NHẮC LẠI phiếu trước chưa duyệt TRONG dialog duyệt (trên bộ chọn giá)
— người duyệt cuối chắc chắn thấy. KHÔNG đụng disable nút Xác nhận (B3). */}
{isApproveAction && showPendingBanner && (
<div className="mb-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] text-amber-800">
{pendingPriors.length} phiếu trước cùng gói thầu CHƯA duyệt: {pendingPriorsText} cần duyệt phiếu trước trước phiếu này.
</div>
)}
{isCancel && (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[11px] text-red-800">
Phiếu sẽ bị khoá hoàn toàn (không edit/transition đưc nữa). Drafter cần tạo phiếu mới nếu muốn làm lại.