[CLAUDE] PurchaseEvaluation: multi-NCC per hạng mục — chọn ≥2 NCC-TP trúng thầu/1 hạng mục (mỗi nhà 1 giá riêng)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m17s

Đổi winner-truth từ whole-supplier Supplier.IsWinner (Mig 58 liên-danh) sang per-hạng-mục
Quote.IsSelected (Mig 12 dormant hook → nay nguồn-sự-thật); Supplier.IsWinner = DERIVED (== Any IsSelected).
Anh Kiệt LOCKED D1=SUM(IsSelected) · D2=THAY-THẾ · D3=giá-chào-gốc · D4=1HĐ/NCC. 0 bảng/cột mới.

BE:
- SelectWinnerCommand + POST /select-winner: body {supplierIds} → {detailId, supplierIds} (per hạng mục).
- Re-key 5 financial-SUM → WHERE IsSelected: winnerQuoteTotal, submit-guard, CCM-threshold, giaTri, budget-B.
- giaTri bỏ shortcut isSingle→detailsSum (single đổi budget→bid theo D4).
- Derive IsWinner invariant ở MỌI writer: SelectWinner + UpsertQuote + seed + DeleteQuote/DeleteDetail (helper PeWinnerInvariant, fix Bước 4).
- GIỮ IsWinner-derived cho winner-names/existence + CreateContract winners-list.
- Migration BackfillPeQuoteIsSelectedAndWinnerInvariant (data-only 2-chiều IsWinner↔IsSelected, Down no-op).

FE 2-app (PeDetailTabs SHA-identical): HangMucCard per-cell winner grid Detail×NCC + toggle per-detail; NccSelectorRow hạ-cấp read-only summary; giữ derived display.

Test: 7 file update + 8 test mới (per-hạng-mục total, giaTri single-partial, threshold-crossing, derive-invariant, delete-path). 486 PASS.

