[CLAUDE] PurchaseEvaluation: CCM 3-fix — multi-winner sum + budget=0 hợp lệ + cấp KẾT THÚC quy trình (Mig 58, anh Kiệt FDC)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s

CCM (Lưu Trần / anh Kiệt FDC) — làm 3/4 mục (mục 3 "CCM sửa ngân sách" để riêng):

1. Multi-winner: chọn NHIỀU NTP/NCC/ĐVDV cùng trúng → winnerQuoteTotal = SUM
   ThanhTien MỌI đơn vị isWinner (vd 3 ông 37.8+9+10 = 56.8tr). Cột mới
   PurchaseEvaluationSupplier.IsWinner (Mig 58, backfill từ SelectedSupplierId).
   SelectWinner → multi-toggle SupplierIds[]. FE: bảng so sánh tick nhiều ô +
   dòng "Tổng chi phí đã chọn"; mọi guard/checklist/computeGiaChaoThau/tạo-HĐ
   đổi sang isWinner (selectedSupplierId = null khi liên-danh ≥2).

2. Ngân sách = 0 hợp lệ: nới submit-guard + 3 validator (chỉ chặn khi NULL,
   số 0 cố ý đi qua; fallback ProInitial chỉ khi null). Mirror BE + FE.

3. Cấp KẾT THÚC quy trình: ApprovalWorkflowLevel.AllowApproverFinalize (Mig 58)
   — admin bật per-cấp trong Workflow Designer; duyệt tới cấp đó TỰ DaDuyet,
   bỏ qua CEO (AUTO theo cấu hình quy trình, không ô-tích runtime — anh Kiệt
   chốt). FE: banner cảnh báo + bắt chọn giá chốt.

Mig 58 AddPeMultiWinnerAndLevelFinalize (2 AddColumn bool + backfill IsWinner,
additive/reversible). Test 377→395 (+PeMultiWinnerTests +PeApproverFinalizeTests
+budget=0; auto-model gọn bớt 2 test bypass/opt-out). FE 2 app SHA-mirror.
Build sạch BE + 2 FE local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-24 15:22:35 +07:00
parent ae4f106fd7
commit f1b10e9c33
26 changed files with 7578 additions and 245 deletions

View File

