[CLAUDE] PurchaseEvaluation: cho nhập giá ÂM ô báo giá hạng mục + submit-guard all-zero (S116)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m28s

Feature (anh Kiệt FDC ⇄ Duy Nguyen 2026-07-13): cho nhập SỐ ÂM ô "Số tiền báo giá
cho hạng mục" (Quote.ThanhTien) — âm = phát-sinh-giảm / hoàn-tiền của NCC = giá THẬT.
Nguyên-tắc "có giá là được": ≥1 giá thật (âm/dương) → gửi được; chỉ chưa-nhập-gì mới chặn.

FE (2 app byte-identical PeDetailTabs.tsx):
- Nút ± toggle 2 input quote (AddSupplierDialog + QuoteDialog), ghi giá-CÓ-DẤU thẳng vào
  form.thanhTien (2 dialog POST commit trực tiếp, không nút Lưu riêng → tránh mất-dấu thầm lặng).
- Submit gate + toast >0 → !==0. Ô ngân sách giữ ≥0 (không toggle).
- 2 FE submit-mirror (:201 missingForApproval + :235 submitChecklist) đổi từ SUM
  computeGiaChaoThau → helper hasRealSelectedQuote (iterate details[].quotes IsSelected≠0;
  SUM không phân-biệt all-zero vs net-zero).

BE (1 điều-kiện): PurchaseEvaluationWorkflowService.cs:206 selectedThanhTien.Sum()<=0
  → All(x=>x==0) — chặn chỉ khi TẤT CẢ selected=0. BE+DB nhận âm sẵn (0 validator,
  decimal(18,2), no CHECK, KHÔNG migration).

Owner-decisions: R1 cho Contract.GiaTri âm (no clamp) · R2 nới submit-guard · R-CEO
  giữ NET threshold (dòng âm có thể kéo dưới ngưỡng → CCM finalize, owner chấp nhận).

Tests +9 (486→495): ngân-sách-bất-biến · winnerQuoteTotal signed-sum · submit-guard
  all-zero/negative/net-zero · CCM-threshold net.