Flow: fable-clone invest ensemble → anh Kiệt 4-decision → fable-clone review ensemble (6 adjustment) → hmw implement (disk-recover #53) → fable-clone verify (bắt MUST bug delete-path → fix test-before).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 22:22:09 +07:00
parent 8a1b0d4901
commit d436af2971
18 changed files with 7272 additions and 425 deletions

View File

@ -933,95 +933,46 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
) )
} }
// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] ===== // ===== a. NCC / TP được chọn — TÓM TẮT read-only [multi-NCC per hạng mục] =====
// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng // Winner giờ CHỌN THEO TỪNG HẠNG MỤC ở bảng NCC (Mục 2) — nguồn sự thật = quote.isSelected
// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner). // mỗi cặp (hạng mục × NCC). Picker phiếu-wide cũ MẤT NGHĨA → hạ-cấp thành ô TÓM TẮT: mỗi
// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`; // đơn vị trúng + các hạng mục nó thắng (derive ev.details[].quotes IsSelected). Tổng =
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner). // ev.winnerQuoteTotal (BE = SUM mọi quote IsSelected).
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { function NccSelectorRow({ ev }: { ev: PeDetailBundle }) {
const canEdit = !readOnly && isEditablePhase(ev.phase) const winners = ev.suppliers.filter(s => s.isWinner)
const qc = useQueryClient() // Các hạng mục mà supplier-row `rowId` thắng (có ≥1 quote IsSelected trong hạng mục đó).
// gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker. const wonItems = (rowId: string) =>
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0 ev.details
const setWinners = useMutation({ .filter(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === rowId && q.isSelected))
mutationFn: async (supplierIds: string[]) => .map(d => d.groupCode || d.noiDung)
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] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
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) {
const winners = ev.suppliers.filter(s => s.isWinner)
return (
<FormRow
label="a. NCC / TP được chọn"
value={winners.length > 0
? <span className="font-medium text-emerald-700"> {winners.map(s => s.supplierName).join(', ')}</span>
: <span className="text-slate-400"> (chưa chọn)</span>}
/>
)
}
return ( return (
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5"> <FormRow
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span> label="a. NCC / TP được chọn"
<div className="relative min-w-0 flex-1"> value={winners.length > 0 ? (
{ev.suppliers.length === 0 ? ( <div className="space-y-1">
<p className="text-[11px] text-amber-600"> {winners.map(s => {
Thêm NCC Section 3 trước rồi mới chọn trúng thầu. const items = wonItems(s.id)
</p> return (
) : ( <div key={s.id} className="text-[12px] leading-relaxed">
<div className="space-y-1"> <span className="font-medium text-emerald-700"> {s.supplierName}</span>
{ev.suppliers.map(s => ( {items.length > 0 && (
<label <span className="text-slate-500"> thắng hạng mục: {items.join(', ')}</span>
key={s.id}
className={cn(
'flex cursor-pointer items-center gap-2 rounded border px-2 py-1 text-[12px] transition',
s.isWinner
? 'border-emerald-300 bg-emerald-50 font-medium text-emerald-800'
: 'border-slate-200 bg-white hover:bg-slate-50',
)} )}
> </div>
<input )
type="checkbox" })}
className="h-3.5 w-3.5" <div className="mt-1 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
checked={s.isWinner} <span className="text-emerald-700">Tổng chi phí đã chọn ({winners.length} đơn vị):</span>
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>
)}
{/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */}
{winnerIds.length > 0 && (
<div className="mt-1.5 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
<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> <span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
</div> </div>
)} <div className="text-[11px] text-slate-400">
{busy && ( Chọn NCC trúng thầu theo từng hạng mục Mục 2 (Hạng mục + Báo giá NCC).
<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> <span className="text-slate-400"> (chưa chọn chọn theo từng hạng mục Mục 2)</span>
)}
/>
) )
} }
@ -1912,7 +1863,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<NccSelectorRow ev={ev} readOnly={readOnly} /> <NccSelectorRow ev={ev} />
{/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow {/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */} + BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} /> <PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
@ -2566,19 +2517,26 @@ 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)),
}) })
// [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 // [multi-NCC per hạng mục] Winner CHỌN THEO HẠNG MỤC NÀY: nguồn sự thật = quote.isSelected
// danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render. // của detail này. BE select-winner nhận MASTER supplierId (map qua PeSupplier.supplierId) +
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId) // detailId → set IsSelected cho quotes hạng mục này, derive Supplier.IsWinner toàn phiếu.
const setWinners = useMutation({ // Gửi TOÀN BỘ tập master-id đang thắng hạng mục này sau mỗi toggle (chọn ≥2 = liên danh).
const selectedRowIds = new Set(detail.quotes.filter(q => q.isSelected).map(q => q.purchaseEvaluationSupplierId))
const detailWinnerSupplierIds = ev.suppliers.filter(s => selectedRowIds.has(s.id)).map(s => s.supplierId)
const setDetailWinners = useMutation({
mutationFn: async (supplierIds: string[]) => mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }), api.post(`/purchase-evaluations/${ev.id}/select-winner`, { detailId: detail.id, supplierIds }),
onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onSuccess: () => {
toast.success('Đã cập nhật NCC trúng thầu cho hạng mục.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const toggleWinner = (supplierId: string, on: boolean) => const toggleDetailWinner = (supplierId: string, on: boolean) =>
setWinners.mutate(on setDetailWinners.mutate(on
? [...winnerIds, supplierId] ? [...detailWinnerSupplierIds, supplierId]
: winnerIds.filter(id => id !== supplierId)) : detailWinnerSupplierIds.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">
@ -2674,6 +2632,9 @@ 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
// Thắng HẠNG MỤC NÀY = quote của cell có IsSelected. isWinner (phiếu-wide,
// derived BE) chỉ dùng guard xóa/sửa NCC — 1 NCC có thể thắng hạng mục khác.
const cellSelected = q?.isSelected ?? false
const isWinner = s.isWinner 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
@ -2684,15 +2645,15 @@ function HangMucCard({
key={s.id} key={s.id}
className={cn( className={cn(
'align-top border-l-4 transition', 'align-top border-l-4 transition',
isWinner cellSelected
? 'border-l-emerald-500 bg-emerald-100/70 font-semibold shadow-sm ring-1 ring-inset ring-emerald-300 hover:bg-emerald-200/70' ? 'border-l-emerald-500 bg-emerald-100/70 font-semibold shadow-sm ring-1 ring-inset ring-emerald-300 hover:bg-emerald-200/70'
: cn(palette, 'hover:bg-white/80 hover:shadow-sm'), : cn(palette, 'hover:bg-white/80 hover:shadow-sm'),
)} )}
> >
<td className="border-r border-slate-200 px-2 py-1.5"> <td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900"> <div className="font-medium text-slate-900">
{isWinner && <span className="mr-1 text-base font-bold text-emerald-700"></span>} {cellSelected && <span className="mr-1 text-base font-bold text-emerald-700"></span>}
<span className={cn(isWinner && 'text-emerald-900')}>{s.supplierName}</span> <span className={cn(cellSelected && 'text-emerald-900')}>{s.supplierName}</span>
</div> </div>
{s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>} {s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="text-[10px] text-amber-700">{s.note}</div>} {s.note && <div className="text-[10px] text-amber-700">{s.note}</div>}
@ -2724,7 +2685,7 @@ function HangMucCard({
className={cn( className={cn(
'w-full rounded border px-2 py-1 text-right font-mono text-[11px] transition', 'w-full rounded border px-2 py-1 text-right font-mono text-[11px] transition',
q q
? isWinner ? cellSelected
? 'border-emerald-300 bg-emerald-50 font-semibold text-emerald-700 hover:bg-emerald-100' ? 'border-emerald-300 bg-emerald-50 font-semibold text-emerald-700 hover:bg-emerald-100'
: 'border-slate-300 bg-white font-semibold hover:border-brand-300 hover:bg-brand-50' : 'border-slate-300 bg-white font-semibold hover:border-brand-300 hover:bg-brand-50'
: 'border-dashed border-slate-300 bg-slate-50 text-[10px] text-slate-400 hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600', : 'border-dashed border-slate-300 bg-slate-50 text-[10px] text-slate-400 hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600',
@ -2734,7 +2695,7 @@ function HangMucCard({
{q ? `${fmtMoney(q.thanhTien)} đ` : '+ Nhập số tiền'} {q ? `${fmtMoney(q.thanhTien)} đ` : '+ Nhập số tiền'}
</button> </button>
) : ( ) : (
<div className={cn('text-right font-mono font-semibold', isWinner && 'text-emerald-700')}> <div className={cn('text-right font-mono font-semibold', cellSelected && 'text-emerald-700')}>
{q ? `${fmtMoney(q.thanhTien)} đ` : <span className="text-slate-300"></span>} {q ? `${fmtMoney(q.thanhTien)} đ` : <span className="text-slate-300"></span>}
</div> </div>
)} )}
@ -2743,13 +2704,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={() => toggleWinner(s.supplierId, !isWinner)} onClick={() => toggleDetailWinner(s.supplierId, !cellSelected)}
disabled={setWinners.isPending} disabled={setDetailWinners.isPending}
className={cn( className={cn(
'rounded px-1 py-0.5 disabled:opacity-50', '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', cellSelected ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)} )}
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)'} title={cellSelected ? 'Bỏ chọn trúng thầu hạng mục này (chọn ≥2 NCC = liên danh)' : 'Chọn trúng thầu hạng mục này (chọn ≥2 NCC = liên danh)'}
> >
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
</button> </button>

View File

@ -933,95 +933,46 @@ function InfoTab({ ev, readOnly, autoEdit }: { ev: PeDetailBundle; readOnly: boo
) )
} }
// ===== a. NCC / TP được chọn — MULTI-WINNER picker [Mig 58 anh Kiệt FDC] ===== // ===== a. NCC / TP được chọn — TÓM TẮT read-only [multi-NCC per hạng mục] =====
// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng // Winner giờ CHỌN THEO TỪNG HẠNG MỤC ở bảng NCC (Mục 2) — nguồn sự thật = quote.isSelected
// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner). // mỗi cặp (hạng mục × NCC). Picker phiếu-wide cũ MẤT NGHĨA → hạ-cấp thành ô TÓM TẮT: mỗi
// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`; // đơn vị trúng + các hạng mục nó thắng (derive ev.details[].quotes IsSelected). Tổng =
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner). // ev.winnerQuoteTotal (BE = SUM mọi quote IsSelected).
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) { function NccSelectorRow({ ev }: { ev: PeDetailBundle }) {
const canEdit = !readOnly && isEditablePhase(ev.phase) const winners = ev.suppliers.filter(s => s.isWinner)
const qc = useQueryClient() // Các hạng mục mà supplier-row `rowId` thắng (có ≥1 quote IsSelected trong hạng mục đó).
// gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker. const wonItems = (rowId: string) =>
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0 ev.details
const setWinners = useMutation({ .filter(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === rowId && q.isSelected))
mutationFn: async (supplierIds: string[]) => .map(d => d.groupCode || d.noiDung)
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] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
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) {
const winners = ev.suppliers.filter(s => s.isWinner)
return (
<FormRow
label="a. NCC / TP được chọn"
value={winners.length > 0
? <span className="font-medium text-emerald-700"> {winners.map(s => s.supplierName).join(', ')}</span>
: <span className="text-slate-400"> (chưa chọn)</span>}
/>
)
}
return ( return (
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5"> <FormRow
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span> label="a. NCC / TP được chọn"
<div className="relative min-w-0 flex-1"> value={winners.length > 0 ? (
{ev.suppliers.length === 0 ? ( <div className="space-y-1">
<p className="text-[11px] text-amber-600"> {winners.map(s => {
Thêm NCC Section 3 trước rồi mới chọn trúng thầu. const items = wonItems(s.id)
</p> return (
) : ( <div key={s.id} className="text-[12px] leading-relaxed">
<div className="space-y-1"> <span className="font-medium text-emerald-700"> {s.supplierName}</span>
{ev.suppliers.map(s => ( {items.length > 0 && (
<label <span className="text-slate-500"> thắng hạng mục: {items.join(', ')}</span>
key={s.id}
className={cn(
'flex cursor-pointer items-center gap-2 rounded border px-2 py-1 text-[12px] transition',
s.isWinner
? 'border-emerald-300 bg-emerald-50 font-medium text-emerald-800'
: 'border-slate-200 bg-white hover:bg-slate-50',
)} )}
> </div>
<input )
type="checkbox" })}
className="h-3.5 w-3.5" <div className="mt-1 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
checked={s.isWinner} <span className="text-emerald-700">Tổng chi phí đã chọn ({winners.length} đơn vị):</span>
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>
)}
{/* Tổng chi phí các đơn vị đã chọn = winnerQuoteTotal (SUM mọi isWinner). */}
{winnerIds.length > 0 && (
<div className="mt-1.5 flex items-center justify-between rounded bg-emerald-50 px-2 py-1 text-[12px]">
<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> <span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
</div> </div>
)} <div className="text-[11px] text-slate-400">
{busy && ( Chọn NCC trúng thầu theo từng hạng mục Mục 2 (Hạng mục + Báo giá NCC).
<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> <span className="text-slate-400"> (chưa chọn chọn theo từng hạng mục Mục 2)</span>
)}
/>
) )
} }
@ -1912,7 +1863,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<NccSelectorRow ev={ev} readOnly={readOnly} /> <NccSelectorRow ev={ev} />
{/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow {/* b. TỔNG HỢP NGÂN SÁCH TRÌNH KÝ (S61 — Excel anh Kiệt). Thay BudgetFieldRow
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */} + BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} /> <PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
@ -2566,19 +2517,26 @@ 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)),
}) })
// [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 // [multi-NCC per hạng mục] Winner CHỌN THEO HẠNG MỤC NÀY: nguồn sự thật = quote.isSelected
// danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render. // của detail này. BE select-winner nhận MASTER supplierId (map qua PeSupplier.supplierId) +
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId) // detailId → set IsSelected cho quotes hạng mục này, derive Supplier.IsWinner toàn phiếu.
const setWinners = useMutation({ // Gửi TOÀN BỘ tập master-id đang thắng hạng mục này sau mỗi toggle (chọn ≥2 = liên danh).
const selectedRowIds = new Set(detail.quotes.filter(q => q.isSelected).map(q => q.purchaseEvaluationSupplierId))
const detailWinnerSupplierIds = ev.suppliers.filter(s => selectedRowIds.has(s.id)).map(s => s.supplierId)
const setDetailWinners = useMutation({
mutationFn: async (supplierIds: string[]) => mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }), api.post(`/purchase-evaluations/${ev.id}/select-winner`, { detailId: detail.id, supplierIds }),
onSuccess: () => { toast.success('Đã cập nhật đơn vị trúng thầu.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) }, onSuccess: () => {
toast.success('Đã cập nhật NCC trúng thầu cho hạng mục.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const toggleWinner = (supplierId: string, on: boolean) => const toggleDetailWinner = (supplierId: string, on: boolean) =>
setWinners.mutate(on setDetailWinners.mutate(on
? [...winnerIds, supplierId] ? [...detailWinnerSupplierIds, supplierId]
: winnerIds.filter(id => id !== supplierId)) : detailWinnerSupplierIds.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">
@ -2674,6 +2632,9 @@ 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
// Thắng HẠNG MỤC NÀY = quote của cell có IsSelected. isWinner (phiếu-wide,
// derived BE) chỉ dùng guard xóa/sửa NCC — 1 NCC có thể thắng hạng mục khác.
const cellSelected = q?.isSelected ?? false
const isWinner = s.isWinner 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
@ -2684,15 +2645,15 @@ function HangMucCard({
key={s.id} key={s.id}
className={cn( className={cn(
'align-top border-l-4 transition', 'align-top border-l-4 transition',
isWinner cellSelected
? 'border-l-emerald-500 bg-emerald-100/70 font-semibold shadow-sm ring-1 ring-inset ring-emerald-300 hover:bg-emerald-200/70' ? 'border-l-emerald-500 bg-emerald-100/70 font-semibold shadow-sm ring-1 ring-inset ring-emerald-300 hover:bg-emerald-200/70'
: cn(palette, 'hover:bg-white/80 hover:shadow-sm'), : cn(palette, 'hover:bg-white/80 hover:shadow-sm'),
)} )}
> >
<td className="border-r border-slate-200 px-2 py-1.5"> <td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900"> <div className="font-medium text-slate-900">
{isWinner && <span className="mr-1 text-base font-bold text-emerald-700"></span>} {cellSelected && <span className="mr-1 text-base font-bold text-emerald-700"></span>}
<span className={cn(isWinner && 'text-emerald-900')}>{s.supplierName}</span> <span className={cn(cellSelected && 'text-emerald-900')}>{s.supplierName}</span>
</div> </div>
{s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>} {s.displayName && <div className="text-[10px] text-slate-500">{s.displayName}</div>}
{s.note && <div className="text-[10px] text-amber-700">{s.note}</div>} {s.note && <div className="text-[10px] text-amber-700">{s.note}</div>}
@ -2724,7 +2685,7 @@ function HangMucCard({
className={cn( className={cn(
'w-full rounded border px-2 py-1 text-right font-mono text-[11px] transition', 'w-full rounded border px-2 py-1 text-right font-mono text-[11px] transition',
q q
? isWinner ? cellSelected
? 'border-emerald-300 bg-emerald-50 font-semibold text-emerald-700 hover:bg-emerald-100' ? 'border-emerald-300 bg-emerald-50 font-semibold text-emerald-700 hover:bg-emerald-100'
: 'border-slate-300 bg-white font-semibold hover:border-brand-300 hover:bg-brand-50' : 'border-slate-300 bg-white font-semibold hover:border-brand-300 hover:bg-brand-50'
: 'border-dashed border-slate-300 bg-slate-50 text-[10px] text-slate-400 hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600', : 'border-dashed border-slate-300 bg-slate-50 text-[10px] text-slate-400 hover:border-brand-400 hover:bg-brand-50 hover:text-brand-600',
@ -2734,7 +2695,7 @@ function HangMucCard({
{q ? `${fmtMoney(q.thanhTien)} đ` : '+ Nhập số tiền'} {q ? `${fmtMoney(q.thanhTien)} đ` : '+ Nhập số tiền'}
</button> </button>
) : ( ) : (
<div className={cn('text-right font-mono font-semibold', isWinner && 'text-emerald-700')}> <div className={cn('text-right font-mono font-semibold', cellSelected && 'text-emerald-700')}>
{q ? `${fmtMoney(q.thanhTien)} đ` : <span className="text-slate-300"></span>} {q ? `${fmtMoney(q.thanhTien)} đ` : <span className="text-slate-300"></span>}
</div> </div>
)} )}
@ -2743,13 +2704,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={() => toggleWinner(s.supplierId, !isWinner)} onClick={() => toggleDetailWinner(s.supplierId, !cellSelected)}
disabled={setWinners.isPending} disabled={setDetailWinners.isPending}
className={cn( className={cn(
'rounded px-1 py-0.5 disabled:opacity-50', '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', cellSelected ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)} )}
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)'} title={cellSelected ? 'Bỏ chọn trúng thầu hạng mục này (chọn ≥2 NCC = liên danh)' : 'Chọn trúng thầu hạng mục này (chọn ≥2 NCC = liên danh)'}
> >
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
</button> </button>

View File

@ -179,7 +179,8 @@ 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.SupplierIds ?? Array.Empty<Guid>()), ct); await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(
id, body.DetailId, body.SupplierIds ?? Array.Empty<Guid>()), ct);
return NoContent(); return NoContent();
} }
@ -359,7 +360,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(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 SelectWinnerBody(Guid DetailId, IReadOnlyList<Guid>? SupplierIds); // [multi-NCC] hạng mục DetailId + tập NCC/TP thắng hạng mục đó; [] hoặc null = bỏ chọn hạng mục này
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

@ -11,8 +11,8 @@ using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations; namespace SolutionErp.Application.PurchaseEvaluations;
// Kế thừa từ phiếu Duyệt NCC đã DaDuyet → tạo HĐ Draft mới. Map cơ bản: // Kế thừa từ phiếu Duyệt NCC đã DaDuyet → tạo HĐ Draft mới. Map cơ bản:
// SupplierId = PE.SelectedSupplierId, ProjectId, DepartmentId, TenHopDong, // SupplierId = mỗi NCC winner (DERIVED IsWinner == Any IsSelected), ProjectId, DepartmentId,
// GiaTri (sum ThanhTienNganSach của details). ContractType do user chọn // TenHopDong, GiaTri = SUM Quote.ThanhTien các báo giá IsSelected của NCC đó (S114 D4). ContractType do user chọn
// (phiếu có thể gen HĐ ThauPhu/NhaCungCap/MuaBan... tùy gói thầu). // (phiếu có thể gen HĐ ThauPhu/NhaCungCap/MuaBan... tùy gói thầu).
// //
// KHÔNG copy Details per-type automatically — user điền riêng sau khi HĐ // KHÔNG copy Details per-type automatically — user điền riêng sau khi HĐ
@ -70,11 +70,10 @@ public class CreateContractFromEvaluationCommandHandler(
.Select(w => (Guid?)w.Id) .Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
// GiaTri: 1 winner → tổng ngân sách details (HÀNH VI CŨ giữ nguyên); ≥2 liên-danh → // [multi-NCC 2026-07-12 — anh Kiệt LOCKED A1/D4] GiaTri mỗi HĐ = SUM báo giá ĐƯỢC CHỌN
// mỗi HĐ = tổng báo giá của NCC đó (giá HĐ = giá NCC chào; HĐ draft sửa được sau — // (Quote.IsSelected) của NCC đó — CẢ single lẫn multi. BỎ shortcut detailsSum cũ (single
// anh Kiệt UAT con số này). 0 nếu NCC chưa báo giá. // trước đây = tổng ngân sách; NAY = giá thầu chào, đúng D4 "1 HĐ/NCC = SUM IsSelected
var detailsSum = pe.Details.Sum(d => d.ThanhTienNganSach); // quotes của NCC"). 0 nếu NCC chưa có báo giá được chọn.
var isSingle = winners.Count == 1;
// [Mig 58] Codegen TỰ-COMMIT (transaction + ContractCodeSequence RIÊNG mỗi call) → gen // [Mig 58] Codegen TỰ-COMMIT (transaction + ContractCodeSequence RIÊNG mỗi call) → gen
// TẤT CẢ mã TRƯỚC khi Add vào context, build N Contract trong list LOCAL → SaveChanges // TẤT CẢ mã TRƯỚC khi Add vào context, build N Contract trong list LOCAL → SaveChanges
@ -86,11 +85,9 @@ public class CreateContractFromEvaluationCommandHandler(
if (!supplierMap.TryGetValue(w.SupplierId, out var supplier)) if (!supplierMap.TryGetValue(w.SupplierId, out var supplier))
throw new NotFoundException("Supplier", w.SupplierId); throw new NotFoundException("Supplier", w.SupplierId);
var giaTri = isSingle var giaTri = pe.Details.SelectMany(d => d.Quotes)
? detailsSum .Where(q => q.PurchaseEvaluationSupplierId == w.Id && q.IsSelected)
: pe.Details.SelectMany(d => d.Quotes) .Sum(q => q.ThanhTien);
.Where(q => q.PurchaseEvaluationSupplierId == w.Id)
.Sum(q => q.ThanhTien);
var contract = new Contract var contract = new Contract
{ {

View File

@ -261,11 +261,11 @@ public record PurchaseEvaluationDetailBundleDto(
// [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle. // [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle.
bool IsUrgentByPro, bool IsUrgentByPro,
bool IsUrgentByCcm, bool IsUrgentByCcm,
// [S69 · Mig 58 multi-winner] Tổng giá chào của các đơn vị NCC/TP ĐƯỢC CHỌN // [S69 · S114 multi-NCC per hạng mục] Tổng giá chào của các báo giá ĐƯỢC CHỌN (winner quote
// (winner quote total) — SUM ThanhTien báo giá của MỌI đơn vị IsWinner (multi-winner // total) — SUM ThanhTien các quote IsSelected (NGUỒN-SỰ-THẬT per hạng mục × NCC; Supplier.IsWinner
// Mig 58), KHÔNG còn chỉ SelectedSupplierId. 0 khi chưa chọn. Mirror predicate // = DERIVED == Any IsSelected). 0 khi chưa chọn. Mirror predicate submit-guard
// submit-guard (PurchaseEvaluationWorkflowService ~:188). FE so với // (PurchaseEvaluationWorkflowService ~:196). FE so với CeoApprovalThreshold hiển thị
// CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO". // "CCM duyệt-final" hoặc "cần CEO".
decimal WinnerQuoteTotal, decimal WinnerQuoteTotal,
// [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi // [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi
// phiếu chưa pin workflow V2 hoặc admin chưa set ngưỡng. // phiếu chưa pin workflow V2 hoặc admin chưa set ngưỡng.
@ -325,8 +325,8 @@ public record PurchaseEvaluationDetailBundleDto(
// flag BE-computed theo role (Procurement / CostControl | Admin — pattern S54). // flag BE-computed theo role (Procurement / CostControl | Admin — pattern S54).
// Lũy kế: các phiếu cùng cặp, Id != this, CreatedAt < this.CreatedAt: // Lũy kế: các phiếu cùng cặp, Id != this, CreatedAt < this.CreatedAt:
// PreviousSubmittedTotal = SUM(BudgetPeriodAmount) WHERE Phase IN (ChoDuyet, DaDuyet) // PreviousSubmittedTotal = SUM(BudgetPeriodAmount) WHERE Phase IN (ChoDuyet, DaDuyet)
// PreviousSelectedTotal = SUM(quote ThanhTien của SelectedSupplier) WHERE Phase = DaDuyet // PreviousSelectedTotal = SUM(quote ThanhTien WHERE IsSelected) WHERE Phase = DaDuyet
// CurrentProposalTotal = SUM(quote ThanhTien của SelectedSupplier) phiếu NÀY (0 khi chưa chọn). // CurrentProposalTotal = SUM(quote ThanhTien WHERE IsSelected) phiếu NÀY (0 khi chưa chọn).
public record PeBudgetSummaryDto( public record PeBudgetSummaryDto(
Guid? BudgetId, Guid? BudgetId,
decimal? ProEstimateAmount, decimal? ProEstimateAmount,

View File

@ -261,6 +261,9 @@ public class DeletePurchaseEvaluationDetailCommandHandler(
db.PurchaseEvaluationDetails.Remove(entity); db.PurchaseEvaluationDetails.Remove(entity);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// [S114 A2 INVARIANT — Bước 4 verify bắt] Cascade xóa quotes của hạng mục có thể bỏ quote
// IsSelected cuối của 1 NCC → re-derive IsWinner toàn phiếu (writer bị sót lúc implement).
await PeWinnerInvariant.ReSyncAfterQuoteRemovalAsync(db, request.PurchaseEvaluationId, ct);
} }
} }
@ -307,6 +310,13 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
existing.ThanhTien = request.ThanhTien; existing.ThanhTien = request.ThanhTien;
existing.IsSelected = request.IsSelected; existing.IsSelected = request.IsSelected;
existing.Note = request.Note; existing.Note = request.Note;
// A2(b) INVARIANT: supplier.IsWinner == đơn vị có ÍT NHẤT 1 quote IsSelected. Đổi
// IsSelected của 1 quote → re-derive IsWinner của supplier-row (quote KHÁC từ DB +
// OR giá trị vừa set — DB chưa flush thay đổi hiện tại nên loại trừ existing.Id).
supplier.IsWinner = request.IsSelected
|| await db.PurchaseEvaluationQuotes.AsNoTracking().AnyAsync(
q => q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId
&& q.Id != existing.Id && q.IsSelected, ct);
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {
PurchaseEvaluationId = request.PurchaseEvaluationId, PurchaseEvaluationId = request.PurchaseEvaluationId,
@ -333,6 +343,12 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
Note = request.Note, Note = request.Note,
}; };
db.PurchaseEvaluationQuotes.Add(entity); db.PurchaseEvaluationQuotes.Add(entity);
// A2(b) INVARIANT — quote MỚI: supplier winner nếu quote này IsSelected hoặc đã có quote
// IsSelected khác (chưa có cặp (detail,supplier) này trước đó nên không cần loại trừ).
supplier.IsWinner = request.IsSelected
|| await db.PurchaseEvaluationQuotes.AsNoTracking().AnyAsync(
q => q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId
&& q.IsSelected, ct);
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {
PurchaseEvaluationId = request.PurchaseEvaluationId, PurchaseEvaluationId = request.PurchaseEvaluationId,
@ -382,20 +398,65 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
db.PurchaseEvaluationQuotes.Remove(quote); db.PurchaseEvaluationQuotes.Remove(quote);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// [S114 A2 INVARIANT — Bước 4 verify bắt] Xóa quote có thể bỏ quote IsSelected CUỐI của 1
// NCC → re-derive IsWinner (writer bị sót ở implement → nếu không, IsWinner kẹt stale →
// CreateContract gom NCC phantom sinh HĐ giaTri=0).
await PeWinnerInvariant.ReSyncAfterQuoteRemovalAsync(db, request.PurchaseEvaluationId, ct);
} }
} }
// ========== Select winner (NCC/TP được chọn tổng thể — MULTI) ========== // [S114 A2 — cardinality invariant helper] Re-derive Supplier.IsWinner (== có ÍT NHẤT 1 quote
// IsSelected TOÀN PHIẾU) + re-sync SelectedSupplierId. Gọi SAU khi đã flush 1 mutation XÓA quote
// (DeleteQuote / DeleteDetail cascade) — nơi selection biến mất KHÔNG qua SelectWinner/UpsertQuote
// (2 writer bị sót lúc implement). Đọc DB đã-flush → không cần loại-trừ quote-đã-xóa.
internal static class PeWinnerInvariant
{
public static async Task ReSyncAfterQuoteRemovalAsync(
IApplicationDbContext db, Guid peId, CancellationToken ct)
{
var supplierRows = await db.PurchaseEvaluationSuppliers
.Where(s => s.PurchaseEvaluationId == peId).ToListAsync(ct);
if (supplierRows.Count == 0) return;
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP cùng // Supplier-row có ÍT NHẤT 1 quote IsSelected (bất kỳ hạng mục nào) = winner.
// trúng (whole-supplier multi-select). SupplierIds = tập đơn vị thắng: var selectedRowIds = (await (
// - List rỗng [] = BỎ CHỌN tất cả (un-select về none) — thay null cũ (anh Kiệt C5). from q in db.PurchaseEvaluationQuotes.AsNoTracking()
// - 1 phần tử = 1 winner (SelectedSupplierId sync = đơn vị đó — backward compat). join d in db.PurchaseEvaluationDetails.AsNoTracking()
// - ≥2 phần tử = joint winner (SelectedSupplierId = null vì không còn "1 winner"). on q.PurchaseEvaluationDetailId equals d.Id
// IsWinner trên supplier-row là nguồn-sự-thật mới; winnerQuoteTotal = SUM ThanhTien where d.PurchaseEvaluationId == peId && q.IsSelected
// MỌI winner-row. Mỗi id phải nằm trong danh sách NCC tham gia của phiếu. select q.PurchaseEvaluationSupplierId).ToListAsync(ct)).ToHashSet();
var changed = false;
foreach (var r in supplierRows)
{
var win = selectedRowIds.Contains(r.Id);
if (r.IsWinner != win) { r.IsWinner = win; changed = true; }
}
// Sync SelectedSupplierId (trục A legacy display): đúng 1 winner toàn phiếu → đơn vị đó; ≥2 hoặc 0 → null.
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(p => p.Id == peId, ct);
if (pe is not null)
{
var winnerSupplierIds = supplierRows.Where(r => r.IsWinner).Select(r => r.SupplierId).ToList();
var newSel = winnerSupplierIds.Count == 1 ? winnerSupplierIds[0] : (Guid?)null;
if (pe.SelectedSupplierId != newSel) { pe.SelectedSupplierId = newSel; changed = true; }
}
if (changed) await db.SaveChangesAsync(ct);
}
}
// ========== Select winner PER HẠNG MỤC (Detail × NCC — multi-NCC/1 hạng mục) ==========
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED] Chọn winner theo TỪNG HẠNG MỤC (Detail): mỗi hạng
// mục có thể có ≥2 NCC/TP cùng trúng, mỗi nhà 1 giá riêng (Quote.ThanhTien). NGUỒN-SỰ-THẬT =
// PurchaseEvaluationQuote.IsSelected (per Detail×Supplier). Supplier.IsWinner = DERIVED (đơn vị
// có ÍT NHẤT 1 quote IsSelected ở bất kỳ hạng mục nào). Body {DetailId, SupplierIds} = "hạng mục
// DetailId, các NCC SupplierIds thắng" → set Quote.IsSelected=true cho (DetailId × mỗi
// SupplierId), false cho NCC còn lại của DetailId đó, rồi derive IsWinner toàn phiếu +
// sync SelectedSupplierId (trục A legacy). SupplierIds rỗng [] = bỏ chọn mọi NCC cho hạng mục này.
public record SelectPurchaseEvaluationWinnerCommand( public record SelectPurchaseEvaluationWinnerCommand(
Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds) : IRequest; Guid PurchaseEvaluationId, Guid DetailId, IReadOnlyList<Guid> SupplierIds) : IRequest;
public class SelectPurchaseEvaluationWinnerCommandHandler( public class SelectPurchaseEvaluationWinnerCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
@ -406,29 +467,48 @@ 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);
// Load mọi supplier-row của phiếu (tracking — để set IsWinner). // Hạng mục phải thuộc phiếu (chặn cross-phiếu / stale).
var rows = await db.PurchaseEvaluationSuppliers var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
d => d.Id == request.DetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
// Load mọi supplier-row của phiếu (tracking — set IsWinner derived).
var supplierRows = await db.PurchaseEvaluationSuppliers
.Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId) .Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId)
.ToListAsync(ct); .ToListAsync(ct);
// Distinct các id được chọn (FE có thể gửi trùng). // Distinct id chọn (FE có thể gửi trùng) + map SupplierId(master) → supplier-row Id.
var selectedIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>(); var selectedSupplierIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>();
var rowIdBySupplierId = supplierRows.ToDictionary(r => r.SupplierId, r => r.Id);
// Mỗi id chọn phải là 1 đơn vị tham gia phiếu (chặn forge/stale). foreach (var sid in selectedSupplierIds)
var participatingSupplierIds = rows.Select(r => r.SupplierId).ToHashSet();
foreach (var sid in selectedIds)
{ {
if (!participatingSupplierIds.Contains(sid)) if (!rowIdBySupplierId.ContainsKey(sid))
throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá."); throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
} }
var winningRowIds = selectedSupplierIds.Select(sid => rowIdBySupplierId[sid]).ToHashSet();
// Set IsWinner per-row: true nếu nằm trong tập chọn, false phần còn lại. // Load MỌI quote của phiếu (JOIN qua Detail) — tracking. Cần cả phiếu để derive IsWinner
foreach (var r in rows) // đúng (1 đơn vị có thể còn thắng hạng mục khác dù mất selection ở hạng mục này).
r.IsWinner = selectedIds.Contains(r.SupplierId); var allQuotes = await (
from q in db.PurchaseEvaluationQuotes
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
where d.PurchaseEvaluationId == request.PurchaseEvaluationId
select q).ToListAsync(ct);
// Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng // Set IsSelected cho quotes CỦA HẠNG MỤC NÀY: true nếu supplier-row thắng, false còn lại.
// tạo HĐ + DTO cũ); ≥2 hoặc 0 → null. foreach (var q in allQuotes.Where(q => q.PurchaseEvaluationDetailId == request.DetailId))
entity.SelectedSupplierId = selectedIds.Count == 1 ? selectedIds.First() : null; q.IsSelected = winningRowIds.Contains(q.PurchaseEvaluationSupplierId);
// Derive Supplier.IsWinner (INVARIANT): winner ⟺ có ÍT NHẤT 1 quote IsSelected toàn phiếu.
var selectedRowIds = allQuotes.Where(q => q.IsSelected)
.Select(q => q.PurchaseEvaluationSupplierId).ToHashSet();
foreach (var r in supplierRows)
r.IsWinner = selectedRowIds.Contains(r.Id);
// Sync SelectedSupplierId (trục A legacy display): đúng 1 đơn vị winner toàn phiếu → đơn vị
// đó; ≥2 hoặc 0 → null. Derive từ tập IsWinner vừa tính (giữ trục A không stale).
var winnerSupplierIds = supplierRows.Where(r => r.IsWinner).Select(r => r.SupplierId).ToList();
entity.SelectedSupplierId = winnerSupplierIds.Count == 1 ? winnerSupplierIds[0] : null;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {
@ -438,9 +518,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 = selectedIds.Count == 0 Summary = selectedSupplierIds.Count == 0
? "Bỏ chọn đơn vị NCC/TP trúng thầu" ? $"Bỏ chọn NCC/TP trúng thầu cho hạng mục {detail.GroupCode}"
: $"Chọn {selectedIds.Count} đơn vị NCC/TP trúng thầu", : $"Chọn {selectedSupplierIds.Count} NCC/TP trúng thầu cho hạng mục {detail.GroupCode}",
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@ -901,29 +901,28 @@ public class GetPurchaseEvaluationQueryHandler(
var prevSubmittedCount = submitted.Count; var prevSubmittedCount = submitted.Count;
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m); var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
// [Mig 58 multi-winner] "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị // "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị IsWinner (existence — IsWinner
// IsWinner (thay `SelectedSupplierId != null` cũ — null khi ≥2 đồng-trúng nên // là DERIVED == Any quote IsSelected, giữ đúng ngữ nghĩa "có chọn thầu"; A3(ii)).
// bỏ sót phiếu liên-danh). Tổng = mọi báo giá của các đơn vị IsWinner.
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
&& p.Suppliers.Any(s => s.IsWinner)); && p.Suppliers.Any(s => s.IsWinner));
var prevSelectedCount = await selectedPeers.CountAsync(ct); var prevSelectedCount = await selectedPeers.CountAsync(ct);
// [multi-NCC A3(i)[5]] Tổng "đã chọn thầu" = SUM ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected
// = nguồn-sự-thật per Detail×Supplier) của các phiếu DaDuyet trước — thay whole-supplier IsWinner.
var prevSelectedTotal = await ( var prevSelectedTotal = await (
from p in selectedPeers from p in selectedPeers
join s in db.PurchaseEvaluationSuppliers.AsNoTracking() join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
on p.Id equals s.PurchaseEvaluationId on p.Id equals s.PurchaseEvaluationId
where s.IsWinner
join q in db.PurchaseEvaluationQuotes.AsNoTracking() join q in db.PurchaseEvaluationQuotes.AsNoTracking()
on s.Id equals q.PurchaseEvaluationSupplierId on s.Id equals q.PurchaseEvaluationSupplierId
where q.IsSelected
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m; select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
// Row 4 "Giá trị kỳ này" = tổng giá chào của MỌI đơn vị IsWinner phiếu này // Row 4 "Giá trị kỳ này" = SUM báo giá ĐƯỢC CHỌN (Quote.IsSelected) phiếu này
// [Mig 58 multi-winner] (khớp winnerQuoteTotal :1087 — dùng IsWinner thay // [multi-NCC A3(i)[5]] (khớp winnerQuoteTotal :1189 — nguồn-sự-thật per Detail×Supplier).
// SelectedSupplierId vì null khi ≥2 đồng-trúng → trước đây báo 0 cho liên-danh). var curSupplierRowIds = e.Suppliers.Select(s => s.Id).ToList();
var curWinnerRowIds = e.Suppliers.Where(s => s.IsWinner) var currentProposalTotal = curSupplierRowIds.Count == 0 ? 0m
.Select(s => s.Id).ToList();
var currentProposalTotal = curWinnerRowIds.Count == 0 ? 0m
: await db.PurchaseEvaluationQuotes.AsNoTracking() : await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => curWinnerRowIds.Contains(q.PurchaseEvaluationSupplierId)) .Where(q => curSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId) && q.IsSelected)
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m; .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho // [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho
@ -1183,16 +1182,12 @@ public class GetPurchaseEvaluationQueryHandler(
} }
} }
// [S69] Giá trị gói = tổng ThanhTien báo giá của NCC/TP ĐƯỢC CHỌN (winner). // [multi-NCC 2026-07-12 — anh Kiệt LOCKED D1] Giá trị gói = SUM ThanhTien báo giá ĐƯỢC
// Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi // CHỌN per hạng mục (Quote.IsSelected = nguồn-sự-thật). Tính từ data đã load (Details.Quotes)
// chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO". // — KHÔNG query thêm. 0 khi chưa chọn NCC nào. FE so CeoApprovalThreshold → "CCM duyệt-final".
var winnerSupplierRowIds = e.Suppliers
.Where(s => s.IsWinner) // [Mig 58] multi-winner: tổng MỌI đơn vị IsWinner
.Select(s => s.Id)
.ToHashSet();
var winnerQuoteTotal = e.Details var winnerQuoteTotal = e.Details
.SelectMany(det => det.Quotes) .SelectMany(det => det.Quotes)
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId)) .Where(q => q.IsSelected)
.Sum(q => q.ThanhTien); .Sum(q => q.ThanhTien);
// [Mig 54] Capability nhập giá đề xuất theo role (mirror PeBudgetSummary canEdit). // [Mig 54] Capability nhập giá đề xuất theo role (mirror PeBudgetSummary canEdit).

View File

@ -1260,6 +1260,21 @@ public static class DbInitializer
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// [multi-NCC 2026-07-12 — A2(c)] Derive IsWinner mỗi supplier-row từ grid IsSelected
// (INVARIANT: supplier.IsWinner == đơn vị có ÍT NHẤT 1 quote IsSelected). Đặt SAU khi
// tạo quotes để seed đồng bộ nguồn-sự-thật (Quote.IsSelected) với derived flag.
for (int si = 0; si < supplierEntities.Count; si++)
{
bool anySelected = false;
for (int di = 0; di < detailEntities.Count && di < quotesGrid.Length; di++)
{
var gridRow = quotesGrid[di];
if (si < gridRow.Length && gridRow[si].IsSelected) { anySelected = true; break; }
}
supplierEntities[si].IsWinner = anySelected;
}
await db.SaveChangesAsync();
// Approval history theo flow của type // Approval history theo flow của type
PurchaseEvaluationPhase[] flow = type == PurchaseEvaluationType.DuyetNcc PurchaseEvaluationPhase[] flow = type == PurchaseEvaluationType.DuyetNcc
? [PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM, ? [PurchaseEvaluationPhase.ChoPurchasing, PurchaseEvaluationPhase.ChoCCM,

View File

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BackfillPeQuoteIsSelectedAndWinnerInvariant : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED A2/A6] Data-only backfill 2 CHIỀU thiết lập
// BẤT BIẾN: PurchaseEvaluationSupplier.IsWinner == đơn vị có ÍT NHẤT 1
// PurchaseEvaluationQuote.IsSelected. NGUỒN-SỰ-THẬT mới = Quote.IsSelected (per
// Detail×Supplier); IsWinner (whole-supplier, Mig 58) → DERIVED. Idempotent (WHERE ...=0
// → re-run no-op). SQL-Server-only (tests SQLite EnsureCreated KHÔNG replay migration).
// CHIỀU 1 (IsWinner → IsSelected): phiếu cũ mô hình whole-supplier winner (Mig 58) →
// mọi báo giá của đơn vị IsWinner=1 được đánh IsSelected=1 (đơn vị thắng cả gói → mọi
// hạng mục của họ được chọn). Preserve tổng: winnerQuoteTotal cũ (SUM quote của IsWinner)
// == winnerQuoteTotal mới (SUM quote IsSelected). Mirror Mig 58 idempotent.
migrationBuilder.Sql(@"
UPDATE q SET IsSelected = 1
FROM PurchaseEvaluationQuotes q
INNER JOIN PurchaseEvaluationSuppliers s ON s.Id = q.PurchaseEvaluationSupplierId
WHERE s.IsWinner = 1 AND q.IsSelected = 0;");
// CHIỀU 2 (IsSelected → IsWinner): dữ liệu dormant/seed có Quote.IsSelected=1 nhưng
// supplier-row IsWinner=0 (per-hạng-mục selection chưa đồng bộ flag) → set IsWinner=1
// cho đơn vị có tồn tại ≥1 quote IsSelected. Sau 2 chiều: IsWinner ⟺ Any(IsSelected).
migrationBuilder.Sql(@"
UPDATE s SET IsWinner = 1
FROM PurchaseEvaluationSuppliers s
WHERE s.IsWinner = 0
AND EXISTS (
SELECT 1 FROM PurchaseEvaluationQuotes q
WHERE q.PurchaseEvaluationSupplierId = s.Id AND q.IsSelected = 1);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Data-only backfill KHÔNG reversible (không snapshot trạng thái IsSelected/IsWinner
// trước khi thiết lập bất biến; revert = phá bất biến / mất per-hạng-mục selection).
// No-op an toàn — mirror BackfillEndedByLevelFinalizeFromChangelog (Mig 61).
}
}
}

View File

@ -189,25 +189,23 @@ 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>();
// [Mig 58 CCM item 1] Multi-winner: winner = các supplier-row IsWinner (≥1). Tổng giá // [multi-NCC A3(i)[2] + D5] Winner = có ÍT NHẤT 1 báo giá ĐƯỢC CHỌN (Quote.IsSelected
// gói = SUM ThanhTien MỌI winner-row (vd 3 đơn vị 37.8+9+10=56.8tr). 0 winner → // = nguồn-sự-thật per Detail×Supplier). Tổng giá gói = SUM ThanhTien mọi quote IsSelected
// "chưa chọn"; có winner nhưng tổng ≤ 0 → "chưa có giá chào thầu" (item 2 chỉ nới // toàn phiếu (D5 giữ >0). 0 selected → "chưa chọn"; có selected nhưng tổng ≤0 → "chưa có giá"
// NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá). // (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() var selectedThanhTien = await (
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) from q in db.PurchaseEvaluationQuotes.AsNoTracking()
.Select(s => s.Id) join d in db.PurchaseEvaluationDetails.AsNoTracking()
.ToListAsync(ct); on q.PurchaseEvaluationDetailId equals d.Id
if (winnerRowIds.Count == 0) where d.PurchaseEvaluationId == evaluation.Id && q.IsSelected
select q.ThanhTien).ToListAsync(ct);
if (selectedThanhTien.Count == 0)
{ {
missing.Add("chưa chọn Đơn vị NCC/TP"); missing.Add("chưa chọn Đơn vị NCC/TP");
} }
else else if (selectedThanhTien.Sum() <= 0)
{ {
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking() missing.Add("Đơn vị được chọn chưa có giá chào thầu");
.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
@ -881,14 +879,14 @@ public class PurchaseEvaluationWorkflowService(
throw new ForbiddenException( throw new ForbiddenException(
"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() // [multi-NCC A3(i)[3]] Giá gói = SUM ThanhTien báo giá ĐƯỢC CHỌN (Quote.IsSelected =
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) // [Mig 58] multi-winner // nguồn-sự-thật) toàn phiếu. CCM duyệt done miễn CEO chỉ khi giá gói < ngưỡng (strict).
.Select(s => s.Id) var winnerQuoteTotal = await (
.ToListAsync(ct); from q in db.PurchaseEvaluationQuotes.AsNoTracking()
var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m join d in db.PurchaseEvaluationDetails.AsNoTracking()
: await db.PurchaseEvaluationQuotes.AsNoTracking() on q.PurchaseEvaluationDetailId equals d.Id
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId)) where d.PurchaseEvaluationId == evaluation.Id && q.IsSelected
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m; select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
if (winnerQuoteTotal >= ceoThreshold) if (winnerQuoteTotal >= ceoThreshold)
throw new ConflictException( throw new ConflictException(

View File

@ -64,6 +64,11 @@ public class CreateContractFromEvaluationMultiWinnerTests
// Seed 1 PE (default DaDuyet) + N supplier rows; winnersByIndex marks which supplier-rows // Seed 1 PE (default DaDuyet) + N supplier rows; winnersByIndex marks which supplier-rows
// are IsWinner. detailQuotes[i][j] = ThanhTien báo giá của supplier j cho detail i. // are IsWinner. detailQuotes[i][j] = ThanhTien báo giá của supplier j cho detail i.
// detailBudgets[i] = ThanhTienNganSach (ngân sách) của detail i. // detailBudgets[i] = ThanhTienNganSach (ngân sách) của detail i.
// [multi-NCC 2026-07-12 cardinality-change] GiaTri mỗi HĐ NAY = SUM ThanhTien quote ĐƯỢC CHỌN
// (Quote.IsSelected) của winner đó — CreateContractFromEvaluationFeatures.cs:88-90
// `q.PurchaseEvaluationSupplierId == w.Id && q.IsSelected`. Nên quote của winner PHẢI
// IsSelected=true mới cộng vào GiaTri. detailQuoteSelected[i][j] override IsSelected per
// (detail,supplier); null → mặc định winner's-all-selected (IsSelected = winnerIndexes.Contains(j)).
private static async Task<Seeded> SeedAsync( private static async Task<Seeded> SeedAsync(
IdentityFixture fix, IdentityFixture fix,
TestApplicationDbContext db, TestApplicationDbContext db,
@ -71,7 +76,8 @@ public class CreateContractFromEvaluationMultiWinnerTests
int[] winnerIndexes, int[] winnerIndexes,
decimal[] detailBudgets, decimal[] detailBudgets,
decimal[][] detailQuotes, decimal[][] detailQuotes,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet) PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet,
bool[][]? detailQuoteSelected = null)
{ {
var actor = await fix.CreateUserAsync("drafter-d2@test.local", "Drafter D2", var actor = await fix.CreateUserAsync("drafter-d2@test.local", "Drafter D2",
departmentId: null, roles: new[] { AppRoles.Drafter }); departmentId: null, roles: new[] { AppRoles.Drafter });
@ -136,12 +142,16 @@ public class CreateContractFromEvaluationMultiWinnerTests
// Quote per supplier cho detail này — FK PESupplierId → supplier-row.Id. // Quote per supplier cho detail này — FK PESupplierId → supplier-row.Id.
for (var j = 0; j < supplierCount; j++) for (var j = 0; j < supplierCount; j++)
{ {
var isSelected = detailQuoteSelected is not null
? detailQuoteSelected[i][j]
: winnerIndexes.Contains(j); // mặc định: quote của winner = được chọn
detail.Quotes.Add(new PurchaseEvaluationQuote detail.Quotes.Add(new PurchaseEvaluationQuote
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detail.Id, PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = suppliers[j].Item2.Id, PurchaseEvaluationSupplierId = suppliers[j].Item2.Id,
ThanhTien = detailQuotes[i][j], ThanhTien = detailQuotes[i][j],
IsSelected = isSelected,
}); });
} }
pe.Details.Add(detail); pe.Details.Add(detail);
@ -206,16 +216,19 @@ public class CreateContractFromEvaluationMultiWinnerTests
peReloaded.ContractId.Should().Be(ids[0]); peReloaded.ContractId.Should().Be(ids[0]);
} }
// ===== (b) single winner → 1 HĐ, GiaTri = details budget sum (LEGACY preserved) ===== // ===== single winner → 1 HĐ, GiaTri = SUM báo giá ĐƯỢC CHỌN của winner (D4) =====
// [multi-NCC 2026-07-12 — SPEC CHANGE D4] BỎ shortcut detailsSum (budget) cũ cho single.
// NAY single = SUM(Quote.IsSelected của winner), giống multi. Trước đây test này assert
// GiaTri = 800 (budget sum); giờ = 140 (100+40 = quote ĐƯỢC CHỌN của winner0).
[Fact] [Fact]
public async Task Create_SingleWinner_GiaTriIsDetailsBudgetSum_NotQuoteTotal() public async Task Create_SingleWinner_GiaTriIsSelectedQuoteTotal_NotBudgetSum()
{ {
using var fix = new IdentityFixture(); using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>(); var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
// 1 winner (idx 0). detailBudgets sum = 500 + 300 = 800. winner0 quote total = // 1 winner (idx 0). detailBudgets sum = 500 + 300 = 800 (KHÔNG dùng nữa). winner0 quote
// 100 + 40 = 140. Single-winner PHẢI = 800 (budget), KHÔNG = 140 (quote). // ĐƯỢC CHỌN = 100 + 40 = 140. Single-winner NAY = 140 (quote IsSelected), KHÔNG = 800 (budget).
var seeded = await SeedAsync(fix, db, var seeded = await SeedAsync(fix, db,
supplierCount: 2, supplierCount: 2,
winnerIndexes: new[] { 0 }, winnerIndexes: new[] { 0 },
@ -235,7 +248,7 @@ public class CreateContractFromEvaluationMultiWinnerTests
var contract = await db.Contracts.AsNoTracking().FirstAsync(c => c.Id == ids[0]); var contract = await db.Contracts.AsNoTracking().FirstAsync(c => c.Id == ids[0]);
contract.SupplierId.Should().Be(seeded.Suppliers[0].supplier.Id); contract.SupplierId.Should().Be(seeded.Suppliers[0].supplier.Id);
contract.GiaTri.Should().Be(800m); // LEGACY: budget sum, NOT 140 contract.GiaTri.Should().Be(140m, "D4: single-winner = SUM báo giá ĐƯỢC CHỌN (100+40), KHÔNG phải budget 800");
contract.Phase.Should().Be(ContractPhase.DangSoanThao); contract.Phase.Should().Be(ContractPhase.DangSoanThao);
contract.MaHopDong.Should().NotBeNull(); contract.MaHopDong.Should().NotBeNull();
// TenHopDong null trong request → fallback pe.TenGoiThau. // TenHopDong null trong request → fallback pe.TenGoiThau.
@ -245,6 +258,45 @@ public class CreateContractFromEvaluationMultiWinnerTests
peReloaded.ContractId.Should().Be(ids[0]); peReloaded.ContractId.Should().Be(ids[0]);
} }
// ===== (b) [multi-NCC 2026-07-12 — NEW] single-winner thắng SUBSET hạng mục =====
// Winner thắng CHỈ 1 trong 2 hạng mục (detail0 IsSelected=true, detail1 IsSelected=false)
// → GiaTri = SUM(selected quotes) = quote detail0 THÔI, KHÁC SUM-mọi-quote-của-winner
// (chống bug detailsSum/all-quote cũ). Chứng minh handler lọc theo Quote.IsSelected per hạng mục.
[Fact]
public async Task Create_SingleWinner_WinsSubsetOfDetails_GiaTriSumsOnlySelectedQuotes()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
// Winner = supplier0. detail0 quote 300 ĐƯỢC CHỌN, detail1 quote 500 KHÔNG chọn.
// GiaTri = 300 (chỉ selected); SUM-mọi-quote-winner = 800; budget-sum = 2000. Cả 3 khác nhau.
var seeded = await SeedAsync(fix, db,
supplierCount: 2,
winnerIndexes: new[] { 0 },
detailBudgets: new[] { 1000m, 1000m }, // sum 2000 — KHÔNG dùng
detailQuotes: new[]
{
new[] { 300m, 11m }, // detail0: winner0=300
new[] { 500m, 13m }, // detail1: winner0=500 (KHÔNG chọn)
},
detailQuoteSelected: new[]
{
new[] { true, false }, // detail0: winner0 ĐƯỢC CHỌN
new[] { false, false }, // detail1: winner0 KHÔNG chọn
});
var handler = BuildHandler(db, fix.Services, seeded.Actor);
var ids = await handler.Handle(
new CreateContractFromEvaluationCommand(seeded.Pe.Id, ContractType.HopDongThauPhu, null),
CancellationToken.None);
ids.Should().HaveCount(1);
var contract = await db.Contracts.AsNoTracking().FirstAsync(c => c.Id == ids[0]);
contract.SupplierId.Should().Be(seeded.Suppliers[0].supplier.Id);
contract.GiaTri.Should().Be(300m,
"SUM chỉ quote ĐƯỢC CHỌN (detail0=300); KHÁC SUM-mọi-quote-winner (800) + budget-sum (2000)");
}
// ===== (c) idempotency → re-call throws ConflictException ===== // ===== (c) idempotency → re-call throws ConflictException =====
[Fact] [Fact]

View File

@ -20,6 +20,12 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// NẾU EF KHÔNG translate (runtime InvalidOperationException) → test FAIL = blocker // NẾU EF KHÔNG translate (runtime InvalidOperationException) → test FAIL = blocker
// REPORT em main (fallback 2-pass). Dùng SqliteDbFixture (KHÔNG cần Identity — query // REPORT em main (fallback 2-pass). Dùng SqliteDbFixture (KHÔNG cần Identity — query
// chỉ đọc, DrafterUserId LEFT-join Users nullable). // chỉ đọc, DrafterUserId LEFT-join Users nullable).
//
// [multi-NCC 2026-07-12 cardinality-change] Winner-truth chuyển whole-supplier IsWinner →
// per-(Detail×Supplier) Quote.IsSelected; NHƯNG Supplier.IsWinner GIỮ là cột PERSISTED
// (BE tự derive = Any quote IsSelected trong SelectWinner/UpsertQuote handler). WinnerSupplierNames
// projection ĐỌC THẲNG cột IsWinner đã persist (`where sw.IsWinner`) → KHÔNG đổi theo cardinality.
// Seed IsWinner trực tiếp = hợp lệ cho translation-smoke (không cần seed quote — projection không đọc quote).
public class PeListWinnerNamesProjectionTests public class PeListWinnerNamesProjectionTests
{ {
private static (Project project, Supplier[] suppliers) SeedMasters(TestApplicationDbContext db, int supplierCount) private static (Project project, Supplier[] suppliers) SeedMasters(TestApplicationDbContext db, int supplierCount)

View File

@ -72,7 +72,11 @@ public class PeMultiWinnerTests
} }
// 1 NCC tham gia (master + PES row) + 1 quote ThanhTien thuộc detail. isWinner pin // 1 NCC tham gia (master + PES row) + 1 quote ThanhTien thuộc detail. isWinner pin
// IsWinner trên PES row. Return supplierId master. // IsWinner trên PES row VÀ IsSelected trên quote (invariant IsWinner==Any(IsSelected)).
// [multi-NCC 2026-07-12 cardinality-change] DTO WinnerQuoteTotal NAY = SUM ThanhTien
// quote ĐƯỢC CHỌN (Quote.IsSelected — PurchaseEvaluationFeatures.cs:1188-1191
// `.Where(q => q.IsSelected)`), KHÔNG còn build từ IsWinner supplier-row. Nên winner PHẢI
// có quote.IsSelected=true thì mới lọt tổng; loser IsSelected=false → bị loại. Return supplierId.
private static async Task<Guid> SeedSupplierWithQuoteAsync( private static async Task<Guid> SeedSupplierWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid detailId, TestApplicationDbContext db, PurchaseEvaluation pe, Guid detailId,
string code, decimal thanhTien, bool isWinner, int order) string code, decimal thanhTien, bool isWinner, int order)
@ -94,6 +98,7 @@ public class PeMultiWinnerTests
PurchaseEvaluationDetailId = detailId, PurchaseEvaluationDetailId = detailId,
PurchaseEvaluationSupplierId = pes.Id, PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien, ThanhTien = thanhTien,
IsSelected = isWinner, // [multi-NCC] winner ⟺ báo giá được chọn
}); });
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
return supplier.Id; return supplier.Id;
@ -215,11 +220,13 @@ public class PeMultiWinnerTests
{ {
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1, Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1,
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 20_000_000m, PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 20_000_000m,
IsSelected = true, // [multi-NCC] cả 2 dòng của winner ĐƯỢC CHỌN → vào tổng
}); });
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{ {
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id, Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id,
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 30_000_000m, PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 30_000_000m,
IsSelected = true,
}); });
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
@ -228,4 +235,35 @@ public class PeMultiWinnerTests
bundle.WinnerQuoteTotal.Should().Be(50_000_000m, "SUM mọi dòng báo giá của winner-row qua nhiều detail"); bundle.WinnerQuoteTotal.Should().Be(50_000_000m, "SUM mọi dòng báo giá của winner-row qua nhiều detail");
} }
// ===== [multi-NCC 2026-07-12 cardinality-change — NEW test (a)] =====
// ≥2 NCC/TP cùng trúng TRÊN CÙNG 1 HẠNG MỤC (Detail) — mỗi nhà 1 giá riêng (Quote.ThanhTien).
// winnerQuoteTotal = SUM 2 quote IsSelected CỦA hạng mục đó; NCC thứ 3 chào cùng hạng mục
// nhưng KHÔNG được chọn (IsSelected=false) → bị loại. Đây là trục cardinality-change cốt lõi:
// winner-truth = per-(Detail×Supplier) Quote.IsSelected, KHÔNG phải whole-supplier.
[Fact]
public async Task WinnerQuoteTotal_TwoNccSameDetail_SumsBothSelected_ExcludesUnselectedThird()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-MW-2NCC1HM");
var detailId = await SeedDetailAsync(db, pe); // 1 hạng mục dùng chung 3 NCC
// 3 NCC chào CÙNG hạng mục detailId: 2 được chọn (33 + 27) + 1 không chọn (50).
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-P1", 33_000_000m, isWinner: true, order: 0);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-P2", 27_000_000m, isWinner: true, order: 1);
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-P3", 50_000_000m, isWinner: false, 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(60_000_000m,
"≥2 NCC trúng CÙNG 1 hạng mục → SUM 2 quote ĐƯỢC CHỌN (33+27); NCC thứ 3 không chọn bị loại");
bundle.SelectedSupplierId.Should().BeNull("≥2 đơn vị cùng trúng → không có 1 winner duy nhất");
// Quote của NCC thứ 3 tồn tại nhưng IsSelected=false → chứng minh loại theo IsSelected.
var detailDto = bundle.Details.Single(d => d.Id == detailId);
detailDto.Quotes.Should().HaveCount(3, "cả 3 NCC đều có báo giá cho hạng mục");
detailDto.Quotes.Count(q => q.IsSelected).Should().Be(2, "đúng 2 báo giá được chọn");
}
} }