@ -73,14 +73,10 @@ const NCC_PALETTES = [
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn // nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá. // NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
function computeGiaChaoThau(ev: PeDetailBundle): number | null { function computeGiaChaoThau(ev: PeDetailBundle): number | null {
const winnerSupplierRowId = ev.selectedSupplierId // [Mig 58] Multi-winner: null khi CHƯA chọn đơn vị nào; else = winnerQuoteTotal (BE
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null // SUM ThanhTien MỌI đơn vị isWinner — single hoặc liên-danh ≥2). 0 = winner chưa báo giá.
: null if (!ev.suppliers.some(s => s.isWinner)) return null
if (winnerSupplierRowId === null) return null return ev.winnerQuoteTotal
return ev.details
.flatMap(d => d.quotes)
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
.reduce((sum, q) => sum + q.thanhTien, 0)
} }
// Main detail content — flat render 3 section không tabs. // Main detail content — flat render 3 section không tabs.
@ -194,8 +190,9 @@ export function PeDetailTabs({
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch. // Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
const missingForApproval = useMemo(() => { const missingForApproval = useMemo(() => {
const missing: string[] = [] const missing: string[] = []
// 1. Chưa chọn Đơn vị NCC/TP // 1. Chưa chọn Đơn vị NCC/TP ([Mig 58] multi-winner: ≥1 đơn vị isWinner; selectedSupplierId
if (evaluation.selectedSupplierId == null) { // null khi liên-danh ≥2 nên KHÔNG dùng nó — mirror BE submit-guard IsWinner).
if (!evaluation.suppliers.some(s => s.isWinner)) {
missing.push("Chưa chọn Đơn vị NCC/TP") missing.push("Chưa chọn Đơn vị NCC/TP")
} else { } else {
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
@ -207,8 +204,10 @@ export function PeDetailTabs({
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0. // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
// [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) — // [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) —
// mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được). // mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được).
// [Mig 58 — anh Kiệt FDC] NS = 0 là HỢP LỆ (gói cho-không / vật tư cấp sẵn). Chỉ
// block khi CHƯA khai (null). Mirror BE submit-guard nới lỏng cùng đợt.
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
if (effBudget == null || effBudget <= 0) { if (effBudget == null) {
missing.push("Chưa nhập Ngân sách kỳ này") missing.push("Chưa nhập Ngân sách kỳ này")
} }
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3). // 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
@ -227,14 +226,14 @@ export function PeDetailTabs({
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan // [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút. // 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 submitChecklist = useMemo(() => {
const supplierOk = evaluation.selectedSupplierId != null const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
return [ return [
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null }, { label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk }, { 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: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
{ label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 }, { 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) }, { label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
] ]
}, [evaluation]) }, [evaluation])
@ -929,30 +928,40 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
) )
} }
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) ===== // ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] =====
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3 // Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)". // thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner).
// Save dùng POST /pe/:id/select-winner endpoint hiện có. // Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`;
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner).
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase) const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient() const qc = useQueryClient()
const setWinner = useMutation({ // gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker.
mutationFn: async (supplierId: string | null) => const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), const setWinners = useMutation({
mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
onSuccess: () => { onSuccess: () => {
toast.success('Đã cập nhật NCC được chọn.') toast.success('Đã cập nhật đơn vị trúng thầu.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] }) qc.invalidateQueries({ queryKey: ['pe-list'] })
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
const toggle = (supplierId: string, on: boolean) =>
setWinners.mutate(on
? [...winnerIds, supplierId]
: winnerIds.filter(id => id !== supplierId))
const busy = setWinners.isPending || peFetching
if (!canEdit) { if (!canEdit) {
const winners = ev.suppliers.filter(s => s.isWinner)
return ( return (
<FormRow <FormRow
label="a. NCC / TP được chọn" label="a. NCC / TP được chọn"
value={ev.selectedSupplierName value={winners.length > 0
? <span className="font-medium text-emerald-700"> {ev.selectedSupplierName}</span> ? <span className="font-medium text-emerald-700"> {winners.map(s => s.supplierName).join(', ')}</span>
: <span className="text-slate-400"> (chưa chọn)</span>} : <span className="text-slate-400"> (chưa chọn)</span>}
/> />
) )
@ -962,30 +971,49 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5"> <div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span> <span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span>
<div className="relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1">
<Select {ev.suppliers.length === 0 ? (
value={ev.selectedSupplierId ?? ''} <p className="text-[11px] text-amber-600">
onChange={e => setWinner.mutate(e.target.value || null)} Thêm NCC Section 3 trước rồi mới chọn trúng thầu.
disabled={ev.suppliers.length === 0 || setWinner.isPending} </p>
className="text-sm" ) : (
> <div className="space-y-1">
<option value=""> Chọn NCC từ danh sách Section 3 </option> {ev.suppliers.map(s => (
{ev.suppliers.map(s => ( <label
<option key={s.id} value={s.supplierId}> key={s.id}
{s.supplierName}{s.displayName ? `${s.displayName}` : ''} className={cn(
</option> 'flex cursor-pointer items-center gap-2 rounded border px-2 py-1 text-[12px] transition',
))} s.isWinner
</Select> ? 'border-emerald-300 bg-emerald-50 font-medium text-emerald-800'
{/* Loading spinner inline khi save có delay (user 2026-05-07) */} : 'border-slate-200 bg-white hover:bg-slate-50',
{setWinner.isPending && ( )}
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600"> >
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" /> <input
<span>Đang chọn NCC + sync cột giá Section 4</span> type="checkbox"
className="h-3.5 w-3.5"
checked={s.isWinner}
disabled={busy}
onChange={e => toggle(s.supplierId, e.target.checked)}
/>
<span className="flex-1">
{s.supplierName}{s.displayName ? `${s.displayName}` : ''}
</span>
<span className="text-[11px] text-emerald-600">{s.isWinner ? 'Trúng thầu' : ''}</span>
</label>
))}
</div> </div>
)} )}
{ev.suppliers.length === 0 && ( {/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */}
<p className="mt-1 text-[11px] text-amber-600"> {winnerIds.length > 0 && (
Thêm NCC Section 3 trước rồi mới chọn winner. <div className="mt-1.5 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
</p> <span className="text-emerald-700">Tổng chi phí đã chọn ({winnerIds.length} đơn vị):</span>
<span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
</div>
)}
{busy && (
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
<span>Đang cập nhật trúng thầu + sync cột giá Section 4</span>
</div>
)} )}
</div> </div>
</div> </div>
@ -1483,7 +1511,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC" label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
value={ value={
<span className="font-sans text-slate-700"> <span className="font-sans text-slate-700">
{ev.selectedSupplierName ?? <span className="text-slate-400"> (chưa chọn)</span>} {ev.suppliers.some(s => s.isWinner)
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
: <span className="text-slate-400"> (chưa chọn)</span>}
</span> </span>
} }
/> />
@ -1793,14 +1823,12 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) ===== // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper // c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt. // module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
const winnerSupplierRowId = ev.selectedSupplierId const hasWinner = ev.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
const giaChaoThau = computeGiaChaoThau(ev) const giaChaoThau = computeGiaChaoThau(ev)
// d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi // d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi
@ -1819,7 +1847,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
<FormRow <FormRow
label="c. Giá chào thầu" label="c. Giá chào thầu"
value={ value={
winnerSupplierRowId === null ? ( !hasWinner ? (
<span className="text-slate-400"> (chọn NCC/TP (a) trước)</span> <span className="text-slate-400"> (chọn NCC/TP (a) trước)</span>
) : giaChaoThau === 0 ? ( ) : giaChaoThau === 0 ? (
<span className="text-slate-400"> (chưa nhập báo giá Section 4)</span> <span className="text-slate-400"> (chưa nhập báo giá Section 4)</span>
@ -2049,7 +2077,7 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName} NCC: <strong>{evaluation.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') || evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
</p> </p>
<div> <div>
<Label>Loại </Label> <Label>Loại </Label>
@ -2436,12 +2464,19 @@ function HangMucCard({
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const setWinner = useMutation({ // [Mig 58 anh Kiệt FDC] Multi-winner: gửi TOÀN BỘ tập ID trúng thầu (chọn ≥2 = liên
mutationFn: async (supplierId: string | null) => // danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render.
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, const setWinners = useMutation({
mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const toggleWinner = (supplierId: string, on: boolean) =>
setWinners.mutate(on
? [...winnerIds, supplierId]
: winnerIds.filter(id => id !== supplierId))
return ( return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm"> <div className="rounded-lg border border-slate-200 bg-white shadow-sm">
@ -2537,7 +2572,7 @@ function HangMucCard({
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{ev.suppliers.map((s, idx) => { {ev.suppliers.map((s, idx) => {
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
const isWinner = ev.selectedSupplierId === s.supplierId const isWinner = s.isWinner
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id)) const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes const canDelete = !isWinner && !hasQuotes
const openQuote = () => setQuoteEdit({ supplier: s, existing: q }) const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
@ -2606,12 +2641,13 @@ function HangMucCard({
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5"> <div className="flex justify-end gap-0.5">
<button <button
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)} onClick={() => toggleWinner(s.supplierId, !isWinner)}
disabled={setWinners.isPending}
className={cn( className={cn(
'rounded px-1 py-0.5', 'rounded px-1 py-0.5 disabled:opacity-50',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700', isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)} )}
title={isWinner ? 'Bỏ chọn đơn vị này (click để hủy)' : 'Chọn đơn vị NCC/TP'} title={isWinner ? 'Bỏ chọn trúng thầu (chọn ≥2 = liên danh)' : 'Chọn trúng thầu (chọn ≥2 = liên danh)'}
> >
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
</button> </button>

View File

@ -110,6 +110,10 @@ export function PeWorkflowPanel({
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1] const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1] const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
const currentIsFinalApprover = lastFlowLevel?.status === 'Current' const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
// [Mig 58 item 4] Cấp Approver hiện tại được cấu hình KẾT THÚC trong Quy trình duyệt
// (admin bật per-slot ở Designer). Duyệt tới cấp này = TỰ kết thúc (BE auto theo cờ cấp,
// KHÔNG cần ô-tích). FE dùng cờ này để: (1) hiện banner cảnh báo + (2) bắt chọn giá chốt.
const approverFinalizeEligible = levelOptions?.allowApproverFinalize === true
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). // Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
const priceCandidates = ([ const priceCandidates = ([
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal }, { source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
@ -149,8 +153,9 @@ export function PeWorkflowPanel({
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null // Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done. // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm) // HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá).
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
// [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi // [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi
// chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE). // chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE).
// File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side). // File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side).
@ -430,7 +435,7 @@ export function PeWorkflowPanel({
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` : `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
// [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn. // [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn.
const isApproveAction = !isCancel && !isSendBack const isApproveAction = !isCancel && !isSendBack
const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm) const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
// [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates // [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates
// rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh // rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh
// người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge). // người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge).
@ -588,6 +593,16 @@ export function PeWorkflowPanel({
</label> </label>
</div> </div>
)} )}
{/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt:
BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */}
{isApproveAction && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<span className="font-medium"> Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
Đưc cấu hình sẵn trong Quy trình duyệt bạn duyệt xong phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt.
</span>
</div>
)}
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done. {/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */} "Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
{shouldPickPrice && ( {shouldPickPrice && (

View File

@ -51,6 +51,7 @@ type LevelDto = {
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
allowApproverEditBudget: boolean allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean allowApproverSkipToFinal: boolean
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot Duyệt = KẾT THÚC (không trình CEO)
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role). // [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role).
canEditProBudget: boolean canEditProBudget: boolean
canEditCcmBudget: boolean canEditCcmBudget: boolean
@ -106,6 +107,7 @@ type EditLevelEntry = {
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
allowApproverEditBudget: boolean allowApproverEditBudget: boolean
allowApproverSkipToFinal: boolean allowApproverSkipToFinal: boolean
allowApproverFinalize: boolean
} }
type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] } type EditStep = { name: string; departmentId: string | null; levelEntries: EditLevelEntry[] }
@ -154,6 +156,7 @@ function copyFromDefinition(d: DefinitionDto): EditStep[] {
allowApproverEditDetails: l.allowApproverEditDetails ?? false, allowApproverEditDetails: l.allowApproverEditDetails ?? false,
allowApproverEditBudget: l.allowApproverEditBudget ?? false, allowApproverEditBudget: l.allowApproverEditBudget ?? false,
allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false, allowApproverSkipToFinal: l.allowApproverSkipToFinal ?? false,
allowApproverFinalize: l.allowApproverFinalize ?? false,
})), })),
})) }))
} }
@ -171,6 +174,7 @@ function makeDefaultLevelEntry(order: LevelOrder, approverUserId: string): EditL
allowApproverEditDetails: false, allowApproverEditDetails: false,
allowApproverEditBudget: false, allowApproverEditBudget: false,
allowApproverSkipToFinal: false, allowApproverSkipToFinal: false,
allowApproverFinalize: false,
} }
} }
@ -598,6 +602,7 @@ function Designer({
allowApproverEditDetails: e.allowApproverEditDetails, allowApproverEditDetails: e.allowApproverEditDetails,
allowApproverEditBudget: e.allowApproverEditBudget, allowApproverEditBudget: e.allowApproverEditBudget,
allowApproverSkipToFinal: e.allowApproverSkipToFinal, allowApproverSkipToFinal: e.allowApproverSkipToFinal,
allowApproverFinalize: e.allowApproverFinalize,
})), })),
})), })),
}) })
@ -989,6 +994,17 @@ function Designer({
/> />
<span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span> <span>Cho phép duyệt thẳng Cấp cuối khi đang duyệt</span>
</label> </label>
{/* [Mig 58 CCM item 4 — anh Kiệt FDC] Duyệt là KẾT THÚC: NV slot
này duyệt xong → DaDuyet luôn, KHÔNG trình tiếp CEO (vd Cấp anh Chương). */}
<label className="col-span-2 flex items-center gap-1 text-[11px] text-emerald-700">
<input
type="checkbox"
className="h-3 w-3"
checked={entry.allowApproverFinalize}
onChange={e => updateField('allowApproverFinalize', e.target.checked)}
/>
<span>Duyệt KẾT THÚC quy trình (không trình tiếp CEO)</span>
</label>
</div> </div>
</div> </div>
) )

View File

@ -150,6 +150,10 @@ export type PeSupplier = {
paymentTermText: string | null paymentTermText: string | null
note: string | null note: string | null
order: number order: number
// [Mig 58 — anh Kiệt FDC] Multi-winner: đơn vị này có thuộc tập đơn vị trúng thầu
// (≥1 đơn vị được chọn) không. Nguồn-sự-thật cho checkbox "Chọn trúng thầu" — KHÔNG
// dùng selectedSupplierId (null khi joint ≥2 winner). winnerQuoteTotal = SUM mọi isWinner.
isWinner: boolean
} }
export type PeQuote = { export type PeQuote = {
@ -399,6 +403,7 @@ export type ApprovalWorkflowOptions = {
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO)
} }
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE // Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE

View File

@ -73,14 +73,10 @@ const NCC_PALETTES = [
// nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn // nút "Lưu & Gửi Duyệt" cùng gọi để KHÔNG lệch predicate. Trả null khi chưa chọn
// NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá. // NCC; trả số (có thể 0) khi đã chọn nhưng chưa nhập báo giá.
function computeGiaChaoThau(ev: PeDetailBundle): number | null { function computeGiaChaoThau(ev: PeDetailBundle): number | null {
const winnerSupplierRowId = ev.selectedSupplierId // [Mig 58] Multi-winner: null khi CHƯA chọn đơn vị nào; else = winnerQuoteTotal (BE
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null // SUM ThanhTien MỌI đơn vị isWinner — single hoặc liên-danh ≥2). 0 = winner chưa báo giá.
: null if (!ev.suppliers.some(s => s.isWinner)) return null
if (winnerSupplierRowId === null) return null return ev.winnerQuoteTotal
return ev.details
.flatMap(d => d.quotes)
.filter(q => q.purchaseEvaluationSupplierId === winnerSupplierRowId)
.reduce((sum, q) => sum + q.thanhTien, 0)
} }
// Main detail content — flat render 3 section không tabs. // Main detail content — flat render 3 section không tabs.
@ -194,8 +190,9 @@ export function PeDetailTabs({
// Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch. // Dùng cùng computeGiaChaoThau như Section 3 để KHÔNG lệch.
const missingForApproval = useMemo(() => { const missingForApproval = useMemo(() => {
const missing: string[] = [] const missing: string[] = []
// 1. Chưa chọn Đơn vị NCC/TP // 1. Chưa chọn Đơn vị NCC/TP ([Mig 58] multi-winner: ≥1 đơn vị isWinner; selectedSupplierId
if (evaluation.selectedSupplierId == null) { // null khi liên-danh ≥2 nên KHÔNG dùng nó — mirror BE submit-guard IsWinner).
if (!evaluation.suppliers.some(s => s.isWinner)) {
missing.push("Chưa chọn Đơn vị NCC/TP") missing.push("Chưa chọn Đơn vị NCC/TP")
} else { } else {
// 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0). // 2. Đơn vị được chọn chưa có giá chào thầu (sum quotes.thanhTien ≤ 0).
@ -207,8 +204,10 @@ export function PeDetailTabs({
// Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0. // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0.
// [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) — // [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) —
// mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được). // mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được).
// [Mig 58 — anh Kiệt FDC] NS = 0 là HỢP LỆ (gói cho-không / vật tư cấp sẵn). Chỉ
// block khi CHƯA khai (null). Mirror BE submit-guard nới lỏng cùng đợt.
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
if (effBudget == null || effBudget <= 0) { if (effBudget == null) {
missing.push("Chưa nhập Ngân sách kỳ này") missing.push("Chưa nhập Ngân sách kỳ này")
} }
// 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3). // 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3).
@ -227,14 +226,14 @@ export function PeDetailTabs({
// [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan // [D4/R3 anh Kiệt — "xanh=đã khai, đỏ=chưa khai => trường bắt buộc khai"] Checklist trực-quan
// các trường BẮT BUỘC để gửi duyệt (mirror missingForApproval predicate). Hiện phía trên nút. // 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 submitChecklist = useMemo(() => {
const supplierOk = evaluation.selectedSupplierId != null const supplierOk = evaluation.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
const gia = supplierOk ? computeGiaChaoThau(evaluation) : null const gia = supplierOk ? computeGiaChaoThau(evaluation) : null
const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null
return [ return [
{ label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null }, { label: 'Quy trình duyệt', ok: evaluation.approvalWorkflowId != null },
{ label: 'Đơn vị NCC/TP được chọn', ok: supplierOk }, { 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: 'Giá chào thầu của đơn vị > 0', ok: gia != null && gia > 0 },
{ label: 'Ngân sách kỳ này', ok: effBudget != null && effBudget > 0 }, { 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) }, { label: 'Bảng so sánh đính kèm', ok: !!evaluation.attachments?.some(a => a.purchaseEvaluationSupplierId === null && a.purpose !== PeAttachmentPurpose.ApprovalAttachment) },
] ]
}, [evaluation]) }, [evaluation])
@ -929,30 +928,40 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
) )
} }
// ===== a. NCC / TP được chọn — dropdown picker (user 2026-05-07) ===== // ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] =====
// Workspace + canEdit phase: render Select dropdown từ ev.suppliers (Section 3 // Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng
// tham gia list). Read-only: hiển thị "✓ Tên NCC" hoặc "(chưa chọn)". // thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner).
// Save dùng POST /pe/:id/select-winner endpoint hiện có. // Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`;
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner).
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase) const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient() const qc = useQueryClient()
const setWinner = useMutation({ // gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker.
mutationFn: async (supplierId: string | null) => const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), const setWinners = useMutation({
mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
onSuccess: () => { onSuccess: () => {
toast.success('Đã cập nhật NCC được chọn.') toast.success('Đã cập nhật đơn vị trúng thầu.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] }) qc.invalidateQueries({ queryKey: ['pe-list'] })
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
const toggle = (supplierId: string, on: boolean) =>
setWinners.mutate(on
? [...winnerIds, supplierId]
: winnerIds.filter(id => id !== supplierId))
const busy = setWinners.isPending || peFetching
if (!canEdit) { if (!canEdit) {
const winners = ev.suppliers.filter(s => s.isWinner)
return ( return (
<FormRow <FormRow
label="a. NCC / TP được chọn" label="a. NCC / TP được chọn"
value={ev.selectedSupplierName value={winners.length > 0
? <span className="font-medium text-emerald-700"> {ev.selectedSupplierName}</span> ? <span className="font-medium text-emerald-700"> {winners.map(s => s.supplierName).join(', ')}</span>
: <span className="text-slate-400"> (chưa chọn)</span>} : <span className="text-slate-400"> (chưa chọn)</span>}
/> />
) )
@ -962,30 +971,49 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5"> <div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span> <span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span>
<div className="relative min-w-0 flex-1"> <div className="relative min-w-0 flex-1">
<Select {ev.suppliers.length === 0 ? (
value={ev.selectedSupplierId ?? ''} <p className="text-[11px] text-amber-600">
onChange={e => setWinner.mutate(e.target.value || null)} Thêm NCC Section 3 trước rồi mới chọn trúng thầu.
disabled={ev.suppliers.length === 0 || setWinner.isPending} </p>
className="text-sm" ) : (
> <div className="space-y-1">
<option value=""> Chọn NCC từ danh sách Section 3 </option> {ev.suppliers.map(s => (
{ev.suppliers.map(s => ( <label
<option key={s.id} value={s.supplierId}> key={s.id}
{s.supplierName}{s.displayName ? `${s.displayName}` : ''} className={cn(
</option> 'flex cursor-pointer items-center gap-2 rounded border px-2 py-1 text-[12px] transition',
))} s.isWinner
</Select> ? 'border-emerald-300 bg-emerald-50 font-medium text-emerald-800'
{/* Loading spinner inline khi save có delay (user 2026-05-07) */} : 'border-slate-200 bg-white hover:bg-slate-50',
{setWinner.isPending && ( )}
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600"> >
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" /> <input
<span>Đang chọn NCC + sync cột giá Section 4</span> type="checkbox"
className="h-3.5 w-3.5"
checked={s.isWinner}
disabled={busy}
onChange={e => toggle(s.supplierId, e.target.checked)}
/>
<span className="flex-1">
{s.supplierName}{s.displayName ? `${s.displayName}` : ''}
</span>
<span className="text-[11px] text-emerald-600">{s.isWinner ? 'Trúng thầu' : ''}</span>
</label>
))}
</div> </div>
)} )}
{ev.suppliers.length === 0 && ( {/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */}
<p className="mt-1 text-[11px] text-amber-600"> {winnerIds.length > 0 && (
Thêm NCC Section 3 trước rồi mới chọn winner. <div className="mt-1.5 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
</p> <span className="text-emerald-700">Tổng chi phí đã chọn ({winnerIds.length} đơn vị):</span>
<span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
</div>
)}
{busy && (
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
<span>Đang cập nhật trúng thầu + sync cột giá Section 4</span>
</div>
)} )}
</div> </div>
</div> </div>
@ -1483,7 +1511,9 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="4. Đề xuất kỳ này — Tên thầu phụ / NCC" label="4. Đề xuất kỳ này — Tên thầu phụ / NCC"
value={ value={
<span className="font-sans text-slate-700"> <span className="font-sans text-slate-700">
{ev.selectedSupplierName ?? <span className="text-slate-400"> (chưa chọn)</span>} {ev.suppliers.some(s => s.isWinner)
? ev.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ')
: <span className="text-slate-400"> (chưa chọn)</span>}
</span> </span>
} }
/> />
@ -1793,14 +1823,12 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) ===== // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.suppliers.some(s => s.isWinner)
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
// c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper // c. Giá chào thầu = sum quotes của NCC được chọn (winner). Dùng helper
// module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt. // module-level (computeGiaChaoThau) — cùng predicate với pre-check gửi duyệt.
const winnerSupplierRowId = ev.selectedSupplierId const hasWinner = ev.suppliers.some(s => s.isWinner) // [Mig 58] multi-winner
? ev.suppliers.find(s => s.supplierId === ev.selectedSupplierId)?.id ?? null
: null
const giaChaoThau = computeGiaChaoThau(ev) const giaChaoThau = computeGiaChaoThau(ev)
// d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi // d. Bản so sánh — attachments supplier-row null, NHƯNG loại file "đính kèm khi
@ -1819,7 +1847,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
<FormRow <FormRow
label="c. Giá chào thầu" label="c. Giá chào thầu"
value={ value={
winnerSupplierRowId === null ? ( !hasWinner ? (
<span className="text-slate-400"> (chọn NCC/TP (a) trước)</span> <span className="text-slate-400"> (chọn NCC/TP (a) trước)</span>
) : giaChaoThau === 0 ? ( ) : giaChaoThau === 0 ? (
<span className="text-slate-400"> (chưa nhập báo giá Section 4)</span> <span className="text-slate-400"> (chưa nhập báo giá Section 4)</span>
@ -2049,7 +2077,7 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
> >
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
NCC: <strong>{evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName} NCC: <strong>{evaluation.suppliers.filter(s => s.isWinner).map(s => s.supplierName).join(', ') || evaluation.selectedSupplierName}</strong> · Dự án: {evaluation.projectName}
</p> </p>
<div> <div>
<Label>Loại </Label> <Label>Loại </Label>
@ -2436,12 +2464,19 @@ function HangMucCard({
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const setWinner = useMutation({ // [Mig 58 anh Kiệt FDC] Multi-winner: gửi TOÀN BỘ tập ID trúng thầu (chọn ≥2 = liên
mutationFn: async (supplierId: string | null) => // danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render.
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }), const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, const setWinners = useMutation({
mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const toggleWinner = (supplierId: string, on: boolean) =>
setWinners.mutate(on
? [...winnerIds, supplierId]
: winnerIds.filter(id => id !== supplierId))
return ( return (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm"> <div className="rounded-lg border border-slate-200 bg-white shadow-sm">
@ -2537,7 +2572,7 @@ function HangMucCard({
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{ev.suppliers.map((s, idx) => { {ev.suppliers.map((s, idx) => {
const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null const q = detail.quotes.find(x => x.purchaseEvaluationSupplierId === s.id) ?? null
const isWinner = ev.selectedSupplierId === s.supplierId const isWinner = s.isWinner
const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id)) const hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes const canDelete = !isWinner && !hasQuotes
const openQuote = () => setQuoteEdit({ supplier: s, existing: q }) const openQuote = () => setQuoteEdit({ supplier: s, existing: q })
@ -2606,12 +2641,13 @@ function HangMucCard({
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5"> <div className="flex justify-end gap-0.5">
<button <button
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)} onClick={() => toggleWinner(s.supplierId, !isWinner)}
disabled={setWinners.isPending}
className={cn( className={cn(
'rounded px-1 py-0.5', 'rounded px-1 py-0.5 disabled:opacity-50',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700', isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)} )}
title={isWinner ? 'Bỏ chọn đơn vị này (click để hủy)' : 'Chọn đơn vị NCC/TP'} title={isWinner ? 'Bỏ chọn trúng thầu (chọn ≥2 = liên danh)' : 'Chọn trúng thầu (chọn ≥2 = liên danh)'}
> >
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
</button> </button>

View File

@ -110,6 +110,10 @@ export function PeWorkflowPanel({
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1] const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1] const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
const currentIsFinalApprover = lastFlowLevel?.status === 'Current' const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
// [Mig 58 item 4] Cấp Approver hiện tại được cấu hình KẾT THÚC trong Quy trình duyệt
// (admin bật per-slot ở Designer). Duyệt tới cấp này = TỰ kết thúc (BE auto theo cờ cấp,
// KHÔNG cần ô-tích). FE dùng cờ này để: (1) hiện banner cảnh báo + (2) bắt chọn giá chốt.
const approverFinalizeEligible = levelOptions?.allowApproverFinalize === true
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). // Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
const priceCandidates = ([ const priceCandidates = ([
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal }, { source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
@ -149,8 +153,9 @@ export function PeWorkflowPanel({
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null // Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai && evaluation.phase !== PurchaseEvaluationPhase.TraLai
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done. // [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm) // HOẶC [Mig 58] cấp được cấu hình KẾT THÚC (đều là điểm CHỐT giá).
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
// [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi // [S78] Người duyệt đính kèm file khi DUYỆT (forward approve). Upload TRƯỚC khi
// chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE). // chuyển phase: file lỗi (sai định dạng / >20MB) → throw, KHÔNG duyệt (toast lỗi BE).
// File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side). // File hợp lệ giữ lại (gắn phiếu, purpose=ApprovalAttachment, uploader=actor server-side).
@ -430,7 +435,7 @@ export function PeWorkflowPanel({
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` : `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
// [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn. // [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn.
const isApproveAction = !isCancel && !isSendBack const isApproveAction = !isCancel && !isSendBack
const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm) const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm || approverFinalizeEligible)
// [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates // [Mig 54 fix] Khoá "Xác nhận" khi PHẢI chọn giá mà: chưa có giá nào (candidates
// rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh // rỗng — buộc nhập PRO/CCM hoặc chọn NCC thắng thầu trước) HOẶC chưa chọn giá. Tránh
// người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge). // người duyệt bấm rồi nhận lỗi BE "Chọn 1 giá chốt" khó hiểu (empty-candidates edge).
@ -588,6 +593,16 @@ export function PeWorkflowPanel({
</label> </label>
</div> </div>
)} )}
{/* [Mig 58 item 4 — anh Kiệt FDC] Cấp được cấu hình KẾT THÚC trong Quy trình duyệt:
BE TỰ finalize khi duyệt tới đây (auto, không ô-tích). Banner báo + bắt chọn giá chốt. */}
{isApproveAction && approverFinalizeEligible && (
<div className="mb-3 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<span className="font-medium"> Cấp này KẾT THÚC quy trình</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
Đưc cấu hình sẵn trong Quy trình duyệt bạn duyệt xong phiếu "Đã duyệt", KHÔNG trình tiếp CEO. Nhớ chọn 1 giá chốt bên dưới trước khi duyệt.
</span>
</div>
)}
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done. {/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */} "Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
{shouldPickPrice && ( {shouldPickPrice && (

View File

@ -154,6 +154,10 @@ export type PeSupplier = {
paymentTermText: string | null paymentTermText: string | null
note: string | null note: string | null
order: number order: number
// [Mig 58 — anh Kiệt FDC] Multi-winner: đơn vị này có thuộc tập đơn vị trúng thầu
// (≥1 đơn vị được chọn) không. Nguồn-sự-thật cho checkbox "Chọn trúng thầu" — KHÔNG
// dùng selectedSupplierId (null khi joint ≥2 winner). winnerQuoteTotal = SUM mọi isWinner.
isWinner: boolean
} }
export type PeQuote = { export type PeQuote = {
@ -401,6 +405,7 @@ export type ApprovalWorkflowOptions = {
allowApproverEditDetails: boolean allowApproverEditDetails: boolean
allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách allowApproverEditBudget: boolean // Mig 30 (S22+5) — F4 Section ngân sách
allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối allowApproverSkipToFinal: boolean // Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối
allowApproverFinalize: boolean // [Mig 58 CCM item 4] slot này Duyệt = KẾT THÚC (không trình CEO)
} }
// Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE // Mig 28 (S21 t4) — F1 mode Trả lại payload gửi BE

View File

@ -167,7 +167,7 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
[HttpPost("{id:guid}/select-winner")] [HttpPost("{id:guid}/select-winner")]
public async Task<IActionResult> SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct) public async Task<IActionResult> SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct)
{ {
await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierId), ct); await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierIds ?? Array.Empty<Guid>()), ct);
return NoContent(); return NoContent();
} }
@ -345,7 +345,7 @@ public record AddSupplierBody(
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone, string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
string? PaymentTermText, string? Note); string? PaymentTermText, string? Note);
public record SelectWinnerBody(Guid? SupplierId); // null = bỏ chọn NCC (cho phép xóa + điền lại — anh Kiệt FDC C5) public record SelectWinnerBody(IReadOnlyList<Guid>? SupplierIds); // [Mig 58] multi-winner: tập đơn vị NCC/TP thắng; [] hoặc null = bỏ chọn (anh Kiệt FDC C5 + CCM multi)
public record DetailBody( public record DetailBody(
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh, string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,

View File

@ -38,6 +38,7 @@ public record AwLevelDto(
bool AllowApproverEditDetails, bool AllowApproverEditDetails,
bool AllowApproverEditBudget, bool AllowApproverEditBudget,
bool AllowApproverSkipToFinal, bool AllowApproverSkipToFinal,
bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
// [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy // [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
// từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO); // từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO);
// CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer. // CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
@ -184,7 +185,7 @@ public class GetAwAdminOverviewQueryHandler(
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email, return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee, l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget, l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
l.AllowApproverSkipToFinal, l.AllowApproverSkipToFinal, l.AllowApproverFinalize,
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId)); proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
}).ToList() }).ToList()
)).ToList()); )).ToList());
@ -225,7 +226,8 @@ public record CreateAwLevelInput(
bool AllowReturnToDrafter = true, bool AllowReturnToDrafter = true,
bool AllowApproverEditDetails = false, bool AllowApproverEditDetails = false,
bool AllowApproverEditBudget = false, bool AllowApproverEditBudget = false,
bool AllowApproverSkipToFinal = false); bool AllowApproverSkipToFinal = false,
bool AllowApproverFinalize = false);
public record CreateAwStepInput( public record CreateAwStepInput(
int Order, int Order,
@ -363,6 +365,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditDetails = l.AllowApproverEditDetails, AllowApproverEditDetails = l.AllowApproverEditDetails,
AllowApproverEditBudget = l.AllowApproverEditBudget, AllowApproverEditBudget = l.AllowApproverEditBudget,
AllowApproverSkipToFinal = l.AllowApproverSkipToFinal, AllowApproverSkipToFinal = l.AllowApproverSkipToFinal,
AllowApproverFinalize = l.AllowApproverFinalize,
}).ToList(), }).ToList(),
}) })
.ToList(), .ToList(),

View File

@ -51,7 +51,8 @@ public record PurchaseEvaluationSupplierDto(
string? ContactPhone, string? ContactPhone,
string? PaymentTermText, string? PaymentTermText,
string? Note, string? Note,
int Order); int Order,
bool IsWinner); // [Mig 58] đơn vị này có thuộc tập winner không (multi)
public record PurchaseEvaluationQuoteDto( public record PurchaseEvaluationQuoteDto(
Guid Id, Guid Id,
@ -115,7 +116,8 @@ public record ApprovalWorkflowOptionsDto(
bool AllowReturnToDrafter, bool AllowReturnToDrafter,
bool AllowApproverEditDetails, bool AllowApproverEditDetails,
bool AllowApproverEditBudget, bool AllowApproverEditBudget,
bool AllowApproverSkipToFinal); bool AllowApproverSkipToFinal,
bool AllowApproverFinalize); // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
public record PurchaseEvaluationWorkflowSummaryDto( public record PurchaseEvaluationWorkflowSummaryDto(
string PolicyName, string PolicyName,

View File

@ -385,11 +385,17 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
} }
} }
// ========== Select winner (NCC được chọn tổng thể) ========== // ========== Select winner (NCC/TP được chọn tổng thể — MULTI) ==========
// SupplierId nullable [anh Kiệt FDC C5]: null = BỎ CHỌN (xóa đơn vị đã chọn, điền // [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP cùng
// lại) — un-select winner về none; có giá trị = chọn/đổi NCC (logic cũ giữ 100%). // trúng (whole-supplier multi-select). SupplierIds = tập đơn vị thắng:
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid? SupplierId) : IRequest; // - List rỗng [] = BỎ CHỌN tất cả (un-select về none) — thay null cũ (anh Kiệt C5).
// - 1 phần tử = 1 winner (SelectedSupplierId sync = đơn vị đó — backward compat).
// - ≥2 phần tử = joint winner (SelectedSupplierId = null vì không còn "1 winner").
// IsWinner trên supplier-row là nguồn-sự-thật mới; winnerQuoteTotal = SUM ThanhTien
// MỌI winner-row. Mỗi id phải nằm trong danh sách NCC tham gia của phiếu.
public record SelectPurchaseEvaluationWinnerCommand(
Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds) : IRequest;
public class SelectPurchaseEvaluationWinnerCommandHandler( public class SelectPurchaseEvaluationWinnerCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
@ -400,28 +406,30 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct) var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
if (request.SupplierId is Guid supplierId) // Load mọi supplier-row của phiếu (tracking — để set IsWinner).
{ var rows = await db.PurchaseEvaluationSuppliers
// ── Chọn / đổi NCC trúng thầu (logic cũ giữ nguyên 100%) ── .Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId)
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct) .ToListAsync(ct);
?? throw new NotFoundException("Supplier", supplierId);
// Verify supplier nằm trong danh sách phiếu // Distinct các id được chọn (FE có thể gửi trùng).
var hasSupplier = await db.PurchaseEvaluationSuppliers var selectedIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>();
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == supplierId, ct);
if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
entity.SelectedSupplierId = supplierId; // Mỗi id chọn phải là 1 đơn vị tham gia phiếu (chặn forge/stale).
} var participatingSupplierIds = rows.Select(r => r.SupplierId).ToHashSet();
else foreach (var sid in selectedIds)
{ {
// ── Bỏ chọn (clear về none) — KHÔNG validate participating list ── if (!participatingSupplierIds.Contains(sid))
// Row 4 "Giá trị kỳ này" + winnerQuoteTotal tính-khi-đọc theo throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
// SelectedSupplierId (PurchaseEvaluationFeatures GetDetail) → tự về 0.
// ApprovedPriceAmount/Source là cột chốt giai-đoạn DaDuyet, KHÔNG đụng ở đây.
entity.SelectedSupplierId = null;
} }
// Set IsWinner per-row: true nếu nằm trong tập chọn, false phần còn lại.
foreach (var r in rows)
r.IsWinner = selectedIds.Contains(r.SupplierId);
// Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng
// tạo HĐ + DTO cũ); ≥2 hoặc 0 → null.
entity.SelectedSupplierId = selectedIds.Count == 1 ? selectedIds.First() : null;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {
PurchaseEvaluationId = entity.Id, PurchaseEvaluationId = entity.Id,
@ -430,7 +438,9 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
PhaseAtChange = entity.Phase, PhaseAtChange = entity.Phase,
UserId = currentUser.UserId, UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email, UserName = currentUser.FullName ?? currentUser.Email,
Summary = request.SupplierId is null ? "Bỏ chọn NCC trúng thầu" : "Chọn NCC trúng thầu", Summary = selectedIds.Count == 0
? "Bỏ chọn đơn vị NCC/TP trúng thầu"
: $"Chọn {selectedIds.Count} đơn vị NCC/TP trúng thầu",
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@ -52,7 +52,7 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson). // [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
RuleFor(x => x.HoSoLink).MaximumLength(1000); RuleFor(x => x.HoSoLink).MaximumLength(1000);
// [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn. // [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn.
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0) RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0) // [Mig 58 item 2] 0 hợp lệ
.When(x => x.BudgetPeriodAmount.HasValue) .When(x => x.BudgetPeriodAmount.HasValue)
.WithMessage("Ngân sách kỳ này phải lớn hơn 0."); .WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
} }
@ -230,7 +230,7 @@ public class UpdatePurchaseEvaluationDraftCommandValidator : AbstractValidator<U
RuleFor(x => x.MoTa).MaximumLength(2000); RuleFor(x => x.MoTa).MaximumLength(2000);
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson). // [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
RuleFor(x => x.HoSoLink).MaximumLength(1000); RuleFor(x => x.HoSoLink).MaximumLength(1000);
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0) RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0) // [Mig 58 item 2] 0 hợp lệ
.When(x => x.BudgetPeriodAmount.HasValue) .When(x => x.BudgetPeriodAmount.HasValue)
.WithMessage("Ngân sách kỳ này phải lớn hơn 0."); .WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
} }
@ -336,12 +336,13 @@ public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator<
{ {
public AdjustPurchaseEvaluationBudgetCommandValidator() public AdjustPurchaseEvaluationBudgetCommandValidator()
{ {
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0).When(x => x.BudgetPeriodAmount.HasValue); RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetPeriodAmount.HasValue); // [Mig 58 item 2] 0 hợp lệ
// [S62] "Giá trị thực hiện dự kiến còn lại" (row 8) CHO PHÉP ÂM. Khi giá trị NCC // [S62] "Giá trị thực hiện dự kiến còn lại" (row 8) CHO PHÉP ÂM. Khi giá trị NCC
// vượt ngân sách → số dư còn lại tự ra âm; nghiệp vụ (anh Kiệt FDC 2026-06-13) // vượt ngân sách → số dư còn lại tự ra âm; nghiệp vụ (anh Kiệt FDC 2026-06-13)
// yêu cầu VẪN lưu được, chỉ cảnh báo (FE banner "Vượt ngân sách"). Gỡ ràng buộc // yêu cầu VẪN lưu được, chỉ cảnh báo (FE banner "Vượt ngân sách"). Gỡ ràng buộc
// GreaterThanOrEqualTo(0) cũ — mirror tiền lệ LeaveBalance (cho phép số dư âm). // GreaterThanOrEqualTo(0) cũ — mirror tiền lệ LeaveBalance (cho phép số dư âm).
// BudgetPeriodAmount vẫn phải > 0; submit guard "đã nhập NS kỳ này" không đổi. // [Mig 58 item 2 CCM] BudgetPeriodAmount cho phép = 0 (SSG ngân sách 0 hợp lệ); submit
// guard chỉ chặn khi NULL (chưa nhập gì), KHÔNG chặn 0 cố ý.
} }
} }
@ -958,7 +959,8 @@ public class GetPurchaseEvaluationQueryHandler(
curLevel.AllowReturnToDrafter, curLevel.AllowReturnToDrafter,
curLevel.AllowApproverEditDetails, curLevel.AllowApproverEditDetails,
curLevel.AllowApproverEditBudget, curLevel.AllowApproverEditBudget,
curLevel.AllowApproverSkipToFinal); curLevel.AllowApproverSkipToFinal,
curLevel.AllowApproverFinalize);
} }
} }
@ -1083,7 +1085,7 @@ public class GetPurchaseEvaluationQueryHandler(
// Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi // Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi
// chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO". // chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO".
var winnerSupplierRowIds = e.Suppliers var winnerSupplierRowIds = e.Suppliers
.Where(s => s.SupplierId == e.SelectedSupplierId) .Where(s => s.IsWinner) // [Mig 58] multi-winner: tổng MỌI đơn vị IsWinner
.Select(s => s.Id) .Select(s => s.Id)
.ToHashSet(); .ToHashSet();
var winnerQuoteTotal = e.Details var winnerQuoteTotal = e.Details
@ -1119,7 +1121,7 @@ public class GetPurchaseEvaluationQueryHandler(
s.Id, s.SupplierId, s.Id, s.SupplierId,
suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "", suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "",
s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone, s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone,
s.PaymentTermText, s.Note, s.Order)) s.PaymentTermText, s.Note, s.Order, s.IsWinner))
.ToList(), .ToList(),
e.Details e.Details
.OrderBy(d => d.Order) .OrderBy(d => d.Order)

