[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
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:
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -191,8 +191,9 @@ public class PurchaseEvaluationWorkflowService(
|
||||
var missing = new List<string>();
|
||||
// [multi-NCC A3(i)[2] + D5] Winner = có ÍT NHẤT 1 báo giá ĐƯỢC CHỌN (Quote.IsSelected
|
||||
// = nguồn-sự-thật per Detail×Supplier). Tổng giá gói = SUM ThanhTien mọi quote IsSelected
|
||||
// toàn phiếu (D5 giữ >0). 0 selected → "chưa chọn"; có selected nhưng tổng ≤0 → "chưa có giá"
|
||||
// (item 2 chỉ nới NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá).
|
||||
// toàn phiếu (SUM có-dấu, chỉ dùng ở ngưỡng CEO/giaTri). [S116 anh Kiệt: cho nhập giá ÂM =
|
||||
// phát-sinh-giảm/hoàn-tiền = giá THẬT] 0 selected → "chưa chọn"; có selected nhưng TẤT CẢ = 0
|
||||
// → "chưa có giá" (âm/dương đều là giá thật → gửi được, kể cả 1 NCC 1 hạng mục âm; Sum()<=0→All(==0)).
|
||||
var selectedThanhTien = await (
|
||||
from q in db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
join d in db.PurchaseEvaluationDetails.AsNoTracking()
|
||||
@ -203,7 +204,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
{
|
||||
missing.Add("chưa chọn Đơn vị NCC/TP");
|
||||
}
|
||||
else if (selectedThanhTien.Sum() <= 0)
|
||||
else if (selectedThanhTien.All(x => x == 0))
|
||||
{
|
||||
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
|
||||
}
|
||||
|
||||
@ -266,4 +266,76 @@ public class PeMultiWinnerTests
|
||||
detailDto.Quotes.Should().HaveCount(3, "cả 3 NCC đều có báo giá cho hạng mục");
|
||||
detailDto.Quotes.Count(q => q.IsSelected).Should().Be(2, "đúng 2 báo giá được chọn");
|
||||
}
|
||||
|
||||
// ===== [S116 anh Kiệt — SỐ ÂM báo giá] winnerQuoteTotal = SUM CÓ DẤU =====
|
||||
// Báo giá ÂM (phát-sinh-giảm / hoàn tiền NCC) là giá-trị THẬT → cộng dồn CÓ DẤU,
|
||||
// KHÔNG clamp về 0 / KHÔNG Abs / KHÔNG loại. winnerQuoteTotal (DTO :1188-1191
|
||||
// `.Where(IsSelected).Sum(ThanhTien)`) phải giữ dấu âm. Nếu ai thêm clamp/Abs →
|
||||
// 2 test dưới RED.
|
||||
|
||||
[Fact]
|
||||
public async Task WinnerQuoteTotal_TwoWinnersOppositeSign_SumsWithSign()
|
||||
{
|
||||
// khác winner: NCC-POS chào +50tr (vật tư), NCC-NEG ghi −20tr (hoàn tiền / giảm
|
||||
// trừ) cùng 1 hạng mục — cả 2 ĐƯỢC CHỌN → tổng gói = 50 − 20 = 30tr.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-MW-SIGN");
|
||||
var detailId = await SeedDetailAsync(db, pe);
|
||||
|
||||
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-POS", 50_000_000m, isWinner: true, order: 0);
|
||||
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-NEG", -20_000_000m, isWinner: true, order: 1);
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
bundle.WinnerQuoteTotal.Should().Be(30_000_000m,
|
||||
"SUM có dấu — báo giá âm (hoàn tiền / phát-sinh-giảm) TRỪ vào tổng, KHÔNG bị loại");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WinnerQuoteTotal_SameWinnerPositiveAndNegativeLines_SumsWithSign()
|
||||
{
|
||||
// cùng winner: 1 NCC trúng với 2 dòng (2 detail) — vật tư +80tr + phát-sinh-giảm
|
||||
// −30tr → tổng = 80 − 30 = 50tr. Verify SelectMany gộp mọi dòng của winner-row
|
||||
// GIỮ dấu (mirror WinnerQuoteTotal_MultipleQuotesPerWinner nhưng 1 dòng âm).
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-MW-SIGN2");
|
||||
|
||||
var detail1 = await SeedDetailAsync(db, pe);
|
||||
var detail2 = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id,
|
||||
GroupCode = "A.II", GroupName = "Phát sinh", NoiDung = "Giảm trừ", Order = 1,
|
||||
};
|
||||
db.PurchaseEvaluationDetails.Add(detail2);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var supplier = new Supplier { Id = Guid.NewGuid(), Code = "NCC-SGN", Name = "NCC 2 dòng trái dấu" };
|
||||
db.Suppliers.Add(supplier);
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id, SupplierId = supplier.Id,
|
||||
Order = 0, IsWinner = true,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1,
|
||||
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 80_000_000m, IsSelected = true,
|
||||
});
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = -30_000_000m, IsSelected = true,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
bundle.WinnerQuoteTotal.Should().Be(50_000_000m,
|
||||
"cùng winner 2 dòng trái dấu → SUM có dấu (80 − 30), dòng âm KHÔNG bị bỏ");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1034,4 +1034,55 @@ public class PeWorkItemBudgetTests
|
||||
reloaded.BudgetPeriodAmount.Should().Be(80m, "field này được set");
|
||||
reloaded.ExpectedRemainingAmount.Should().BeNull("absolute-set: null request CLEAR field, KHÔNG giữ 999");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 7. [S116 anh Kiệt — SỐ ÂM báo giá] Regression-lock: báo giá ÂM (phát-sinh-giảm /
|
||||
// hoàn tiền NCC = giá-trị THẬT) KHÔNG rò rỉ vào ngân sách. "Ngân sách giữ nguyên"
|
||||
// là quyết định KIẾN-TRÚC (0 code nối quote→budget). Test tautological NHƯNG khoá
|
||||
// cứng: nếu ai sau này wire "auto-trừ ngân sách theo báo giá" thì dòng âm sẽ phá
|
||||
// ngân sách — test này bắt regression đó. Đồng thời chứng minh TÁCH BIỆT: dòng âm
|
||||
// CHẢY vào CurrentProposalTotal (cột đề xuất) chứ KHÔNG vào FullAmount (cột ngân
|
||||
// sách) + record PeWorkItemBudget bất biến.
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task NegativeSelectedQuote_DoesNotMutateBudgetRecord_ReflectsInProposalOnly()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var wi = await SeedWorkItemAsync(db, "WI-NEG1");
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id,
|
||||
PurchaseEvaluationPhase.DangSoanThao, code: "PE-NEG1");
|
||||
|
||||
// Ngân sách CCM ban hành: Initial 80tr, Adjustment −5tr → FullAmount 75tr.
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
|
||||
ProInitialAmount = 100_000_000m, ProAdjustmentAmount = 0m,
|
||||
InitialAmount = 80_000_000m, AdjustmentAmount = -5_000_000m,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Báo giá ÂM −20tr được chọn (phát-sinh-giảm / hoàn tiền) cho phiếu này.
|
||||
await SeedWinnerWithQuoteAsync(db, pe, Guid.NewGuid(), -20_000_000m);
|
||||
|
||||
var bundle = await BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin))
|
||||
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
var s = bundle.BudgetSummary!;
|
||||
// Cột NGÂN SÁCH bất biến — dòng âm KHÔNG kéo giảm ngân sách.
|
||||
s.FullAmount.Should().Be(75_000_000m, "ngân sách = CCM Initial+Adjustment (80−5), KHÔNG bị báo giá âm chạm");
|
||||
s.FullIsEstimate.Should().BeFalse();
|
||||
// Cột ĐỀ XUẤT phản ánh dòng âm (SUM có dấu) — chứng minh âm chảy đúng chỗ.
|
||||
s.CurrentProposalTotal.Should().Be(-20_000_000m, "báo giá âm phản ánh ở đề xuất, KHÔNG phải ngân sách");
|
||||
|
||||
// Record PeWorkItemBudget persistent BẤT BIẾN sau khi thêm quote âm + GET.
|
||||
var rec = await db.PeWorkItemBudgets.AsNoTracking()
|
||||
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
|
||||
rec.InitialAmount.Should().Be(80_000_000m, "record ngân sách KHÔNG đổi vì báo giá âm");
|
||||
rec.AdjustmentAmount.Should().Be(-5_000_000m);
|
||||
rec.ProInitialAmount.Should().Be(100_000_000m);
|
||||
rec.ProAdjustmentAmount.Should().Be(0m);
|
||||
}
|
||||
}
|
||||
|
||||
@ -703,4 +703,122 @@ public class PeCcmThresholdFinalizeTests
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
|
||||
}
|
||||
}
|
||||
|
||||
// [S116 anh Kiệt — SỐ ÂM báo giá] Seed 1 winner mang 2 báo giá ĐƯỢC CHỌN trái dấu
|
||||
// (mỗi giá 1 detail) → winnerQuoteTotal = SUM có dấu (NET). Cho test threshold theo
|
||||
// NET: R-CEO giữ ngưỡng NET, dòng âm (phát-sinh-giảm / hoàn tiền) kéo tổng xuống.
|
||||
private static async Task<Guid> SeedWinnerWithTwoSelectedSignedQuotesAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal a, decimal b)
|
||||
{
|
||||
var supplierId = Guid.NewGuid();
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, SupplierId = supplierId, Order = 0, IsWinner = true,
|
||||
};
|
||||
var d1 = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, GroupCode = "A.I", GroupName = "Vật tư", NoiDung = "Dòng +", Order = 0,
|
||||
};
|
||||
var d2 = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, GroupCode = "A.II", GroupName = "Phát sinh", NoiDung = "Dòng −", Order = 1,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationDetails.Add(d1);
|
||||
db.PurchaseEvaluationDetails.Add(d2);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = d1.Id, PurchaseEvaluationSupplierId = pes.Id, ThanhTien = a, IsSelected = true,
|
||||
});
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = d2.Id, PurchaseEvaluationSupplierId = pes.Id, ThanhTien = b, IsSelected = true,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// (d) [S116 anh Kiệt — SỐ ÂM báo giá] NET threshold: dòng âm (phát-sinh-giảm /
|
||||
// hoàn tiền) kéo winnerQuoteTotal (SUM có dấu) xuống DƯỚI ngưỡng CEO → CCM
|
||||
// duyệt done miễn CEO HỢP LỆ. R-CEO giữ ngưỡng NET (KHÔNG đổi) — chỉ tổng đại
|
||||
// số quyết định, KHÔNG phải dòng dương lớn nhất.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmDelegation_NegativeLinePullsNetBelowThreshold_Finalizes_NoThrow()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccmNeg@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceoNeg@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm }, // Bước 1 = CCM (đứng đây)
|
||||
new[] { ceo }, // Bước 2 = CEO (sẽ bị bỏ qua)
|
||||
}, ceoThreshold: CeoThreshold); // 1 tỷ
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-NEG");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// +1.2 tỷ (vật tư) + (−0.3 tỷ) (giảm trừ) → NET 0.9 tỷ < ngưỡng 1 tỷ.
|
||||
// Dòng dương RIÊNG (1.2 tỷ) ≥ ngưỡng — nếu code lấy max-line / positive-only = SAI.
|
||||
var supplierId = await SeedWinnerWithTwoSelectedSignedQuotesAsync(
|
||||
db, pe, a: 1_200_000_000m, b: -300_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
|
||||
finalizeByCcmDelegation: true,
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
await act.Should().NotThrowAsync(
|
||||
"winnerQuoteTotal = SUM có dấu (1.2 tỷ − 0.3 tỷ = 0.9 tỷ) < ngưỡng → CCM finalize hợp lệ");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "net dưới ngưỡng → duyệt done miễn CEO");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// (e) [S116] CONTRAST — CÙNG dòng dương 1.2 tỷ NHƯNG KHÔNG có dòng âm → NET 1.2 tỷ
|
||||
// ≥ ngưỡng → Conflict. Chứng minh dòng âm ở (d) là LOAD-BEARING (chính nó kéo
|
||||
// net xuống mới cho finalize), test (d) KHÔNG phải happy-path trivial.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmDelegation_SamePositiveLineWithoutNegative_NetAboveThreshold_ThrowsConflict()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccmNeg2@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceoNeg2@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm },
|
||||
new[] { ceo },
|
||||
}, ceoThreshold: CeoThreshold);
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-NEG2");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// CHỈ dòng +1.2 tỷ (KHÔNG dòng âm) → net 1.2 tỷ ≥ ngưỡng.
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_200_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
|
||||
finalizeByCcmDelegation: true,
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*ngưỡng CEO*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"không có dòng âm → net ≥ ngưỡng → CEO vẫn phải duyệt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1066,4 +1066,170 @@ public class PeSubmitGuardAndBypassTests
|
||||
cap1Opinion.Comment.Should().Be("Ý kiến mới sau bản rỗng", "opinion overwrite bình thường");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// [S116 anh Kiệt — SỐ ÂM báo giá / R2-revised submit-guard] Báo giá ÂM
|
||||
// (phát-sinh-giảm / hoàn tiền NCC = giá-trị THẬT) → gửi duyệt được.
|
||||
// Guard ĐỔI (prod :202-208): `Sum() <= 0` (cũ) → `All(x => x == 0)` (mới):
|
||||
// • CHƯA chọn gì (0 quote IsSelected) → "chưa chọn Đơn vị NCC/TP" (chặn)
|
||||
// • Có chọn nhưng TẤT CẢ = 0 → "chưa có giá chào thầu" (chặn)
|
||||
// • Có ≥1 báo giá KHÁC 0 (âm HOẶC dương) → QUA guard (gửi được)
|
||||
// Regression-lock mạnh nhất = mixed net==0 nhưng từng dòng ≠0: dưới guard CŨ
|
||||
// (Sum<=0) bị CHẶN, dưới guard MỚI (All==0) cho QUA. Revert về Sum()<=0 →
|
||||
// 2 test net≤0 dưới đây RED ngay.
|
||||
// =====================================================================
|
||||
|
||||
// Seed 1 winner (PES) mang N báo giá ĐƯỢC CHỌN (mỗi giá 1 detail) — cho phép âm /
|
||||
// trái dấu. Mở rộng SeedWinnerWithQuoteAsync (:106, 1 quote/winner) → N quote/winner.
|
||||
private static async Task<Guid> SeedWinnerWithQuotesAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, params decimal[] quoteThanhTien)
|
||||
{
|
||||
var supplierId = Guid.NewGuid();
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
var order = 0;
|
||||
foreach (var tt in quoteThanhTien)
|
||||
{
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Bê tông",
|
||||
NoiDung = $"Dòng {order}",
|
||||
Order = order,
|
||||
};
|
||||
db.PurchaseEvaluationDetails.Add(detail);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = tt,
|
||||
IsSelected = true,
|
||||
});
|
||||
order++;
|
||||
}
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
}
|
||||
|
||||
// Dựng PE V2 đủ 4 ĐK Section-3 NHƯNG winner mang N báo giá tuỳ ý (âm / mixed).
|
||||
// budget=1tr (>0 qua budget-guard) + comparison + pin workflow. Drafter ngoài
|
||||
// chuỗi → submit start pointer (0,1) KHÔNG bypass.
|
||||
private static async Task<PurchaseEvaluation> BuildV2PeReadyToSubmitWithQuotesAsync(
|
||||
TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code,
|
||||
params decimal[] selectedQuoteThanhTien)
|
||||
{
|
||||
var pe = BuildPeNhap(
|
||||
budgetPeriodAmount: 1_000_000m,
|
||||
approvalWorkflowId: workflowId,
|
||||
drafterUserId: drafterUserId,
|
||||
code: code);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuotesAsync(db, pe, selectedQuoteThanhTien);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
SeedComparisonAttachment(db, pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return pe;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_AllSelectedQuotesZero_ThrowsConflict_NoBidPrice_R2Revised()
|
||||
{
|
||||
// all-zero: winner có 2 báo giá ĐƯỢC CHỌN NHƯNG cả 2 = 0 → All(==0) → chặn
|
||||
// "chưa có giá chào thầu" (số ÂM mới hợp lệ; số 0 vẫn = chưa nhập giá). Đủ
|
||||
// budget + comparison → message CHỈ nhắc giá. Bổ sung test (3) single-zero:
|
||||
// ở đây .All() chạy trên list NHIỀU phần tử (đều 0).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 500_000m, code: "PE-NEG-ZERO");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuotesAsync(db, pe, 0m, 0m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
SeedComparisonAttachment(db, pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
|
||||
|
||||
var ex = await act.Should().ThrowAsync<ConflictException>();
|
||||
ex.Which.Message.Should().Contain("chưa có giá chào thầu");
|
||||
ex.Which.Message.Should().NotContain("chưa chọn Đơn vị NCC/TP");
|
||||
ex.Which.Message.Should().NotContain("Ngân sách");
|
||||
ex.Which.Message.Should().NotContain("Bảng so sánh");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao, "guard chặn TRƯỚC mutate");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_PureNegativeQuote_PassesBidPriceGuard_SucceedsToChoDuyet_R2Revised()
|
||||
{
|
||||
// ⭐ TRỤC CỐT LÕI: 1 winner, 1 báo giá ÂM −500k (hoàn tiền / phát-sinh-giảm =
|
||||
// giá THẬT) → All(==0) false → QUA guard → gửi duyệt OK (ChoDuyet 0,1). Dưới
|
||||
// guard CŨ (Sum<=0) case này BỊ CHẶN → đây là hành-vi mới R2-revised.
|
||||
var (svc, fix, db, clock) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("apprneg@s116.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approver);
|
||||
var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
|
||||
db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-PURE", -500_000m);
|
||||
|
||||
await SubmitAsync(svc, pe, Guid.NewGuid()); // actor ngoài chuỗi
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "báo giá âm là giá THẬT → gửi duyệt được");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_MixedSignQuotes_NetPositive_SucceedsToChoDuyet_R2Revised()
|
||||
{
|
||||
// mixed net>0: +1tr vật tư + (−300k) giảm trừ → net +700k, từng dòng ≠0 → qua guard.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("apprmix@s116.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approver);
|
||||
var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
|
||||
db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-MIXPOS", 1_000_000m, -300_000m);
|
||||
|
||||
await SubmitAsync(svc, pe, Guid.NewGuid());
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "net dương + từng dòng ≠0 → gửi duyệt OK");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_MixedSignQuotes_NetZeroButIndividualNonzero_SucceedsToChoDuyet_R2Revised()
|
||||
{
|
||||
// 🔒 STRONGEST regression-lock cho R2-revised: +500k + (−500k) → net == 0 NHƯNG
|
||||
// từng dòng ≠0 → All(==0) FALSE → QUA guard. Dưới guard CŨ (Sum()<=0) net==0 sẽ
|
||||
// BỊ CHẶN → nếu code revert về Sum()<=0, test này RED ngay. Đây chính là ranh
|
||||
// giới phân biệt All(==0) vs Sum()<=0.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("apprz@s116.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approver);
|
||||
var pe = await BuildV2PeReadyToSubmitWithQuotesAsync(
|
||||
db, wf.Id, drafterUserId: Guid.NewGuid(), code: "PE-NEG-NET0", 500_000m, -500_000m);
|
||||
|
||||
await SubmitAsync(svc, pe, Guid.NewGuid());
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"net==0 nhưng từng báo giá ≠0 → All(==0) false → gửi được (phân biệt All==0 vs Sum<=0)");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user