View File

@ -10,26 +10,32 @@ using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application; namespace SolutionErp.Infrastructure.Tests.Application;
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-after] SelectPurchaseEvaluationWinnerCommand // [multi-NCC 2026-07-12 — anh Kiệt LOCKED — cardinality-change test-before] SelectPurchaseEvaluationWinnerCommand
// MULTI-WINNER. Command record ĐỔI `(Guid PurchaseEvaluationId, Guid? SupplierId)` → // đổi `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)` → thêm DetailId:
// `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)`. // `(Guid PurchaseEvaluationId, Guid DetailId, IReadOnlyList<Guid> SupplierIds)`. Winner NAY chọn
// Handler (PurchaseEvaluationDetailFeatures.cs ~404-447): // theo TỪNG HẠNG MỤC (Detail) — mỗi hạng mục ≥2 NCC/TP có thể cùng trúng, mỗi nhà 1 giá riêng.
// • Load MỌI supplier-row của phiếu (tracking) → set IsWinner = (row.SupplierId ∈
// selectedIds), false phần còn lại. IsWinner trên supplier-row = nguồn-sự-thật mới.
// • selectedIds rỗng [] = BỎ CHỌN tất cả (IsWinner=false hết) — thay null cũ (anh Kiệt C5).
// • Mỗi id chọn PHẢI thuộc participating-list (PurchaseEvaluationSuppliers) → Conflict.
// • 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 (PurchaseEvaluationDetailFeatures.cs ~410-480):
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name. // • Load PE (NotFound) → Load Detail thuộc PE (NotFound) → Load MỌI supplier-row của phiếu.
// • Validate mỗi selected SupplierId ∈ participating-list → Conflict "NCC chưa được thêm vào phiếu".
// • Load MỌI quote của phiếu → set Quote.IsSelected cho quotes CỦA DetailId NÀY: true nếu
// supplier-row thắng, false còn lại. NGUỒN-SỰ-THẬT = Quote.IsSelected (per Detail×Supplier).
// • Derive Supplier.IsWinner (INVARIANT): winner ⟺ có ÍT NHẤT 1 quote IsSelected TOÀN PHIẾU.
// • Sync SelectedSupplierId: đúng 1 winner toàn phiếu → đơn vị đó; ≥2 HOẶC 0 → null.
// • Changelog Header/Update Summary: count==0 → "Bỏ chọn NCC/TP trúng thầu cho hạng mục {GroupCode}";
// else "Chọn {n} NCC/TP trúng thầu cho hạng mục {GroupCode}".
//
// ⚠️ CARDINALITY-CHANGE (khác test cũ): IsWinner KHÔNG còn set-thẳng — nó DERIVE từ Quote.IsSelected.
// Muốn supplier thành winner qua SelectWinner thì (Detail × Supplier) PHẢI có 1 quote để handler
// flip IsSelected → derive IsWinner. Test seed 1 detail + 1 quote/participating-row (helper
// AddParticipatingWithQuoteAsync). Test dùng SQLite EnsureCreated — KHÔNG replay migration
// backfill (chỉ SQL-Server) → seed IsSelected/IsWinner trực tiếp cho pre-state.
//
// Handler 2-dep (db + ICurrentUser) → SqliteDbFixture đủ nhẹ. ICurrentUser fake để Changelog ghi.
public class PeSelectWinnerClearTests public class PeSelectWinnerClearTests
{ {
private const string DetailGroupCode = "A.I";
private sealed class FakeCurrentUser : ICurrentUser private sealed class FakeCurrentUser : ICurrentUser
{ {
public Guid? UserId { get; init; } = Guid.NewGuid(); public Guid? UserId { get; init; } = Guid.NewGuid();
@ -43,6 +49,10 @@ public class PeSelectWinnerClearTests
TestApplicationDbContext db) TestApplicationDbContext db)
=> new(db, new FakeCurrentUser()); => new(db, new FakeCurrentUser());
private static UpsertPurchaseEvaluationQuoteCommandHandler BuildUpsertQuoteHandler(
TestApplicationDbContext db)
=> new(db, new FakeCurrentUser());
private static async Task<Supplier> SeedSupplierAsync( private static async Task<Supplier> SeedSupplierAsync(
TestApplicationDbContext db, string code) TestApplicationDbContext db, string code)
{ {
@ -52,8 +62,8 @@ public class PeSelectWinnerClearTests
return s; return s;
} }
// Phiếu PE + (tùy chọn) SelectedSupplierId pre-set. KHÔNG tự thêm participating // Phiếu PE Nháp + (tùy chọn) SelectedSupplierId pre-set. KHÔNG tự thêm participating
// row — caller quyết định để cô lập nhánh clear (skip-list) vs select (need-list). // row — caller quyết định để cô lập nhánh clear vs select.
private static async Task<PurchaseEvaluation> SeedPeAsync( private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db, Guid? selectedSupplierId, string code = "PE-SW") TestApplicationDbContext db, Guid? selectedSupplierId, string code = "PE-SW")
{ {
@ -72,9 +82,54 @@ public class PeSelectWinnerClearTests
return pe; return pe;
} }
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu. // 1 hạng mục (Detail) cho phiếu. Winner chọn theo hạng mục → mọi command cần DetailId này.
// Return PES row Id để assert IsWinner. private static async Task<Guid> SeedDetailAsync(
private static async Task<Guid> AddParticipatingAsync( TestApplicationDbContext db, Guid peId, string groupCode = DetailGroupCode)
{
var detail = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = peId,
GroupCode = groupCode,
GroupName = "Bê tông",
NoiDung = "Concrete M100",
Order = 0,
};
db.PurchaseEvaluationDetails.Add(detail);
await db.SaveChangesAsync(CancellationToken.None);
return detail.Id;
}
// Gắn supplier vào participating-list + 1 quote thuộc detailId. isSelected pin pre-state
// (quote.IsSelected + pes.IsWinner giữ invariant). SelectWinner sẽ flip IsSelected của
// quote NÀY → derive IsWinner. Return PES row Id.
private static async Task<Guid> AddParticipatingWithQuoteAsync(
TestApplicationDbContext db, Guid peId, Guid supplierId, Guid detailId,
bool isSelected = false, decimal thanhTien = 100m, int order = 0)
{
var row = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = peId,
SupplierId = supplierId,
Order = order,
IsWinner = isSelected, // derived == Any(quote.IsSelected)
};
db.PurchaseEvaluationSuppliers.Add(row);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detailId,
PurchaseEvaluationSupplierId = row.Id,
ThanhTien = thanhTien,
IsSelected = isSelected,
});
await db.SaveChangesAsync(CancellationToken.None);
return row.Id;
}
// Participating-row THUẦN (không quote) — cho test derive-invariant qua UpsertQuote.
private static async Task<Guid> AddParticipatingNoQuoteAsync(
TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0) TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
{ {
var row = new PurchaseEvaluationSupplier var row = new PurchaseEvaluationSupplier
@ -98,52 +153,48 @@ public class PeSelectWinnerClearTests
.FirstAsync(); .FirstAsync();
// ============================================================ // ============================================================
// 1. CLEAR branch ([] = bỏ chọn tất cả — trục quan trọng nhất) // 1. CLEAR branch ([] = bỏ chọn cho hạng mục — trục quan trọng nhất)
// ============================================================ // ============================================================
[Fact] [Fact]
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull_AllRowsNotWinner() public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull_AllRowsNotWinner()
{ {
// ⭐ Phiếu đang có 1 winner (s1, IsWinner=true) + s1 trong participating-list → // ⭐ Phiếu đang có 1 winner (s1, quote IsSelected=true → IsWinner=true) + s1 trong
// gọi SupplierIds=[] → clear: SelectedSupplierId=null + s1.IsWinner=false. // participating-list → gọi SupplierIds=[] cho hạng mục → clear: quote IsSelected=false,
// IsWinner derive=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-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 detailId = await SeedDetailAsync(db, pe.Id);
// Pre-set s1 = winner (mô phỏng phiếu đã chọn trước). await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
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, SupplierIds: Array.Empty<Guid>()); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, 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"); (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear → quote IsSelected=false → IsWinner=false");
} }
[Fact] [Fact]
public async Task Clear_EmptyList_AllRowsNotWinner_NoConflict() public async Task Clear_EmptyList_AllRowsNotWinner_NoConflict()
{ {
// Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không // Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không có id
// có id cần kiểm). Phiếu 2 NCC tham gia, s1 đang là winner → clear [] // cần kiểm). Phiếu 2 NCC tham gia, s1 winner (quote sel=true) → clear [] cho hạng mục
// KHÔNG ném + cả 2 row IsWinner=false + SelectedSupplierId=null. // KHÔNG ném + cả 2 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-1"); var s1 = await SeedSupplierAsync(db, "NCC-1");
var s2 = await SeedSupplierAsync(db, "NCC-2"); var s2 = await SeedSupplierAsync(db, "NCC-2");
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); var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true, order: 0);
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id); await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
r1.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>()); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, 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");
@ -156,14 +207,16 @@ public class PeSelectWinnerClearTests
[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, không row IsWinner) → // Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không quote IsSelected) →
// clear [] lần nữa = idempotent, vẫn null, không ném. Empty list cũng không cản. // clear [] cho hạng mục lần nữa = idempotent, vẫn null, không ném. Cần seed detail
// (handler load Detail thuộc phiếu). Không có supplier/quote.
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 detailId = await SeedDetailAsync(db, pe.Id);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>()); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, 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();
@ -174,22 +227,23 @@ 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 đơn vị // Nhánh clear ghi 1 Changelog Header/Update Summary "Bỏ chọn NCC/TP trúng thầu cho
// NCC/TP trúng thầu" (phân biệt với "Chọn {n} đơn vị..." của nhánh select). // hạng mục {GroupCode}" (phân biệt với "Chọn {n}..." 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 detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>()); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, 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 đơn vị NCC/TP trúng thầu"); log.Summary.Should().Be($"Bỏ chọn NCC/TP trúng thầu cho hạng mục {DetailGroupCode}");
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header); log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
log.Action.Should().Be(ChangelogAction.Update); log.Action.Should().Be(ChangelogAction.Update);
} }
@ -201,16 +255,17 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Select_OneSupplierInList_SetsSelectedSupplierId_RowIsWinner() public async Task Select_OneSupplierInList_SetsSelectedSupplierId_RowIsWinner()
{ {
// Happy-path single-winner: 1 supplier hợp lệ (thuộc participating-list) → // Happy-path single-winner: 1 supplier hợp lệ (trong list, có quote cho hạng mục) →
// SelectedSupplierId = đúng id (count==1 sync) + row IsWinner=true. // quote IsSelected=true → IsWinner=true + SelectedSupplierId = đúng id (count==1 sync).
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");
var pe = await SeedPeAsync(db, selectedSupplierId: null); var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id); var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id }); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, 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);
@ -221,22 +276,20 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2_OnlyS2IsWinner() 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 (quote sel=true) → chọn [s2] cho hạng mục → s2 quote
// chuyển s2 + s2.IsWinner=true + s1.IsWinner=false (set false phần còn lại). // IsSelected=true + s1 quote IsSelected=false → SelectedSupplierId=s2 + s2.IsWinner=true
// + 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-1"); var s1 = await SeedSupplierAsync(db, "NCC-1");
var s2 = await SeedSupplierAsync(db, "NCC-2"); var s2 = await SeedSupplierAsync(db, "NCC-2");
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); var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true, order: 0);
// s1 đang là winner. await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, 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, SupplierIds: new[] { s2.Id }); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, 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);
@ -248,20 +301,18 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate() public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
{ {
// Supplier không thuộc participating-list của phiếu → Conflict. IsWinner + // Supplier không thuộc participating-list → Conflict. IsWinner + SelectedSupplierId giữ
// SelectedSupplierId giữ nguyên (throw TRƯỚC khi set + SaveChanges). // nguyên (throw TRƯỚC khi flip IsSelected + 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 (winner)
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); var detailId = await SeedDetailAsync(db, pe.Id);
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id); await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
r1.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { outsider.Id }); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, 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>()
@ -275,18 +326,18 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Select_OneIdInListOneOutside_ThrowsConflict_NoPartialMutate() public async Task Select_OneIdInListOneOutside_ThrowsConflict_NoPartialMutate()
{ {
// Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict (mỗi id // Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict. KHÔNG
// phải thuộc list). KHÔNG partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ // partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ (validate TRƯỚC khi flip IsSelected).
// (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 s1 = await SeedSupplierAsync(db, "NCC-OK");
var outsider = await SeedSupplierAsync(db, "NCC-FORGE"); 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 detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, outsider.Id }); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, 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<ConflictException>() await act.Should().ThrowAsync<ConflictException>()
@ -295,25 +346,26 @@ public class PeSelectWinnerClearTests
} }
// ============================================================ // ============================================================
// 3. SELECT branch — MULTI winner (≥2 → joint winner, SelectedSupplierId=null) // 3. SELECT branch — MULTI winner CÙNG HẠNG MỤC (≥2 → joint, SelectedSupplierId=null)
// [TASK 2.1] regression cho hành vi multi-winner mới (Mig 58). // [multi-NCC] ≥2 NCC/TP trúng CÙNG 1 hạng mục.
// ============================================================ // ============================================================
[Fact] [Fact]
public async Task Select_TwoSuppliers_BothIsWinner_SelectedSupplierIdNull() public async Task Select_TwoSuppliers_BothIsWinner_SelectedSupplierIdNull()
{ {
// Chọn 2 đơn vị cùng trúng → cả 2 IsWinner=true, SelectedSupplierId=null (≥2 → // Chọn 2 đơn vị cùng trúng 1 hạng mục → cả 2 quote IsSelected=true → cả 2 IsWinner=true,
// không còn "1 winner" duy nhất). Trục multi-winner cốt lõi. // SelectedSupplierId=null (≥2 → không "1 winner" duy nhất). Trục multi-NCC cốt 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-J1"); var s1 = await SeedSupplierAsync(db, "NCC-J1");
var s2 = await SeedSupplierAsync(db, "NCC-J2"); var s2 = await SeedSupplierAsync(db, "NCC-J2");
var pe = await SeedPeAsync(db, selectedSupplierId: null); var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, 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);
@ -325,8 +377,8 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Select_ThreeOfFour_ThreeIsWinner_NonSelectedIsFalse_SelectedSupplierIdNull() 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, // 4 NCC chào cùng hạng mục, chọn 3 → đúng 3 quote IsSelected=true (3 IsWinner), NCC thứ 4
// SelectedSupplierId=null. Verify "set false phần còn lại" với nhiều row. // quote IsSelected=false (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-Q1"); var s1 = await SeedSupplierAsync(db, "NCC-Q1");
@ -334,13 +386,14 @@ public class PeSelectWinnerClearTests
var s3 = await SeedSupplierAsync(db, "NCC-Q3"); var s3 = await SeedSupplierAsync(db, "NCC-Q3");
var s4 = await SeedSupplierAsync(db, "NCC-Q4"); var s4 = await SeedSupplierAsync(db, "NCC-Q4");
var pe = await SeedPeAsync(db, selectedSupplierId: null); var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
await AddParticipatingAsync(db, pe.Id, s3.Id, order: 2); await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
await AddParticipatingAsync(db, pe.Id, s4.Id, order: 3); await AddParticipatingWithQuoteAsync(db, pe.Id, s3.Id, detailId, isSelected: false, order: 2);
await AddParticipatingWithQuoteAsync(db, pe.Id, s4.Id, detailId, isSelected: false, order: 3);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id, s3.Id }); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id, s3.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);
@ -354,16 +407,17 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Select_DuplicateIds_Deduped_TreatedAsSingleWinner_SelectedSupplierIdSet() public async Task Select_DuplicateIds_Deduped_TreatedAsSingleWinner_SelectedSupplierIdSet()
{ {
// FE gửi trùng id [s1, s1] → Distinct → count==1 → đối xử single-winner: // FE gửi trùng id [s1, s1] → Distinct → count==1 → single-winner: s1.IsWinner=true +
// s1.IsWinner=true + SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng). // SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng).
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-DUP"); var s1 = await SeedSupplierAsync(db, "NCC-DUP");
var pe = await SeedPeAsync(db, selectedSupplierId: null); var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id); var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
var handler = BuildHandler(db); var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s1.Id }); var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, 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);
@ -374,25 +428,25 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Select_MultiThenClear_RoundTrip_AllWinnersReset() public async Task Select_MultiThenClear_RoundTrip_AllWinnersReset()
{ {
// Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false + // Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false + SelectedSupplierId=null.
// SelectedSupplierId=null. Chứng minh clear reset đúng kể cả từ trạng thái multi.
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-R1"); var s1 = await SeedSupplierAsync(db, "NCC-R1");
var s2 = await SeedSupplierAsync(db, "NCC-R2"); var s2 = await SeedSupplierAsync(db, "NCC-R2");
var pe = await SeedPeAsync(db, selectedSupplierId: null); var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
var handler = BuildHandler(db); var handler = BuildHandler(db);
await handler.Handle( await handler.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }), new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id }),
CancellationToken.None); CancellationToken.None);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("sau chọn 2"); (await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("sau chọn 2");
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue(); (await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
await handler.Handle( await handler.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>()), new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>()),
CancellationToken.None); 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);
@ -404,43 +458,42 @@ public class PeSelectWinnerClearTests
[Fact] [Fact]
public async Task Select_MultiWinner_WritesChangelog_WithChonNSummary() 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ố // Multi-winner ghi Changelog Summary "Chọn {n} NCC/TP trúng thầu cho hạng mục {GroupCode}".
// đơn vị chọn). Verify count chính xác trong message.
using var fix = new SqliteDbFixture(); using var fix = new SqliteDbFixture();
var db = fix.Db; var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-CL1"); var s1 = await SeedSupplierAsync(db, "NCC-CL1");
var s2 = await SeedSupplierAsync(db, "NCC-CL2"); var s2 = await SeedSupplierAsync(db, "NCC-CL2");
var pe = await SeedPeAsync(db, selectedSupplierId: null); var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0); var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1); await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
var handler = BuildHandler(db); var handler = BuildHandler(db);
await handler.Handle( await handler.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }), new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id }),
CancellationToken.None); 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("Chọn 2 đơn vị NCC/TP trúng thầu"); log.Summary.Should().Be($"Chọn 2 NCC/TP trúng thầu cho hạng mục {DetailGroupCode}");
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header); log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
log.Action.Should().Be(ChangelogAction.Update); log.Action.Should().Be(ChangelogAction.Update);
} }
// ============================================================ // ============================================================
// 4. PE existence — chung mọi nhánh (check TRƯỚC khi rẽ nhánh SupplierIds) // 4. Existence guards — PE + Detail (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 Detail + SupplierIds).
// 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(), SupplierIds: Array.Empty<Guid>()); var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), 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>();
@ -454,9 +507,190 @@ 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(), SupplierIds: new[] { s1.Id }); var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), 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>();
} }
[Fact]
public async Task UnknownDetail_NotBelongingToPhieu_ThrowsNotFound()
{
// [multi-NCC] Detail phải thuộc phiếu — DetailId lạ (random Guid) → NotFound("PurchaseEvaluationDetail").
// Chặn cross-phiếu / stale detail. Kiểm SAU PE-existence, TRƯỚC supplier validate.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
var validDetailId = await SeedDetailAsync(db, pe.Id); // phiếu CÓ 1 detail hợp lệ
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, validDetailId);
var handler = BuildHandler(db);
// cmd truyền DetailId LẠ (không thuộc phiếu) → NotFound TRƯỚC supplier validate.
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, Guid.NewGuid(), SupplierIds: new[] { s1.Id });
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*PurchaseEvaluationDetail*");
}
// ============================================================
// 5. [multi-NCC NEW test (d)] DERIVE INVARIANT: Supplier.IsWinner == Any(Quote.IsSelected)
// duy trì qua UpsertQuote + SelectWinner + seed. Nguồn-sự-thật = Quote.IsSelected.
// ============================================================
[Fact]
public async Task DeriveInvariant_IsWinnerEqualsAnySelectedQuote_AfterUpsertQuoteAndSelectWinner()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-INV1");
var s2 = await SeedSupplierAsync(db, "NCC-INV2");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
var detailId = await SeedDetailAsync(db, pe.Id);
var s1Row = await AddParticipatingNoQuoteAsync(db, pe.Id, s1.Id, order: 0);
var s2Row = await AddParticipatingNoQuoteAsync(db, pe.Id, s2.Id, order: 1);
var upsert = BuildUpsertQuoteHandler(db);
var select = BuildHandler(db);
// Step 1 — UpsertQuote (Detail × s1) IsSelected=true → handler set s1.IsWinner=true.
await upsert.Handle(new UpsertPurchaseEvaluationQuoteCommand(
pe.Id, detailId, s1Row, BgVat: 0m, ChuaVat: 0m, ThanhTien: 100m, IsSelected: true, Note: null),
CancellationToken.None);
await AssertWinnerInvariantAsync(db, pe.Id);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("UpsertQuote IsSelected=true → derive IsWinner=true");
// Step 2 — UpsertQuote (Detail × s2) IsSelected=false → s2.IsWinner=false.
await upsert.Handle(new UpsertPurchaseEvaluationQuoteCommand(
pe.Id, detailId, s2Row, BgVat: 0m, ChuaVat: 0m, ThanhTien: 200m, IsSelected: false, Note: null),
CancellationToken.None);
await AssertWinnerInvariantAsync(db, pe.Id);
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse("UpsertQuote IsSelected=false → IsWinner=false");
// Step 3 — SelectWinner [s2] cho hạng mục → s2 quote IsSelected=true, s1 quote IsSelected=false.
await select.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s2.Id }),
CancellationToken.None);
await AssertWinnerInvariantAsync(db, pe.Id);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("SelectWinner đổi sang s2 → s1 quote IsSelected=false → IsWinner=false");
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue("s2 quote IsSelected=true → IsWinner=true");
// Step 4 — SelectWinner [] clear → cả 2 quote IsSelected=false → cả 2 IsWinner=false.
await select.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>()),
CancellationToken.None);
await AssertWinnerInvariantAsync(db, pe.Id);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse();
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
}
// ============================================================
// [S114 Bước 4 verify — MUST/SHOULD] Delete-path re-derive IsWinner (writer bị sót ở implement)
// ============================================================
[Fact]
public async Task DeleteQuote_LastSelectedQuoteOfSupplier_ClearsIsWinner_NoPhantom()
{
// ⭐ BUG (Bước 4): DeleteQuoteHandler Remove(quote) KHÔNG re-derive IsWinner → xóa quote
// IsSelected CUỐI của 1 NCC để IsWinner kẹt=true trong khi Any(IsSelected)=false → phantom
// winner → CreateContract sinh HĐ giaTri=0. Test-before: assert IsWinner về false + invariant.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-DQ");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
var detailId = await SeedDetailAsync(db, pe.Id);
var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
var quoteId = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => q.PurchaseEvaluationSupplierId == rowId).Select(q => q.Id).FirstAsync();
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("pre-state: quote IsSelected=true → IsWinner=true");
var del = new DeletePurchaseEvaluationQuoteCommandHandler(db, new FakeCurrentUser());
await del.Handle(new DeletePurchaseEvaluationQuoteCommand(pe.Id, quoteId), CancellationToken.None);
await AssertWinnerInvariantAsync(db, pe.Id);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("xóa quote-selected cuối → IsWinner về false");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull("0 winner → SelectedSupplierId=null");
}
[Fact]
public async Task DeleteDetail_WithSupplierLastSelectedQuote_ClearsIsWinner()
{
// ⭐ BUG (Bước 4): DeleteDetailHandler Remove(entity) cascade xóa quotes NHƯNG không re-derive
// IsWinner → NCC mất quote-selected cuối bị kẹt IsWinner=true. Test-before.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-DD");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("pre-state IsWinner=true");
var del = new DeletePurchaseEvaluationDetailCommandHandler(db, new FakeCurrentUser());
await del.Handle(new DeletePurchaseEvaluationDetailCommand(pe.Id, detailId), CancellationToken.None);
await AssertWinnerInvariantAsync(db, pe.Id);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("xóa hạng mục chứa quote-selected cuối → IsWinner=false");
}
[Fact]
public async Task DeleteDetail_SupplierStillWinsAnotherDetail_KeepsIsWinner()
{
// Guard: re-derive per-PHIẾU (không over-clear). Xóa 1 hạng mục nhưng NCC còn thắng hạng
// mục KHÁC → IsWinner GIỮ true.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-2D");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
var detailA = await SeedDetailAsync(db, pe.Id, "A.I");
var detailB = await SeedDetailAsync(db, pe.Id, "B.I");
var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailA, isSelected: true);
var up = BuildUpsertQuoteHandler(db);
await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailB, rowId, 0, 0, 50m, true, null), CancellationToken.None);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
var del = new DeletePurchaseEvaluationDetailCommandHandler(db, new FakeCurrentUser());
await del.Handle(new DeletePurchaseEvaluationDetailCommand(pe.Id, detailA), CancellationToken.None);
await AssertWinnerInvariantAsync(db, pe.Id);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("vẫn thắng detailB → IsWinner giữ true");
}
[Fact]
public async Task UpsertQuote_UpdateBranch_TogglesIsWinnerCorrectly()
{
// [Bước 4 SHOULD] Coverage-gap: UPDATE-path (quote đã tồn tại) re-derive dùng loại-trừ
// q.Id != existing.Id (né stale-DB-read). Test-before đã có, giữ để cover UPDATE branch.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-UP");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
var detailId = await SeedDetailAsync(db, pe.Id);
var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("quote tồn tại nhưng chưa selected");
var up = BuildUpsertQuoteHandler(db);
await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailId, rowId, 0, 0, 100m, true, null), CancellationToken.None);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("UPDATE IsSelected=true → IsWinner=true");
await AssertWinnerInvariantAsync(db, pe.Id);
await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailId, rowId, 0, 0, 100m, false, null), CancellationToken.None);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("UPDATE IsSelected=false (loại chính nó) → IsWinner=false");
await AssertWinnerInvariantAsync(db, pe.Id);
}
// Invariant checker: MỌI supplier-row → IsWinner == (tồn tại ÍT NHẤT 1 quote IsSelected của row).
private static async Task AssertWinnerInvariantAsync(TestApplicationDbContext db, Guid peId)
{
var rows = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == peId).ToListAsync();
foreach (var r in rows)
{
var anySelected = await db.PurchaseEvaluationQuotes.AsNoTracking()
.AnyAsync(q => q.PurchaseEvaluationSupplierId == r.Id && q.IsSelected);
r.IsWinner.Should().Be(anySelected,
$"invariant Supplier.IsWinner == Any(Quote.IsSelected) cho row SupplierId={r.SupplierId}");
}
}
} }