View File

@ -130,5 +130,16 @@ public class ApprovalWorkflowLevel : BaseEntity
/// reinforced 3× cumulative. NO BACKFILL — 4 prod user lose value cũ per bro Option A. /// reinforced 3× cumulative. NO BACKFILL — 4 prod user lose value cũ per bro Option A.
public bool AllowApproverSkipToFinal { get; set; } public bool AllowApproverSkipToFinal { get; set; }
/// F5 (Mig 58 2026-06-24 — CCM/anh Kiệt FDC) — Cho phép NV slot này, khi duyệt
/// ở cấp của mình, KẾT THÚC quy trình luôn (Phase=DaDuyet) KHÔNG cần trình CEO/
/// các Bước-Cấp sau. Admin opt-in per slot trong Workflow Designer ("Duyệt là
/// kết thúc (không trình CEO)"). Default false. KHÁC AllowApproverSkipToFinal
/// (skip = advance pointer tới Cấp cuối, CEO vẫn ký thật): F5 = chính NV này
/// approve = terminal NGAY. Service ApproveV2Async TỰ finalize khi
/// matchingLevel.AllowApproverFinalize=true (AUTO theo cấu hình quy trình, anh Kiệt
/// chốt 2026-06-24 — KHÔNG ô-tích runtime; admin per-level designation = cổng duy
/// nhất, KHÔNG threshold/role check). Mirror F2/F3/F4 admin opt-in per-slot pattern.
public bool AllowApproverFinalize { get; set; }
public ApprovalWorkflowStep? Step { get; set; } public ApprovalWorkflowStep? Step { get; set; }
} }