Pipeline S116: /fable-clone invest (4-lane) → owner-gate → /fable-clone review (3-lane)
  → /fable-real (Fable, #53 garble → em-main-solo gate GO-WITH-FIXES) → hmw implement (Opus MAX).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-13 17:08:34 +07:00
parent 2ef3f445bf
commit 88368fd87e
7 changed files with 571 additions and 67 deletions

View File

@ -79,6 +79,16 @@ function computeGiaChaoThau(ev: PeDetailBundle): number | null {
return ev.winnerQuoteTotal
}
// [S116 anh Kiệt] "Có giá là được": phiếu gửi-duyệt được khi có ≥1 báo giá ĐƯỢC CHỌN
// (IsSelected) với thanhTien !== 0. Giá ÂM = phát-sinh-giảm / hoàn tiền NCC = giá THẬT,
// KHÔNG phải lỗi. Thay điều-kiện computeGiaChaoThau > 0 cũ: SUM không phân biệt all-zero
// (chưa nhập) vs net-zero (+100,-100=0 nhưng có giá thật) → net-zero bị chặn oan. Mirror
// BE selectedThanhTien = quotes WHERE IsSelected. Quote sống ở details[].quotes (KHÔNG
// suppliers[].quotes); IsSelected = báo giá được tick trúng trong hàng hạng mục.
function hasRealSelectedQuote(ev: PeDetailBundle): boolean {
return ev.details.some(d => d.quotes.some(q => q.isSelected && q.thanhTien !== 0))
}
// Main detail content — flat render 3 section không tabs.
// Tên giữ PeDetailTabs để không break callsite (rename gây churn).
//
@ -195,10 +205,11 @@ export function PeDetailTabs({
if (!evaluation.suppliers.some(s => s.isWinner)) {
missing.push("Chưa chọn Đơn vị NCC/TP")
} else {
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
// Chỉ check khi đã chọn (không spam khi chưa chọn — đã có mục 1).
const gia = computeGiaChaoThau(evaluation)
if (gia == null || gia <= 0) missing.push("Đơn vị được chọn chưa có giá chào thầu")
// 2. [S116 anh Kiệt] Đơn vị được chọn CHƯA NHẬP giá chào thầu nào (mọi báo giá
// được chọn = 0). "Có giá là được": giá ÂM (phát-sinh-giảm / hoàn tiền NCC) = giá
// THẬT → gửi được; net-zero có giá thật (+100,-100) cũng gửi được. Chỉ chặn khi
// TOÀN BỘ = 0 (chưa nhập gì). Mirror BE selectedThanhTien = quotes WHERE IsSelected.
if (!hasRealSelectedQuote(evaluation)) missing.push("Đơn vị được chọn chưa có giá chào thầu")
}
// 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập).
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
@ -227,12 +238,14 @@ export function PeDetailTabs({
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút.
const submitChecklist = useMemo(() => {
const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
// [S116 anh Kiệt] "đã nhập giá" = có ≥1 báo giá được chọn !== 0 (âm = giá thật). Không
// dùng SUM > 0 (net-zero có giá thật bị false-đỏ). Mirror BE quotes WHERE IsSelected.
const hasRealQuote = supplierOk && hasRealSelectedQuote(evaluation)
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
return [
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk },
{ label: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
{ label: 'Đã nhập giá chào thầu', ok: hasRealQuote },
{ label: 'Ngân sách kỳ này', ok: effBudget != null },
{ label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
]
@ -2182,6 +2195,10 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
note: '',
thanhTien: 0,
})
// [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG
// (KHÔNG nhét vào form) — nhớ ý-định-dấu khi ô còn trống/0. Toggle ghi giá-trị-CÓ-DẤU
// vào form.thanhTien NGAY (POST commit thẳng, không có nút "Lưu" riêng).
const [neg, setNeg] = useState(false)
const phoneError = !isValidPhone(form.contactPhone) ? 'SĐT không hợp lệ (cần 10-11 số bắt đầu 0)' : ''
const emailError = !isValidEmail(form.contactEmail) ? 'Email không hợp lệ' : ''
const hasError = !!(phoneError || emailError)
@ -2222,8 +2239,9 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
note: form.note,
})
const newSupplierRowId = res.data.id
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien > 0)
if (detailId && form.thanhTien > 0) {
// Step 2: tạo quote cho hạng mục (chỉ khi có detailId + thanhTien !== 0).
// [S116] !== 0 (không > 0): giá ÂM tạo quote; rỗng/0 vẫn skip = "chưa báo giá".
if (detailId && form.thanhTien !== 0) {
await api.post(`/purchase-evaluations/${evaluationId}/quotes`, {
purchaseEvaluationDetailId: detailId,
purchaseEvaluationSupplierId: newSupplierRowId,
@ -2236,7 +2254,7 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
}
},
onSuccess: () => {
toast.success(showQuote && form.thanhTien > 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
toast.success(showQuote && form.thanhTien !== 0 ? 'Đã thêm NCC + báo giá.' : 'Đã thêm NCC.')
qc.invalidateQueries({ queryKey: ['pe-detail', evaluationId] })
onClose()
},
@ -2361,19 +2379,32 @@ function AddSupplierDialog({ evaluationId, detailId, onClose }: {
{showQuote && (
<div className="col-span-2 rounded-lg border border-brand-200 bg-brand-50/40 p-3">
<Label className="text-brand-700">Số tiền báo giá cho hạng mục</Label>
<div className="relative mt-1 max-w-xs">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.thanhTien)}
onChange={e => setForm({ ...form, thanhTien: parseVnd(e.target.value) })}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
<div className="mt-1 flex max-w-xs items-center gap-1.5">
<button
type="button"
onClick={() => { setNeg(!neg); setForm({ ...form, thanhTien: -form.thanhTien }) }}
className={cn(
'h-9 w-9 shrink-0 rounded border text-sm font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
)}
title="Đảo dấu âm/dương (số âm = phát sinh giảm / hoàn tiền NCC)"
>
{neg ? '' : '+'}
</button>
<div className="relative flex-1">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(Math.abs(form.thanhTien))}
onChange={e => { const mag = parseVnd(e.target.value); setForm({ ...form, thanhTien: neg ? -mag : mag }) }}
placeholder="0"
className="pr-10 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
</div>
<p className="mt-1 text-[11px] text-slate-500">
Đ trống / 0 chỉ tạo NCC, chưa báo giá. Sửa lại sau bằng cách click số tiền trong bảng.
Đ trống / 0 chỉ tạo NCC, chưa báo giá. Bấm +/ để nhập số âm (phát sinh giảm / hoàn tiền NCC). Sửa lại sau bằng cách click số tiền trong bảng.
</p>
</div>
)}
@ -2864,6 +2895,10 @@ function QuoteDialog({
const [form, setForm] = useState({
thanhTien: existing?.thanhTien ?? 0,
})
// [S116 anh Kiệt] Dấu âm cho báo giá (phát-sinh-giảm / hoàn tiền NCC). neg = state RIÊNG
// (KHÔNG nhét vào form: setForm({thanhTien}) thay-thế cả object sẽ nuốt mất). Init theo
// dấu quote đang mở. Toggle ghi giá-trị-CÓ-DẤU vào form.thanhTien NGAY (POST commit thẳng).
const [neg, setNeg] = useState((existing?.thanhTien ?? 0) < 0)
const mut = useMutation({
mutationFn: async () =>
@ -2913,19 +2948,32 @@ function QuoteDialog({
<p className="text-sm text-slate-500">Hạng mục: <strong>{itemName}</strong></p>
<div>
<Label>Số tiền</Label>
<div className="relative">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(form.thanhTien)}
onChange={e => setForm({ thanhTien: parseVnd(e.target.value) })}
placeholder="0"
autoFocus
className="pr-12 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => { setNeg(!neg); setForm({ thanhTien: -form.thanhTien }) }}
className={cn(
'h-9 w-9 shrink-0 rounded border text-sm font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
)}
title="Đảo dấu âm/dương (số âm = phát sinh giảm / hoàn tiền NCC)"
>
{neg ? '' : '+'}
</button>
<div className="relative flex-1">
<Input
type="text"
inputMode="numeric"
value={formatVndInput(Math.abs(form.thanhTien))}
onChange={e => { const mag = parseVnd(e.target.value); setForm({ thanhTien: neg ? -mag : mag }) }}
placeholder="0"
autoFocus
className="pr-12 font-mono text-right"
/>
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-[12px] font-medium text-slate-500">đ</span>
</div>
</div>
<p className="mt-1 text-[11px] text-slate-500">VND nhập số, tự format dấu chấm ngàn (vd 1.000.000)</p>
<p className="mt-1 text-[11px] text-slate-500">VND nhập số, tự format dấu chấm ngàn (vd 1.000.000). Bấm +/ để nhập số âm (phát sinh giảm / hoàn tiền NCC).</p>
</div>
</div>
</Dialog>