View File

@ -593,10 +593,11 @@ public class PeWorkItemBudgetTests
// ===================================================================== // =====================================================================
// Seed 1 NCC thắng + 1 detail + 1 quote ThanhTien cho phiếu (winner tổng thể). // Seed 1 NCC thắng + 1 detail + 1 quote ThanhTien cho phiếu (winner tổng thể).
// [Mig 58 multi-winner] Tiêu chí "đã chọn thầu" nay = IsWinner trên supplier-row // [multi-NCC 2026-07-12 cardinality-change] budgetSummary PreviousSelectedTotal +
// (KHÔNG còn SelectedSupplierId). Helper này set CẢ HAI để mô phỏng single-winner đúng // CurrentProposalTotal NAY = SUM ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected —
// nghiệp vụ (đúng 1 winner ⇒ SelectedSupplierId = winner đó được sync). Code GetDetail // PurchaseEvaluationFeatures.cs:911-926 `where q.IsSelected`), KHÔNG còn build từ IsWinner.
// chỉ đọc IsWinner cho prevSelected*; SelectedSupplierId giữ để các path khác (banner FE). // PreviousSelectedCount vẫn dùng IsWinner (existence). Helper set CẢ quote.IsSelected=true +
// pes.IsWinner=true (invariant) → tổng tiền lọt đúng + count đúng. SelectedSupplierId sync caller.
private static async Task SeedWinnerWithQuoteAsync( private static async Task SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien) TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
{ {
@ -606,7 +607,7 @@ public class PeWorkItemBudgetTests
PurchaseEvaluationId = pe.Id, PurchaseEvaluationId = pe.Id,
SupplierId = supplierId, SupplierId = supplierId,
Order = 0, Order = 0,
IsWinner = true, // [Mig 58] nguồn-sự-thật mới cho "đơn vị trúng" IsWinner = true, // existence (PreviousSelectedCount) — derived == Any(IsSelected)
}; };
var detail = new PurchaseEvaluationDetail var detail = new PurchaseEvaluationDetail
{ {
@ -625,6 +626,7 @@ public class PeWorkItemBudgetTests
PurchaseEvaluationDetailId = detail.Id, PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id, PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien, ThanhTien = thanhTien,
IsSelected = true, // [multi-NCC] báo giá được chọn → vào PrevSelectedTotal/CurrentProposalTotal
}); });
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
} }
@ -665,6 +667,7 @@ public class PeWorkItemBudgetTests
PurchaseEvaluationDetailId = detail.Id, PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id, PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien, ThanhTien = thanhTien,
IsSelected = true, // [multi-NCC] mỗi winner có báo giá được chọn → vào SUM IsSelected
}); });
} }
// pe.SelectedSupplierId GIỮ null (đã null từ SeedPeAsync) — đặc trưng ≥2 winner. // pe.SelectedSupplierId GIỮ null (đã null từ SeedPeAsync) — đặc trưng ≥2 winner.