View File

@ -18,5 +18,14 @@ public class PurchaseEvaluationSupplier : BaseEntity
public string? Note { get; set; } // Chip trạng thái so sánh public string? Note { get; set; } // Chip trạng thái so sánh
public int Order { get; set; } // Thứ tự cột trong bảng so sánh public int Order { get; set; } // Thứ tự cột trong bảng so sánh
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP
// cùng trúng (whole-supplier multi-select). IsWinner=true → báo giá của đơn vị
// này tính vào "Giá trị kỳ này" (winnerQuoteTotal = SUM ThanhTien của MỌI
// winner). Thay tiêu chí cũ "SupplierId == SelectedSupplierId" (1 winner) bằng
// IsWinner trên supplier-row = nguồn-sự-thật mới. SelectedSupplierId vẫn sync:
// = winner duy nhất khi đúng 1, null khi ≥2 (multi). Backfill Mig 58:
// IsWinner=1 cho row khớp SelectedSupplierId cũ (single-winner phiếu UAT giữ).
public bool IsWinner { get; set; }
public PurchaseEvaluation? PurchaseEvaluation { get; set; } public PurchaseEvaluation? PurchaseEvaluation { get; set; }
} }

View File

@ -93,5 +93,10 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
// slot này skip thẳng Cấp cuối lúc đang duyệt ChoDuyet. Default false // slot này skip thẳng Cấp cuối lúc đang duyệt ChoDuyet. Default false
// (admin opt-in). Semantic + storage cũ ở Users table per-Drafter đã drop. // (admin opt-in). Semantic + storage cũ ở Users table per-Drafter đã drop.
e.Property(x => x.AllowApproverSkipToFinal).HasDefaultValue(false); e.Property(x => x.AllowApproverSkipToFinal).HasDefaultValue(false);
// Mig 58 (2026-06-24 — CCM/anh Kiệt FDC) — F5 per-NV: cho phép NV slot này
// duyệt = KẾT THÚC quy trình (Phase=DaDuyet) miễn trình CEO. Default false
// (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay.
e.Property(x => x.AllowApproverFinalize).HasDefaultValue(false);
} }
} }

View File

@ -82,6 +82,9 @@ public class PurchaseEvaluationSupplierConfiguration : IEntityTypeConfiguration<
b.Property(x => x.PaymentTermText).HasMaxLength(200); b.Property(x => x.PaymentTermText).HasMaxLength(200);
b.Property(x => x.Note).HasMaxLength(500); b.Property(x => x.Note).HasMaxLength(500);
// [Mig 58] Multi-winner flag — default false (mirror per-slot bool pattern).
b.Property(x => x.IsWinner).HasDefaultValue(false);
b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique(); b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique();
b.HasIndex(x => x.SupplierId); b.HasIndex(x => x.SupplierId);
} }

View File

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeMultiWinnerAndLevelFinalize : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsWinner",
table: "PurchaseEvaluationSuppliers",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowApproverFinalize",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
// [Mig 58] Backfill IsWinner từ winner đơn lẻ cũ (SelectedSupplierId) —
// phiếu UAT đang chạy giữ winner đã chọn dưới mô hình multi-winner mới.
// Chạy SAU AddColumn (cột đã tồn tại). Idempotent: chỉ set 1 (default 0
// không đụng) → re-run no-op. KHÔNG un-backfill ở Down (DropColumn xoá hẳn).
migrationBuilder.Sql(@"
UPDATE pes SET IsWinner = 1
FROM PurchaseEvaluationSuppliers pes
INNER JOIN PurchaseEvaluations pe ON pe.Id = pes.PurchaseEvaluationId
WHERE pe.SelectedSupplierId IS NOT NULL AND pes.SupplierId = pe.SelectedSupplierId;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsWinner",
table: "PurchaseEvaluationSuppliers");
migrationBuilder.DropColumn(
name: "AllowApproverFinalize",
table: "ApprovalWorkflowLevels");
}
}
}

View File

@ -202,6 +202,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasColumnType("bit") .HasColumnType("bit")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<bool>("AllowApproverFinalize")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowApproverSkipToFinal") b.Property<bool>("AllowApproverSkipToFinal")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("bit") .HasColumnType("bit")
@ -5275,6 +5280,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("nvarchar(200)"); .HasColumnType("nvarchar(200)");
b.Property<bool>("IsWinner")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<string>("Note") b.Property<string>("Note")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("nvarchar(500)"); .HasColumnType("nvarchar(500)");

View File

@ -172,28 +172,25 @@ public class PurchaseEvaluationWorkflowService(
// missingForApproval (FE KHÔNG check Purpose enum → BE cũng không, // missingForApproval (FE KHÔNG check Purpose enum → BE cũng không,
// tránh mismatch 2 tầng). // tránh mismatch 2 tầng).
var missing = new List<string>(); var missing = new List<string>();
if (evaluation.SelectedSupplierId is not Guid winnerId) // [Mig 58 CCM item 1] Multi-winner: winner = các supplier-row IsWinner (≥1). Tổng giá
// gói = SUM ThanhTien MỌI winner-row (vd 3 đơn vị 37.8+9+10=56.8tr). 0 winner →
// "chưa chọn"; có winner nhưng tổng ≤ 0 → "chưa có giá chào thầu" (item 2 chỉ nới
// NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá).
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner)
.Select(s => s.Id)
.ToListAsync(ct);
if (winnerRowIds.Count == 0)
{ {
missing.Add("chưa chọn Đơn vị NCC/TP"); missing.Add("chưa chọn Đơn vị NCC/TP");
} }
else else
{ {
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking() var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.SupplierId == winnerId) .Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.Select(s => s.Id) .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
.ToListAsync(ct); if (winnerQuoteTotal <= 0)
if (winnerRowIds.Count == 0) missing.Add("Đơn vị được chọn chưa có giá chào thầu");
{
missing.Add("Đơn vị được chọn không còn trong danh sách NCC tham gia");
}
else
{
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
if (winnerQuoteTotal <= 0)
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
}
} }
// [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này" // [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này"
// (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval // (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
@ -202,15 +199,18 @@ public class PurchaseEvaluationWorkflowService(
// (PeWorkItemBudget.ProInitialAmount per Dự-án×Hạng-mục). Effective = BudgetPeriodAmount // (PeWorkItemBudget.ProInitialAmount per Dự-án×Hạng-mục). Effective = BudgetPeriodAmount
// ?? ProInitialAmount → phiếu chưa nhập tay row3 nhưng đã có ban-hành PRO vẫn gửi-duyệt được // ?? ProInitialAmount → phiếu chưa nhập tay row3 nhưng đã có ban-hành PRO vẫn gửi-duyệt được
// (FE missingForApproval mirror cùng fallback — đổi đồng bộ 2 tầng). // (FE missingForApproval mirror cùng fallback — đổi đồng bộ 2 tầng).
// [Mig 58 item 2 CCM] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn gửi duyệt được). Chỉ
// fallback ProInitial khi drafter CHƯA nhập (null) — KHÔNG override số 0 cố ý thành
// ProInitial rồi vô tình null-block. Block cuối chỉ fire khi null; 0 (cố ý) đi qua.
decimal? effectiveBudget = evaluation.BudgetPeriodAmount; decimal? effectiveBudget = evaluation.BudgetPeriodAmount;
if ((effectiveBudget is null || effectiveBudget <= 0) && evaluation.WorkItemId is Guid wiId) if (effectiveBudget is null && evaluation.WorkItemId is Guid wiId)
{ {
effectiveBudget = await db.PeWorkItemBudgets.AsNoTracking() effectiveBudget = await db.PeWorkItemBudgets.AsNoTracking()
.Where(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiId) .Where(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiId)
.Select(b => b.ProInitialAmount) .Select(b => b.ProInitialAmount)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
} }
if (effectiveBudget is null || effectiveBudget <= 0) if (effectiveBudget is null)
{ {
missing.Add("chưa nhập Ngân sách kỳ này"); missing.Add("chưa nhập Ngân sách kỳ này");
} }
@ -851,6 +851,31 @@ public class PurchaseEvaluationWorkflowService(
// (winnerQuoteTotal = SUM báo giá NCC được chọn) < ngưỡng. Tích mà KHÔNG đủ điều // (winnerQuoteTotal = SUM báo giá NCC được chọn) < ngưỡng. Tích mà KHÔNG đủ điều
// kiện → Conflict/Forbidden. KHÔNG tích → bỏ block, advance bình thường lên CEO. // kiện → Conflict/Forbidden. KHÔNG tích → bỏ block, advance bình thường lên CEO.
// Opinion + approval row đã ghi ở trên → finalize giữ đủ vết. ① giá chốt: ApplyApprovedPriceOnFinalize. // Opinion + approval row đã ghi ở trên → finalize giữ đủ vết. ① giá chốt: ApplyApprovedPriceOnFinalize.
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC item 4] Cấp được cấu hình KẾT THÚC trong Quy
// trình duyệt (admin bật AllowApproverFinalize cho slot, vd Cấp anh Chương). Duyệt tới
// cấp này = TỰ kết thúc (DaDuyet), bỏ qua MỌI Bước/Cấp còn lại (kể cả CEO) — quy-trình-
// driven, KHÔNG cần ô-tích runtime (anh Kiệt chốt 2026-06-24 "cho bật trong quy trình
// hay hơn"). KHÔNG threshold/role-gate (admin chỉ-định slot = đủ thẩm quyền). ① giá chốt:
// người duyệt phải chọn (Conflict nếu thiếu, isSystem miễn). Đặt SAU skipToFinal, TRƯỚC
// ccm-delegation + advance.
if (matchingLevel.AllowApproverFinalize)
{
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource);
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
await LogTransitionAsync(
evaluation,
PurchaseEvaluationPhase.ChoDuyet,
PurchaseEvaluationPhase.DaDuyet,
actorUserId,
ApprovalDecision.Approve,
$"[Duyệt KẾT THÚC tại Bước {currentIdx + 1} Cấp {currentLevelOrder} — cấu hình quy trình, không trình CEO] {comment ?? ""}".Trim(),
ct);
return;
}
if (finalizeByCcmDelegation) if (finalizeByCcmDelegation)
{ {
if (aw.CeoApprovalThreshold is not decimal ceoThreshold) if (aw.CeoApprovalThreshold is not decimal ceoThreshold)
@ -861,8 +886,7 @@ public class PurchaseEvaluationWorkflowService(
"Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO."); "Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO.");
var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking() var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id .Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) // [Mig 58] multi-winner
&& s.SupplierId == evaluation.SelectedSupplierId)
.Select(s => s.Id) .Select(s => s.Id)
.ToListAsync(ct); .ToListAsync(ct);
var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m

View File