View File

@ -80,6 +80,11 @@ public class PeCcmThresholdFinalizeTests
// Seed 1 NCC tham gia (winner) + 1 detail + 1 quote ThanhTien=amount → winner // Seed 1 NCC tham gia (winner) + 1 detail + 1 quote ThanhTien=amount → winner
// quote sum = amount. Return supplierId (master ref) để pin SelectedSupplierId. // quote sum = amount. Return supplierId (master ref) để pin SelectedSupplierId.
// [multi-NCC 2026-07-12 cardinality-change] ccm-delegate winnerQuoteTotal NAY = SUM
// ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected — service :884-889 `where q.IsSelected`),
// KHÔNG còn theo IsWinner supplier-row. Quote PHẢI IsSelected=true thì mới vào tổng
// → boundary test (b)/(b2) (gói >= ngưỡng → Conflict) mới đúng; nếu IsSelected=false thì
// tổng=0 luôn < ngưỡng → mất case Conflict. Set CẢ IsSelected + IsWinner (giữ invariant).
private static async Task<Guid> SeedWinnerWithQuoteAsync( private static async Task<Guid> SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien) TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
{ {
@ -89,7 +94,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 IsWinner = true, // derived == Any(quote.IsSelected)
}; };
var detail = new PurchaseEvaluationDetail var detail = new PurchaseEvaluationDetail
{ {
@ -106,6 +111,47 @@ public class PeCcmThresholdFinalizeTests
PurchaseEvaluationDetailId = detail.Id, PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id, PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = quoteThanhTien, ThanhTien = quoteThanhTien,
IsSelected = true, // [multi-NCC] báo giá được chọn → vào winnerQuoteTotal
});
await db.SaveChangesAsync(CancellationToken.None);
return supplierId;
}
// [multi-NCC 2026-07-12 — new test (c) threshold-crossing] Seed 1 winner-row với 2 quote:
// 1 ĐƯỢC CHỌN (selectedThanhTien) + 1 KHÔNG chọn (unselectedThanhTien). Tổng-theo-IsSelected
// = selectedThanhTien; tổng-theo-CŨ (whole-supplier IsWinner, gồm cả quote không chọn) =
// selected + unselected. Return supplierId.
private static async Task<Guid> SeedWinnerWithSelectedAndUnselectedQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe,
decimal selectedThanhTien, decimal unselectedThanhTien)
{
var supplierId = Guid.NewGuid();
var pes = new PurchaseEvaluationSupplier
{
PurchaseEvaluationId = pe.Id, SupplierId = supplierId, Order = 0, IsWinner = true,
};
var detailSel = new PurchaseEvaluationDetail
{
PurchaseEvaluationId = pe.Id, GroupCode = "A.I", GroupName = "Bê tông",
NoiDung = "Hạng mục ĐƯỢC chọn", Order = 0,
};
var detailUnsel = new PurchaseEvaluationDetail
{
PurchaseEvaluationId = pe.Id, GroupCode = "A.II", GroupName = "Thép",
NoiDung = "Hạng mục KHÔNG chọn", Order = 1,
};
db.PurchaseEvaluationSuppliers.Add(pes);
db.PurchaseEvaluationDetails.Add(detailSel);
db.PurchaseEvaluationDetails.Add(detailUnsel);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
PurchaseEvaluationDetailId = detailSel.Id, PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = selectedThanhTien, IsSelected = true,
});
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
PurchaseEvaluationDetailId = detailUnsel.Id, PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = unselectedThanhTien, IsSelected = false,
}); });
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
return supplierId; return supplierId;
@ -611,4 +657,50 @@ public class PeCcmThresholdFinalizeTests
.WithMessage("*giá chốt*"); .WithMessage("*giá chốt*");
} }
} }
// =====================================================================
// (c) [multi-NCC 2026-07-12 cardinality-change — NEW] THRESHOLD-CROSSING:
// Winner có 1 quote ĐƯỢC CHỌN 500tr + 1 quote KHÔNG chọn 700tr.
// • Tổng-theo-CŨ (whole-supplier IsWinner, gồm cả quote không chọn) = 1.2 tỷ ≥ ngưỡng
// 1 tỷ → nếu code còn dùng logic cũ = Conflict (SAI).
// • Tổng-theo-MỚI (Quote.IsSelected only) = 500tr < 1 tỷ → CCM duyệt done miễn CEO
// HỢP LỆ → finalize DaDuyet, KHÔNG throw.
// Chống regression: chứng minh winnerQuoteTotal đọc IsSelected-subset, KHÔNG whole-supplier.
// =====================================================================
[Fact]
public async Task ApproveV2_CcmDelegation_WholeSupplierSumAboveButSelectedSubsetBelow_Finalizes_NoThrow()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccmXc@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceoXc@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm }, // Bước 1 = CCM (đứng đây)
new[] { ceo }, // Bước 2 = CEO (sẽ bị bỏ qua)
}, ceoThreshold: CeoThreshold); // 1 tỷ
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-XC");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// Selected 500tr + Unselected 700tr → whole-supplier=1.2 tỷ ≥ ngưỡng, IsSelected=500tr < ngưỡng.
var supplierId = await SeedWinnerWithSelectedAndUnselectedQuoteAsync(
db, pe, selectedThanhTien: 500_000_000m, unselectedThanhTien: 700_000_000m);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
// finalize theo IsSelected-subset (500tr < 1 tỷ) → KHÔNG throw, DaDuyet.
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
await act.Should().NotThrowAsync(
"winnerQuoteTotal đọc SUM Quote.IsSelected (500tr < ngưỡng); logic cũ whole-supplier 1.2 tỷ đã bị thay");
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "CCM duyệt done miễn CEO — gói ĐƯỢC CHỌN < ngưỡng");
pe.CurrentWorkflowStepIndex.Should().BeNull();
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
}
}
} }