@ -0,0 +1,231 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-before/after] WinnerQuoteTotal multi-winner.
// item 1: winnerQuoteTotal = SUM(báo giá ThanhTien) MỌI supplier-row IsWinner — KHÔNG còn
// theo SelectedSupplierId (nguồn-sự-thật mới = IsWinner). Verify qua GetDetail read DTO
// (GetPurchaseEvaluationQueryHandler → PurchaseEvaluationDetailBundleDto.WinnerQuoteTotal,
// PurchaseEvaluationFeatures.cs ~1090-1097): build winnerSupplierRowIds = Suppliers IsWinner
// → SUM Details.Quotes thuộc các row đó.
//
// Harness mirror PeWorkItemBudgetTests (IdentityFixture + SQLite + GetPurchaseEvaluationQuery
// stack). KHÔNG touch production — test theo CODE đã land (S34). Query handler 3-dep
// (db + UserManager + ICurrentUser); dùng Admin để bỏ qua authz scope.
public class PeMultiWinnerTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid? UserId { get; init; } = Guid.NewGuid();
public string? Email { get; init; } = "actor@test.local";
public string? FullName { get; init; } = "Actor Test";
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
public bool IsAuthenticated => UserId is not null;
}
private static FakeCurrentUser AsRoles(params string[] roles) => new() { Roles = roles };
private static GetPurchaseEvaluationQueryHandler BuildQueryHandler(
IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser)
=> new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser);
private static async Task<PurchaseEvaluation> SeedPeAsync(TestApplicationDbContext db, string code = "PE-MW")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.DangSoanThao,
MaPhieu = code,
TenGoiThau = "Gói thầu multi-winner",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// 1 detail dùng chung cho mọi NCC (mỗi NCC 1 quote thuộc detail này).
private static async Task<Guid> SeedDetailAsync(TestApplicationDbContext db, PurchaseEvaluation pe)
{
var detail = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
GroupCode = "A.I",
GroupName = "Bê tông",
NoiDung = "Concrete M100",
Order = 0,
};
db.PurchaseEvaluationDetails.Add(detail);
await db.SaveChangesAsync(CancellationToken.None);
return detail.Id;
}
// 1 NCC tham gia (master + PES row) + 1 quote ThanhTien thuộc detail. isWinner pin
// IsWinner trên PES row. Return supplierId master.
private static async Task<Guid> SeedSupplierWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid detailId,
string code, decimal thanhTien, bool isWinner, int order)
{
var supplier = new Supplier { Id = Guid.NewGuid(), Code = code, Name = "NCC " + code };
db.Suppliers.Add(supplier);
var pes = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
SupplierId = supplier.Id,
Order = order,
IsWinner = isWinner,
};
db.PurchaseEvaluationSuppliers.Add(pes);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detailId,
PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien,
});
await db.SaveChangesAsync(CancellationToken.None);
return supplier.Id;
}
[Fact]
public async Task WinnerQuoteTotal_ThreeWinners_SumsAllThreeThanhTien()
{
// ⭐ TRỤC CỐT LÕI item 1: 3 NCC cùng trúng (IsWinner=true) → WinnerQuoteTotal =
// SUM cả 3 báo giá (37.8 + 9 + 10 = 56.8tr), KHÔNG chỉ 1 đơn vị.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-MW-3WIN");
var detailId = await SeedDetailAsync(db, pe);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-A", 37_800_000m, isWinner: true, order: 0);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-B", 9_000_000m, isWinner: true, order: 1);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-C", 10_000_000m, isWinner: true, order: 2);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundle.WinnerQuoteTotal.Should().Be(56_800_000m, "tổng giá gói = SUM báo giá MỌI đơn vị IsWinner");
bundle.SelectedSupplierId.Should().BeNull("≥2 winner → SelectedSupplierId không sync 1 đơn vị");
}
[Fact]
public async Task WinnerQuoteTotal_ExcludesNonWinnerSuppliers()
{
// 4 NCC tham gia, chỉ 2 IsWinner=true → WinnerQuoteTotal CHỈ tính 2 winner
// (100 + 50 = 150tr), bỏ qua 2 NCC không trúng (200 + 300tr KHÔNG cộng).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-MW-EXCL");
var detailId = await SeedDetailAsync(db, pe);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-W1", 100_000_000m, isWinner: true, order: 0);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-W2", 50_000_000m, isWinner: true, order: 1);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-L1", 200_000_000m, isWinner: false, order: 2);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-L2", 300_000_000m, isWinner: false, order: 3);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundle.WinnerQuoteTotal.Should().Be(150_000_000m, "chỉ SUM winner — NCC không trúng bị loại");
}
[Fact]
public async Task WinnerQuoteTotal_SingleWinner_EqualsThatSupplierQuote_SelectedSupplierIdSet()
{
// 1 winner (single-winner backward compat) → WinnerQuoteTotal = đúng báo giá đơn
// vị đó + SelectedSupplierId sync = winner (handler select-winner set khi count==1).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-MW-1WIN");
var detailId = await SeedDetailAsync(db, pe);
var winner = await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-SOLO", 75_000_000m, isWinner: true, order: 0);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-OTHER", 80_000_000m, isWinner: false, order: 1);
// Mô phỏng handler select-winner sync SelectedSupplierId khi đúng 1 winner.
pe.SelectedSupplierId = winner;
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(75_000_000m, "1 winner → tổng = đúng báo giá đơn vị đó");
bundle.SelectedSupplierId.Should().Be(winner);
}
[Fact]
public async Task WinnerQuoteTotal_NoWinner_IsZero()
{
// Chưa chọn winner nào (mọi PES IsWinner=false) → WinnerQuoteTotal = 0 (KHÔNG
// lẫn báo giá các NCC tham gia chưa-trúng).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-MW-NONE");
var detailId = await SeedDetailAsync(db, pe);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-X", 40_000_000m, isWinner: false, order: 0);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-Y", 60_000_000m, isWinner: false, 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(0m, "0 winner → tổng gói = 0");
}
[Fact]
public async Task WinnerQuoteTotal_MultipleQuotesPerWinner_SumsAllRows()
{
// 1 winner nhưng có 2 detail (2 dòng báo giá thuộc cùng PES winner) → SUM cả 2
// dòng (20 + 30 = 50tr). Verify SelectMany Details.Quotes gộp mọi dòng của winner-row.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-MW-MULTQ");
// 2 detail rời.
var detail1 = await SeedDetailAsync(db, pe);
var detail2 = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(), PurchaseEvaluationId = pe.Id,
GroupCode = "A.II", GroupName = "Thép", NoiDung = "Steel D10", Order = 1,
};
db.PurchaseEvaluationDetails.Add(detail2);
await db.SaveChangesAsync(CancellationToken.None);
// 1 winner PES + 2 quote (mỗi detail 1 dòng).
var supplier = new Supplier { Id = Guid.NewGuid(), Code = "NCC-2Q", Name = "NCC 2 dòng" };
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 = 20_000_000m,
});
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id,
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 30_000_000m,
});
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, "SUM mọi dòng báo giá của winner-row qua nhiều detail");
}
}

View File

@ -10,15 +10,21 @@ using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application; namespace SolutionErp.Infrastructure.Tests.Application;
// [anh Kiệt FDC C5 — test-after] SelectPurchaseEvaluationWinnerCommand: NCC winner // [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-after] SelectPurchaseEvaluationWinnerCommand
// nullable hoá. Command record `(Guid PurchaseEvaluationId, Guid? SupplierId)`. // MULTI-WINNER. Command record ĐỔI `(Guid PurchaseEvaluationId, Guid? SupplierId)`
// Handler (PurchaseEvaluationDetailFeatures.cs ~388-438) 2 nhánh theo SupplierId: // `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)`.
// • `SupplierId is Guid x` → CHỌN/ĐỔI (logic cũ giữ 100%): NotFound nếu supplier // Handler (PurchaseEvaluationDetailFeatures.cs ~404-447):
// không có ở master + Conflict nếu supplier KHÔNG thuộc participating-list của // • Load MỌI supplier-row của phiếu (tracking) → set IsWinner = (row.SupplierId ∈
// phiếu (PurchaseEvaluationSuppliers) + set entity.SelectedSupplierId = x. // selectedIds), false phần còn lại. IsWinner trên supplier-row = nguồn-sự-thật mới.
// • `SupplierId == null` → BỎ CHỌN (clear về none): set SelectedSupplierId = null, // • selectedIds rỗng [] = BỎ CHỌN tất cả (IsWinner=false hết) — thay null cũ (anh Kiệt C5).
// BỎ QUA validate participating-list (nhánh MỚI — trục test quan trọng nhất). // • Mỗi id chọn PHẢI thuộc participating-list (PurchaseEvaluationSuppliers) → Conflict.
// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary phân theo null vs có-giá-trị. // • Distinct id (FE có thể gửi trùng).
// • Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng tạo HĐ +
// DTO cũ); ≥2 HOẶC 0 → null.
// • KHÔNG còn check master Suppliers existence (logic mới chỉ validate participating-list)
// — NotFound master-existence bỏ; chỉ Conflict khi id không thuộc list của phiếu.
// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary "Bỏ chọn đơn vị NCC/TP trúng thầu"
// (count==0) vs "Chọn {n} đơn vị NCC/TP trúng thầu".
// //
// Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ // Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name. // (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name.
@ -67,74 +73,97 @@ public class PeSelectWinnerClearTests
} }
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu. // Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu.
private static async Task AddParticipatingAsync( // Return PES row Id để assert IsWinner.
private static async Task<Guid> AddParticipatingAsync(
TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0) TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
{ {
db.PurchaseEvaluationSuppliers.Add(new PurchaseEvaluationSupplier var row = new PurchaseEvaluationSupplier
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
PurchaseEvaluationId = peId, PurchaseEvaluationId = peId,
SupplierId = supplierId, SupplierId = supplierId,
Order = order, Order = order,
}); };
db.PurchaseEvaluationSuppliers.Add(row);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
return row.Id;
} }
// Re-read IsWinner cho 1 supplier-row (theo SupplierId) — AsNoTracking để né cache.
private static async Task<bool> IsWinnerAsync(
TestApplicationDbContext db, Guid peId, Guid supplierId)
=> await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == peId && s.SupplierId == supplierId)
.Select(s => s.IsWinner)
.FirstAsync();
// ============================================================ // ============================================================
// 1. CLEAR branch (MỚI — trục quan trọng nhất) // 1. CLEAR branch ([] = bỏ chọn tất cả — trục quan trọng nhất)
// ============================================================ // ============================================================
[Fact] [Fact]
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull() public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull_AllRowsNotWinner()
{ {
// ⭐ Phiếu đang có SelectedSupplierId = winner (s1) + s1 VẪN nằm trong // ⭐ Phiếu đang có 1 winner (s1, IsWinner=true) + s1 trong participating-list →
// participating-list → gọi SupplierId=null → clear về null, KHÔNG ném. // gọi SupplierIds=[] → clear: SelectedSupplierId=null + s1.IsWinner=false.
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A"); var s1 = await SeedSupplierAsync(db, "NCC-A");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id); await AddParticipatingAsync(db, pe.Id, s1.Id);
// Pre-set s1 = winner (mô phỏng phiếu đã chọn trước).
var row = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
row.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
await handler.Handle(cmd, CancellationToken.None); await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none"); reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none");
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear → mọi supplier-row IsWinner=false");
} }
[Fact] [Fact]
public async Task Clear_WinnerNotInParticipatingList_StillClears_NoConflict() public async Task Clear_EmptyList_AllRowsNotWinner_NoConflict()
{ {
// Trục cốt lõi nhánh clear: SKIP validate participating-list. Phiếu có // Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không
// SelectedSupplierId = s1 (winner cũ) nhưng s1 KHÔNG còn trong list (vd bị // có id cần kiểm). Phiếu có 2 NCC tham gia, s1 đang là winner → clear [] →
// gỡ khỏi bảng so sánh). Clear vẫn phải thành công, KHÔNG ném Conflict/NotFound. // KHÔNG ném + cả 2 row IsWinner=false + SelectedSupplierId=null.
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-GONE"); var s1 = await SeedSupplierAsync(db, "NCC-1");
var s2 = await SeedSupplierAsync(db, "NCC-2");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
// CỐ Ý không AddParticipating → s1 không thuộc list của phiếu. await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
r1.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
var act = async () => await handler.Handle(cmd, CancellationToken.None); var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().NotThrowAsync("clear KHÔNG validate participating-list"); await act.Should().NotThrowAsync("clear [] KHÔNG validate participating-list");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull(); reloaded.SelectedSupplierId.Should().BeNull();
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse();
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
} }
[Fact] [Fact]
public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow() public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
{ {
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null) → clear lần nữa = // Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không row IsWinner) →
// idempotent, vẫn null, không ném. Empty participating-list cũng không cản. // clear [] lần nữa = idempotent, vẫn null, không ném. Empty list cũng không cản.
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var pe = await SeedPeAsync(db, selectedSupplierId: null); var pe = await SeedPeAsync(db, selectedSupplierId: null);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
var act = async () => await handler.Handle(cmd, CancellationToken.None); var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().NotThrowAsync(); await act.Should().NotThrowAsync();
@ -145,34 +174,35 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Clear_WritesChangelog_WithBoChonSummary() public async Task Clear_WritesChangelog_WithBoChonSummary()
{ {
// Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn NCC // Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn đơn vị
// trúng thầu" (phân biệt với "Chọn NCC trúng thầu" của nhánh select). // NCC/TP trúng thầu" (phân biệt với "Chọn {n} đơn vị..." của nhánh select).
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A"); var s1 = await SeedSupplierAsync(db, "NCC-A");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
await handler.Handle(cmd, CancellationToken.None); await handler.Handle(cmd, CancellationToken.None);
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking() var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == pe.Id) .Where(c => c.PurchaseEvaluationId == pe.Id)
.SingleAsync(); .SingleAsync();
log.Summary.Should().Be("Bỏ chọn NCC trúng thầu"); log.Summary.Should().Be("Bỏ chọn đơn vị NCC/TP trúng thầu");
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header); log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
log.Action.Should().Be(ChangelogAction.Update); log.Action.Should().Be(ChangelogAction.Update);
} }
// ============================================================ // ============================================================
// 2. SELECT branch (regression — logic cũ giữ nguyên 100%) // 2. SELECT branch — 1 winner (backward compat single-winner)
// ============================================================ // ============================================================
[Fact] [Fact]
public async Task Select_SupplierInParticipatingList_SetsSelectedSupplierId() public async Task Select_OneSupplierInList_SetsSelectedSupplierId_RowIsWinner()
{ {
// Happy-path cũ: supplier hợp lệ (tồn tại master + thuộc participating-list) // Happy-path single-winner: 1 supplier hợp lệ (thuộc participating-list)
// → set SelectedSupplierId = đúng id. // SelectedSupplierId = đúng id (count==1 sync) + row IsWinner=true.
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-WIN"); var s1 = await SeedSupplierAsync(db, "NCC-WIN");
@ -180,18 +210,19 @@ public class PeSelectWinnerClearTests
await AddParticipatingAsync(db, pe.Id, s1.Id); await AddParticipatingAsync(db, pe.Id, s1.Id);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s1.Id); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id });
await handler.Handle(cmd, CancellationToken.None); await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s1.Id); reloaded.SelectedSupplierId.Should().Be(s1.Id, "đúng 1 winner → SelectedSupplierId sync");
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
} }
[Fact] [Fact]
public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2() public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2_OnlyS2IsWinner()
{ {
// Đổi winner: phiếu đang chọn s1 → chọn s2 (cả 2 trong list) → SelectedSupplierId // Đổi winner: phiếu đang chọn s1 → chọn [s2] (cả 2 trong list) → SelectedSupplierId
// chuyển s2 (chứng minh nhánh select overwrite, không chỉ set-from-null). // chuyển s2 + s2.IsWinner=true + s1.IsWinner=false (set false phần còn lại).
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-1"); var s1 = await SeedSupplierAsync(db, "NCC-1");
@ -199,68 +230,217 @@ public class PeSelectWinnerClearTests
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
// s1 đang là winner.
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
r1.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s2.Id); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s2.Id });
await handler.Handle(cmd, CancellationToken.None); await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s2.Id); reloaded.SelectedSupplierId.Should().Be(s2.Id);
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue("s2 là winner mới");
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("winner cũ s1 bị set false");
} }
[Fact] [Fact]
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate() public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
{ {
// Supplier tồn tại ở master NHƯNG chưa được thêm vào phiếu → Conflict. // Supplier không thuộc participating-list của phiếu → Conflict. IsWinner +
// entity.SelectedSupplierId giữ nguyên (throw TRƯỚC khi gán + SaveChanges). // SelectedSupplierId giữ nguyên (throw TRƯỚC khi set + SaveChanges).
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list
var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id); var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id); await AddParticipatingAsync(db, pe.Id, s1.Id);
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
r1.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: outsider.Id); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { outsider.Id });
var act = async () => await handler.Handle(cmd, CancellationToken.None); var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>() await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*NCC chưa được thêm vào phiếu*"); .WithMessage("*NCC chưa được thêm vào phiếu*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC gán → winner cũ s1 giữ nguyên"); reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC mutate → winner cũ s1 giữ nguyên");
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("Conflict → IsWinner s1 không bị set false");
} }
[Fact] [Fact]
public async Task Select_SupplierNotInMaster_ThrowsNotFound() public async Task Select_OneIdInListOneOutside_ThrowsConflict_NoPartialMutate()
{ {
// Supplier Guid không tồn tại trong Suppliers master → NotFound (check master // Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict (mỗi id
// existence TRƯỚC participating-list). // phải thuộc list). KHÔNG partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ
// (validate TRƯỚC khi set IsWinner cho toàn bộ rows).
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-OK");
var outsider = await SeedSupplierAsync(db, "NCC-FORGE");
var pe = await SeedPeAsync(db, selectedSupplierId: null); var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: Guid.NewGuid()); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, outsider.Id });
var act = async () => await handler.Handle(cmd, CancellationToken.None); var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>(); await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*NCC chưa được thêm vào phiếu*");
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("Conflict → KHÔNG set winner cho id hợp lệ trong cùng lệnh");
} }
// ============================================================ // ============================================================
// 3. PE existencechung cả 2 nhánh (check TRƯỚC khi rẽ nhánh SupplierId) // 3. SELECT branchMULTI winner (≥2 → joint winner, SelectedSupplierId=null)
// [TASK 2.1] regression cho hành vi multi-winner mới (Mig 58).
// ============================================================
[Fact]
public async Task Select_TwoSuppliers_BothIsWinner_SelectedSupplierIdNull()
{
// Chọn 2 đơn vị cùng trúng → cả 2 IsWinner=true, SelectedSupplierId=null (≥2 →
// không còn "1 winner" duy nhất). Trục multi-winner cốt lõi.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-J1");
var s2 = await SeedSupplierAsync(db, "NCC-J2");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id });
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull("≥2 winner → không sync 1 SelectedSupplierId");
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
}
[Fact]
public async Task Select_ThreeOfFour_ThreeIsWinner_NonSelectedIsFalse_SelectedSupplierIdNull()
{
// 4 NCC tham gia, chọn 3 → đúng 3 row IsWinner=true, NCC thứ 4 IsWinner=false,
// SelectedSupplierId=null. Verify "set false phần còn lại" với nhiều row.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-Q1");
var s2 = await SeedSupplierAsync(db, "NCC-Q2");
var s3 = await SeedSupplierAsync(db, "NCC-Q3");
var s4 = await SeedSupplierAsync(db, "NCC-Q4");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
await AddParticipatingAsync(db, pe.Id, s3.Id, order: 2);
await AddParticipatingAsync(db, pe.Id, s4.Id, order: 3);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id, s3.Id });
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull();
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
(await IsWinnerAsync(db, pe.Id, s3.Id)).Should().BeTrue();
(await IsWinnerAsync(db, pe.Id, s4.Id)).Should().BeFalse("NCC không được chọn → IsWinner=false");
}
[Fact]
public async Task Select_DuplicateIds_Deduped_TreatedAsSingleWinner_SelectedSupplierIdSet()
{
// FE gửi trùng id [s1, s1] → Distinct → count==1 → đối xử single-winner:
// s1.IsWinner=true + SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-DUP");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s1.Id });
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s1.Id, "trùng id Distinct → count==1 → sync SelectedSupplierId");
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
}
[Fact]
public async Task Select_MultiThenClear_RoundTrip_AllWinnersReset()
{
// Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false +
// SelectedSupplierId=null. Chứng minh clear reset đúng kể cả từ trạng thái multi.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-R1");
var s2 = await SeedSupplierAsync(db, "NCC-R2");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var handler = BuildHandler(db);
await handler.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }),
CancellationToken.None);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("sau chọn 2");
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
await handler.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>()),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull();
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear sau multi → false hết");
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
}
[Fact]
public async Task Select_MultiWinner_WritesChangelog_WithChonNSummary()
{
// Multi-winner ghi Changelog Summary "Chọn {n} đơn vị NCC/TP trúng thầu" (n = số
// đơn vị chọn). Verify count chính xác trong message.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-CL1");
var s2 = await SeedSupplierAsync(db, "NCC-CL2");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var handler = BuildHandler(db);
await handler.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }),
CancellationToken.None);
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == pe.Id)
.SingleAsync();
log.Summary.Should().Be("Chọn 2 đơn vị NCC/TP trúng thầu");
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
log.Action.Should().Be(ChangelogAction.Update);
}
// ============================================================
// 4. PE existence — chung mọi nhánh (check TRƯỚC khi rẽ nhánh SupplierIds)
// ============================================================ // ============================================================
[Fact] [Fact]
public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch() public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch()
{ {
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC // PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC
// khi xét SupplierId (kể cả nhánh clear null cũng không bỏ qua existence). // khi xét SupplierIds (kể cả nhánh clear [] cũng không bỏ qua existence).
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: null); var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierIds: Array.Empty<Guid>());
var act = async () => await handler.Handle(cmd, CancellationToken.None); var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>(); await act.Should().ThrowAsync<NotFoundException>();
@ -274,7 +454,7 @@ public class PeSelectWinnerClearTests
var s1 = await SeedSupplierAsync(db, "NCC-A"); var s1 = await SeedSupplierAsync(db, "NCC-A");
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: s1.Id); var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierIds: new[] { s1.Id });
var act = async () => await handler.Handle(cmd, CancellationToken.None); var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>(); await act.Should().ThrowAsync<NotFoundException>();

View File

@ -857,13 +857,25 @@ public class PeWorkItemBudgetTests
} }
[Fact] [Fact]
public void AdjustBudget_Validator_BudgetPeriodZero_FailsValidation() public void AdjustBudget_Validator_BudgetPeriodZero_PassesValidation()
{ {
// GreaterThan(0) when HasValue → 0 không hợp lệ. // [Mig 58 item 2 — SPEC CHANGE] GreaterThanOrEqualTo(0) when HasValue → 0 HỢP LỆ
// (trước GreaterThan(0) chặn 0). SSG ngân sách 0 vẫn lưu/gửi duyệt được (anh Kiệt FDC).
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator(); var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 0m, null)); var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 0m, null));
result.IsValid.Should().BeFalse(); result.IsValid.Should().BeTrue("ngân sách kỳ này = 0 hợp lệ sau Mig 58");
}
[Fact]
public void AdjustBudget_Validator_BudgetPeriodNegative_FailsValidation()
{
// [Mig 58 item 2] Boundary: 0 hợp lệ NHƯNG ÂM vẫn KHÔNG (GreaterThanOrEqualTo(0)
// = >= 0, loại số âm). Chứng minh nới chỉ tới 0, không mở cho âm.
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), -1m, null));
result.IsValid.Should().BeFalse("ngân sách kỳ này ÂM không hợp lệ");
result.Errors.Should().Contain(e => result.Errors.Should().Contain(e =>
e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.BudgetPeriodAmount)); e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.BudgetPeriodAmount));
} }

View File