View File

@ -96,9 +96,13 @@ 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 + // [multi-NCC 2026-07-12 cardinality-change] Submit guard NAY đọc SUM theo
// ccm-delegate + read DTO SUM theo IsWinner, KHÔNG còn theo SelectedSupplierId). Set // Quote.IsSelected (nguồn-sự-thật per Detail×Supplier — PurchaseEvaluationWorkflowService
// IsWinner=true + sync SelectedSupplierId trong caller để mirror handler select-winner. // :196-201 `where q.IsSelected`), KHÔNG còn IsWinner/SelectedSupplierId. Winner quote PHẢI
// IsSelected=true thì mới lọt selectedThanhTien (nếu không → "chưa chọn Đơn vị NCC/TP" bắn
// sai). Set CẢ quote.IsSelected=true + pes.IsWinner=true (invariant IsWinner==Any(IsSelected))
// để mirror handler select-winner. Test (3) ThanhTien=0 vẫn đúng: IsSelected=true → lọt
// list nhưng Sum≤0 → "chưa có giá chào thầu".
private static async Task<Guid> SeedWinnerWithQuoteAsync( private static async Task<Guid> SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien) TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
{ {
@ -108,7 +112,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 IsWinner = true, // derived == Any(quote.IsSelected) — giữ đúng invariant
}; };
var detail = new PurchaseEvaluationDetail var detail = new PurchaseEvaluationDetail
{ {
@ -125,6 +129,7 @@ public class PeSubmitGuardAndBypassTests
PurchaseEvaluationDetailId = detail.Id, PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id, PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = quoteThanhTien, ThanhTien = quoteThanhTien,
IsSelected = true, // [multi-NCC] báo giá ĐƯỢC CHỌN → lọt SUM submit-guard/DTO/threshold
}); });
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
return supplierId; return supplierId;