@ -0,0 +1,319 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Services;
// ===== item 4 (Mig 58 2026-06-24 — CCM/anh Kiệt FDC) — PER-LEVEL finalize "Duyệt là
// KẾT THÚC, không trình CEO" — AUTO theo cấu hình quy trình (anh Kiệt chốt 2026-06-24
// "cho bật trong quy trình hay hơn") — test theo CODE (S34 rule, KHÔNG touch production).
//
// SPEC item 4 (AUTO, KHÔNG ô-tích runtime): ApprovalWorkflowLevel +AllowApproverFinalize
// (bool, Mig 58). Trong ApproveV2Async, SAU opinion UPSERT + skipToFinal, TRƯỚC
// ccm-delegation + advance:
// if (matchingLevel.AllowApproverFinalize) {
// ApplyApprovedPriceOnFinalize(...); // human PHẢI chọn giá chốt (Conflict nếu null)
// Phase = DaDuyet; clear pointers (StepIdx/LevelOrder/SlaDeadline = null); return;
// }
// ⚠️ Quy-trình-driven: cấp được cấu hình KẾT THÚC → duyệt tới đó là TỰ done, KHÔNG cần
// cờ runtime. KHÔNG threshold, KHÔNG role-gate (admin chỉ-định slot = đủ thẩm quyền). Bỏ
// qua MỌI Bước/Cấp còn lại (kể cả CEO ở step sau). Wrong-actor vẫn Forbidden TRƯỚC finalize
// (actor-match guard chạy trước).
//
// Mirror harness PeCcmThresholdFinalizeTests.cs cùng folder (IdentityFixture + SQLite +
// SeedWorkflowAsync + BuildPeAtApprovalSlot + NoOpNotificationService). Dựng PE TRỰC TIẾP
// ở ChoDuyet tại slot NON-terminal (còn Step/CEO sau) → drive 1 Approve.
public class PeApproverFinalizeTests
{
private const decimal ValidApprovedPrice = 400_000_000m;
private const string ValidApprovedSource = "Ncc";
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
TestApplicationDbContext db, FixedDateTime clock) CreateService()
{
var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var clock = new FixedDateTime(new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
return (svc, fix, db, clock);
}
private static PurchaseEvaluation BuildPeAtApprovalSlot(
Guid approvalWorkflowId, int stepIdx, int levelOrder, string code = "PE-AF-001")
=> new()
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Test item 4 — per-level finalize (auto)",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
ApprovalWorkflowId = approvalWorkflowId,
CurrentWorkflowStepIndex = stepIdx,
CurrentApprovalLevelOrder = levelOrder,
SlaDeadline = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
};
// Seed workflow V2. stepApprovers[s][lvl] = (approverUserId, allowFinalize) cho Cấp
// (Order lvl+1) của Bước (Order s+1). 1 NV / cấp. Return ApprovalWorkflow đã persist.
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
TestApplicationDbContext db, (Guid user, bool allowFinalize)[][] stepApprovers)
{
var wf = new ApprovalWorkflow
{
Code = "QT-AF-V2",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
Name = "QT test item 4 per-level finalize (auto)",
IsActive = true,
IsUserSelectable = true,
};
for (int s = 0; s < stepApprovers.Length; s++)
{
var step = new ApprovalWorkflowStep
{
ApprovalWorkflowId = wf.Id,
Order = s + 1,
Name = $"Bước {s + 1}",
};
for (int lvl = 0; lvl < stepApprovers[s].Length; lvl++)
{
var (user, allow) = stepApprovers[s][lvl];
step.Levels.Add(new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = lvl + 1,
Name = $"Cấp {lvl + 1}",
ApproverUserId = user,
AllowApproverFinalize = allow,
});
}
wf.Steps.Add(step);
}
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
// AUTO model: KHÔNG còn finalizeByApprover param — finalize tự chạy theo cờ cấp.
private static Task ApproveAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
string[] roles,
decimal? approvedPriceAmount = null,
string? approvedPriceSource = null) =>
svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place
actorUserId: actorUserId,
actorRoles: roles,
decision: ApprovalDecision.Approve,
comment: null,
approvedPriceAmount: approvedPriceAmount,
approvedPriceSource: approvedPriceSource,
ct: CancellationToken.None);
// =====================================================================
// 1. ⭐ LOAD-BEARING — slot NON-terminal AllowApproverFinalize=true + approver duyệt
// (+ giá chốt) → TỰ DaDuyet, bỏ CEO (Step 2), pointers cleared. AUTO, không ô-tích.
// =====================================================================
[Fact]
public async Task ApproveV2_LevelAllowsFinalize_AutoFinalizesDaDuyet_SkipsCeo_PointersCleared()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
// Bước 1 Cấp 1 = approver (AllowApproverFinalize=TRUE, đang đứng đây).
// Bước 2 Cấp 1 = CEO (sẽ bị BỎ QUA hoàn toàn).
var approver = (await fix.CreateUserAsync("appr@af.test", "Approver", null, Array.Empty<string>())).Id;
var ceo = (await fix.CreateUserAsync("ceo@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { (approver, true) }, // Bước 1 Cấp 1 = approver, finalize-allowed
new[] { (ceo, false) }, // Bước 2 Cấp 1 = CEO (bỏ qua)
});
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
"slot AllowApproverFinalize → duyệt tới đây là TỰ kết thúc, bỏ CEO");
pe.CurrentWorkflowStepIndex.Should().BeNull("terminal → step pointer null");
pe.CurrentApprovalLevelOrder.Should().BeNull("terminal → level pointer null");
pe.SlaDeadline.Should().BeNull("terminal → SLA null");
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice, "giá chốt bind khi finalize");
pe.ApprovedPriceSource.Should().Be(ValidApprovedSource);
// CEO KHÔNG được ghi opinion (bị bỏ qua — chỉ approver Bước 1 ký).
var opinions = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
var approverLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 1).Id;
opinions.Should().ContainSingle(o => o.ApprovalWorkflowLevelId == approverLevelId,
"chỉ slot approver ký, không ghi hộ CEO");
// 1 Approval Approve (của approver), finalize-branch return → KHÔNG double.
var approvals = await db.PurchaseEvaluationApprovals
.Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve)
.ToListAsync();
approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double-finalize");
}
}
// =====================================================================
// 2. CONFIG GATES IT — slot AllowApproverFinalize=FALSE + approver duyệt → KHÔNG
// finalize, advance bình thường lên CEO (Step 2). Chứng minh CHỈ cấp được cấu
// hình KẾT THÚC mới dừng; cấp thường vẫn trình tiếp.
// =====================================================================
[Fact]
public async Task ApproveV2_LevelNotAllowed_AdvancesToCeo_PhaseStaysChoDuyet()
{
var (svc, fix, db, clock) = CreateService();
using (fix)
{
var approver = (await fix.CreateUserAsync("appr2@af.test", "Approver", null, Array.Empty<string>())).Id;
var ceo = (await fix.CreateUserAsync("ceo2@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { (approver, false) }, // ⭐ slot KHÔNG cấu hình KẾT THÚC
new[] { (ceo, false) },
});
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-002");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, approver, Array.Empty<string>()); // không giá chốt — chưa terminal
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"slot không cấu hình kết thúc → advance bình thường lên CEO");
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)");
pe.CurrentApprovalLevelOrder.Should().Be(1);
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7), "SLA reset cho CEO");
pe.ApprovedPriceAmount.Should().BeNull("chưa terminal → chưa bind giá chốt");
}
}
// =====================================================================
// 3. PRICE REQUIRED — slot allow=true NHƯNG approvedPrice null (human) →
// Conflict("giá chốt"). Finalize cũng cần giá chốt như terminal cuối.
// =====================================================================
[Fact]
public async Task ApproveV2_LevelAllowsFinalize_NullPrice_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var approver = (await fix.CreateUserAsync("appr3@af.test", "Approver", null, Array.Empty<string>())).Id;
var ceo = (await fix.CreateUserAsync("ceo3@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { (approver, true) },
new[] { (ceo, false) },
});
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-003");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// slot allowed NHƯNG KHÔNG truyền giá chốt → finalize đòi giá chốt.
var act = async () => await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
approvedPriceAmount: null, approvedPriceSource: null);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*giá chốt*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "thiếu giá chốt → không finalize");
}
}
// =====================================================================
// 4. WRONG-ACTOR precedence — approver-match guard chạy TRƯỚC finalize-branch:
// NV KHÔNG thuộc Cấp hiện tại duyệt → Forbidden (không phải lượt), KHÔNG tới
// được finalize logic. Bảo vệ forge request.
// =====================================================================
[Fact]
public async Task ApproveV2_ActorNotInLevel_ThrowsForbidden_BeforeFinalize()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var realApprover = (await fix.CreateUserAsync("real4@af.test", "Real Approver", null, Array.Empty<string>())).Id;
var intruder = (await fix.CreateUserAsync("intruder4@af.test", "Intruder", null, Array.Empty<string>())).Id;
var ceo = (await fix.CreateUserAsync("ceo4@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { (realApprover, true) }, // slot cấu hình kết thúc — nhưng intruder KHÔNG phải người này
new[] { (ceo, false) },
});
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-004");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// intruder (không trong Cấp) duyệt → Forbidden TRƯỚC finalize logic.
var act = async () => await ApproveAsync(svc, pe, intruder, Array.Empty<string>(),
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
await act.Should().ThrowAsync<ForbiddenException>();
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "không phải lượt → không finalize");
}
}
// =====================================================================
// 5. AT-LAST-SLOT — slot CUỐI (1 bước 1 cấp) + allow=true → DaDuyet qua finalize-
// branch (chạy TRƯỚC normal terminal advance). 1 Approval, không double.
// =====================================================================
[Fact]
public async Task ApproveV2_AtLastSlot_LevelAllowsFinalize_FinalizesDaDuyet_NoDoubleApproval()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
// 1 bước 1 cấp = approver (slot cuối luôn), allow finalize.
var approver = (await fix.CreateUserAsync("appr5@af.test", "Approver", null, Array.Empty<string>())).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { (approver, true) },
});
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-005");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
"finalize ở slot cuối → DaDuyet qua finalize-branch (trước normal advance)");
pe.CurrentWorkflowStepIndex.Should().BeNull();
pe.CurrentApprovalLevelOrder.Should().BeNull();
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
var approvals = await db.PurchaseEvaluationApprovals
.Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve)
.ToListAsync();
approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double");
}
}
}

View File

@ -89,6 +89,7 @@ public class PeCcmThresholdFinalizeTests
PurchaseEvaluationId = pe.Id, PurchaseEvaluationId = pe.Id,
SupplierId = supplierId, SupplierId = supplierId,
Order = 0, Order = 0,
IsWinner = true, // [Mig 58] ccm-delegate winnerQuoteTotal đếm SUM theo IsWinner rows
}; };
var detail = new PurchaseEvaluationDetail var detail = new PurchaseEvaluationDetail
{ {

View File

@ -79,6 +79,9 @@ public class PeSubmitGuardAndBypassTests
// Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1 // Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1
// quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref). // quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref).
// [Mig 58] Winner = supplier-row IsWinner=true (nguồn-sự-thật mới — submit guard +
// ccm-delegate + read DTO SUM theo IsWinner, KHÔNG còn theo SelectedSupplierId). Set
// IsWinner=true + sync SelectedSupplierId trong caller để mirror handler select-winner.
private static async Task<Guid> SeedWinnerWithQuoteAsync( private static async Task<Guid> SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien) TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
{ {
@ -88,6 +91,7 @@ public class PeSubmitGuardAndBypassTests
PurchaseEvaluationId = pe.Id, PurchaseEvaluationId = pe.Id,
SupplierId = supplierId, SupplierId = supplierId,
Order = 0, Order = 0,
IsWinner = true, // [Mig 58] đánh dấu winner — submit guard đếm IsWinner rows
}; };
var detail = new PurchaseEvaluationDetail var detail = new PurchaseEvaluationDetail
{ {
@ -257,15 +261,16 @@ public class PeSubmitGuardAndBypassTests
} }
[Fact] [Fact]
public async Task Submit_MissingBudget_BothNullAndManualZero_ThrowsConflict() public async Task Submit_MissingBudget_NullBlocks_ThrowsConflict()
{ {
// (4) Đủ winner + quote + comparison. Thiếu ngân sách: BudgetId null VÀ // (4) [Mig 58 item 2] Đủ winner + quote + comparison. Ngân sách = NULL (chưa nhập,
// BudgetManualAmount = 0 → "chưa nhập Ngân sách" (cover cả nhánh manual=0, // và phiếu KHÔNG có WorkItemId fallback ProInitial) → "chưa nhập Ngân sách kỳ này".
// không chỉ null). // ĐỔI từ trước (block khi null||<=0): NAY chỉ NULL block, 0 hợp lệ (test 4b). Phiếu
// KHÔNG pin WorkItemId → effectiveBudget=null sau khi không fallback được.
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
var pe = BuildPeNhap(budgetPeriodAmount: 0m); var pe = BuildPeNhap(budgetPeriodAmount: null); // null = chưa nhập → block
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
@ -283,6 +288,65 @@ public class PeSubmitGuardAndBypassTests
} }
} }
[Fact]
public async Task Submit_BudgetZero_IsValid_PassesBudgetGuard_ThenWorkflowRequired()
{
// (4b) [Mig 58 item 2] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn cho gửi duyệt).
// ĐỔI hành vi: trước `is null || <= 0` block; NAY chỉ `is null` block → 0 đi qua.
// Đủ winner + quote>0 + comparison + budget=0 → guard Section-3 PASS, KHÔNG còn
// "chưa nhập Ngân sách". Phiếu KHÔNG pin workflow → dừng ở require-workflow guard
// (chứng minh đã qua budget-guard: message NHẮC workflow chứ KHÔNG nhắc ngân sách).
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetPeriodAmount: 0m); // 0 = cố ý → hợp lệ
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
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().NotContain("Ngân sách", "ngân sách = 0 hợp lệ → qua budget-guard");
ex.Which.Message.Should().Contain("quy trình duyệt", "dừng ở require-workflow (sau budget-guard)");
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
}
}
[Fact]
public async Task Submit_BudgetZero_WithWorkflow_SucceedsToChoDuyet()
{
// (4c) [Mig 58 item 2] END-TO-END ngân sách 0 hợp lệ: đủ 4 Section-3 (winner +
// quote>0 + comparison + budget=0) + pin workflow V2 → submit THÀNH CÔNG, Phase
// ChoDuyet (KHÔNG ConflictException "chưa nhập Ngân sách"). Đây là trục item 2 cốt
// lõi: ngân sách 0 không cản gửi duyệt. Drafter ngoài chuỗi → KHÔNG bypass, start
// pointer Bước 1 Cấp 1.
var (svc, fix, db, clock) = CreateService();
using (fix)
{
var approver = (await fix.CreateUserAsync("appr0@s60.test", "Approver", null, Array.Empty<string>())).Id;
var wf = await SeedWorkflowSingleStepAsync(db, approver);
var pe = BuildPeNhap(budgetPeriodAmount: 0m, approvalWorkflowId: wf.Id, code: "PE-S60-B0");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
pe.SelectedSupplierId = supplierId;
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
await SubmitAsync(svc, pe, Guid.NewGuid()); // drafter random (ngoài chuỗi)
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "ngân sách 0 + đủ ĐK + workflow → gửi duyệt OK");
pe.CurrentWorkflowStepIndex.Should().Be(0);
pe.CurrentApprovalLevelOrder.Should().Be(1);
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
}
}
[Fact] [Fact]
public async Task Submit_MissingComparisonTable_ThrowsConflict() public async Task Submit_MissingComparisonTable_ThrowsConflict()
{ {