[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] =====
// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng
// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner).
// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`;
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner).
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
// gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker.
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
const setWinners = useMutation({
mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
onSuccess: () => {
toast.success('Đã cập nhật đơn vị trúng thầu.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
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>}
/>
)
}
// ===== a. NCC / TP được chọn — TÓM TẮT read-only [multi-NCC per hạng mục] =====
// Winner giờ CHỌN THEO TỪNG HẠNG MỤC ở bảng NCC (Mục 2) — nguồn sự thật = quote.isSelected
// 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
// đơn vị trúng + các hạng mục nó thắng (derive ev.details[].quotes IsSelected). Tổng =
// ev.winnerQuoteTotal (BE = SUM mọi quote IsSelected).
function NccSelectorRow({ ev }: { ev: PeDetailBundle }) {
const winners = ev.suppliers.filter(s => s.isWinner)
// Các hạng mục mà supplier-row `rowId` thắng (có ≥1 quote IsSelected trong hạng mục đó).
const wonItems = (rowId: string) =>
ev.details
.filter(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === rowId && q.isSelected))
.map(d => d.groupCode || d.noiDung)
return (
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span>
<div className="relative min-w-0 flex-1">
{ev.suppliers.length === 0 ? (
<p className="text-[11px] text-amber-600">
Thêm NCC Section 3 trước rồi mới chọn trúng thầu.
</p>
) : (
<div className="space-y-1">
{ev.suppliers.map(s => (
<label
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',
<FormRow
label="a. NCC / TP được chọn"
value={winners.length > 0 ? (
<div className="space-y-1">
{winners.map(s => {
const items = wonItems(s.id)
return (
<div key={s.id} className="text-[12px] leading-relaxed">
<span className="font-medium text-emerald-700"> {s.supplierName}</span>
{items.length > 0 && (
<span className="text-slate-500"> thắng hạng mục: {items.join(', ')}</span>
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5"
checked={s.isWinner}
disabled={busy}
onChange={e => toggle(s.supplierId, e.target.checked)}
/>
<span className="flex-1">
{s.supplierName}{s.displayName ? `${s.displayName}` : ''}
</span>
<span className="text-[11px] text-emerald-600">{s.isWinner ? 'Trúng thầu' : ''}</span>
</label>
))}
</div>
)}
{/* 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>
</div>
)
})}
<div className="mt-1 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 ({winners.length} đơn vị):</span>
<span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
</div>
)}
{busy && (
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
<span>Đang cập nhật trúng thầu + sync cột giá Section 4</span>
<div className="text-[11px] text-slate-400">
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>
)}
</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 (
<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
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
@ -2566,19 +2517,26 @@ function HangMucCard({
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
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
// danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render.
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
const setWinners = useMutation({
// [multi-NCC per hạng mục] Winner CHỌN THEO HẠNG MỤC NÀY: nguồn sự thật = quote.isSelected
// của detail này. BE select-winner nhận MASTER supplierId (map qua PeSupplier.supplierId) +
// detailId → set IsSelected cho quotes hạng mục này, derive Supplier.IsWinner toàn phiếu.
// 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[]) =>
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] }) },
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { detailId: detail.id, supplierIds }),
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)),
})
const toggleWinner = (supplierId: string, on: boolean) =>
setWinners.mutate(on
? [...winnerIds, supplierId]
: winnerIds.filter(id => id !== supplierId))
const toggleDetailWinner = (supplierId: string, on: boolean) =>
setDetailWinners.mutate(on
? [...detailWinnerSupplierIds, supplierId]
: detailWinnerSupplierIds.filter(id => id !== supplierId))
return (
<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">
{ev.suppliers.map((s, idx) => {
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 hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
@ -2684,15 +2645,15 @@ function HangMucCard({
key={s.id}
className={cn(
'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'
: cn(palette, 'hover:bg-white/80 hover:shadow-sm'),
)}
>
<td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900">
{isWinner && <span className="mr-1 text-base font-bold text-emerald-700"></span>}
<span className={cn(isWinner && 'text-emerald-900')}>{s.supplierName}</span>
{cellSelected && <span className="mr-1 text-base font-bold text-emerald-700"></span>}
<span className={cn(cellSelected && 'text-emerald-900')}>{s.supplierName}</span>
</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>}
@ -2724,7 +2685,7 @@ function HangMucCard({
className={cn(
'w-full rounded border px-2 py-1 text-right font-mono text-[11px] transition',
q
? isWinner
? cellSelected
? '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-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'}
</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>}
</div>
)}
@ -2743,13 +2704,13 @@ function HangMucCard({
<td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5">
<button
onClick={() => toggleWinner(s.supplierId, !isWinner)}
disabled={setWinners.isPending}
onClick={() => toggleDetailWinner(s.supplierId, !cellSelected)}
disabled={setDetailWinners.isPending}
className={cn(
'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" />
</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] =====
// Mỗi đơn vị tham gia có 1 checkbox "Chọn trúng thầu" (chọn ≥2 = liên danh). Trạng
// thái tích = supplier.isWinner (KHÔNG dùng selectedSupplierId — null khi ≥2 winner).
// Toggle → POST select-winner với TOÀN BỘ tập ID đang tích `{ supplierIds: [...] }`;
// bỏ tích hết = []. Tổng chi phí = ev.winnerQuoteTotal (BE = SUM mọi isWinner).
function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
// gotcha #70 — chặn echo-cũ: trong lúc refetch pe-detail, bỏ qua optimistic flicker.
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
const setWinners = useMutation({
mutationFn: async (supplierIds: string[]) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierIds }),
onSuccess: () => {
toast.success('Đã cập nhật đơn vị trúng thầu.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
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>}
/>
)
}
// ===== a. NCC / TP được chọn — TÓM TẮT read-only [multi-NCC per hạng mục] =====
// Winner giờ CHỌN THEO TỪNG HẠNG MỤC ở bảng NCC (Mục 2) — nguồn sự thật = quote.isSelected
// 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
// đơn vị trúng + các hạng mục nó thắng (derive ev.details[].quotes IsSelected). Tổng =
// ev.winnerQuoteTotal (BE = SUM mọi quote IsSelected).
function NccSelectorRow({ ev }: { ev: PeDetailBundle }) {
const winners = ev.suppliers.filter(s => s.isWinner)
// Các hạng mục mà supplier-row `rowId` thắng (có ≥1 quote IsSelected trong hạng mục đó).
const wonItems = (rowId: string) =>
ev.details
.filter(d => d.quotes.some(q => q.purchaseEvaluationSupplierId === rowId && q.isSelected))
.map(d => d.groupCode || d.noiDung)
return (
<div className="flex items-baseline gap-3 border-b border-dotted border-slate-200 pb-1.5">
<span className="w-44 shrink-0 text-[12px] text-slate-500">a. NCC / TP đưc chọn</span>
<div className="relative min-w-0 flex-1">
{ev.suppliers.length === 0 ? (
<p className="text-[11px] text-amber-600">
Thêm NCC Section 3 trước rồi mới chọn trúng thầu.
</p>
) : (
<div className="space-y-1">
{ev.suppliers.map(s => (
<label
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',
<FormRow
label="a. NCC / TP được chọn"
value={winners.length > 0 ? (
<div className="space-y-1">
{winners.map(s => {
const items = wonItems(s.id)
return (
<div key={s.id} className="text-[12px] leading-relaxed">
<span className="font-medium text-emerald-700"> {s.supplierName}</span>
{items.length > 0 && (
<span className="text-slate-500"> thắng hạng mục: {items.join(', ')}</span>
)}
>
<input
type="checkbox"
className="h-3.5 w-3.5"
checked={s.isWinner}
disabled={busy}
onChange={e => toggle(s.supplierId, e.target.checked)}
/>
<span className="flex-1">
{s.supplierName}{s.displayName ? `${s.displayName}` : ''}
</span>
<span className="text-[11px] text-emerald-600">{s.isWinner ? 'Trúng thầu' : ''}</span>
</label>
))}
</div>
)}
{/* 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>
</div>
)
})}
<div className="mt-1 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 ({winners.length} đơn vị):</span>
<span className="font-mono font-semibold text-emerald-800">{fmtMoney(ev.winnerQuoteTotal)} đ</span>
</div>
)}
{busy && (
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-brand-600">
<div className="h-3 w-3 animate-spin rounded-full border-2 border-brand-300 border-t-brand-600" />
<span>Đang cập nhật trúng thầu + sync cột giá Section 4</span>
<div className="text-[11px] text-slate-400">
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>
)}
</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 (
<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
+ BudgetAdjustSection cũ (module Budget bỏ hẳn). */}
<PeBudgetSummaryTable ev={ev} readOnly={readOnly} />
@ -2566,19 +2517,26 @@ function HangMucCard({
onSuccess: () => { toast.success('Đã xóa NCC.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
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
// danh). Trạng thái nguồn = s.isWinner. winnerIds dựng lại từ ev.suppliers mỗi render.
const winnerIds = ev.suppliers.filter(s => s.isWinner).map(s => s.supplierId)
const setWinners = useMutation({
// [multi-NCC per hạng mục] Winner CHỌN THEO HẠNG MỤC NÀY: nguồn sự thật = quote.isSelected
// của detail này. BE select-winner nhận MASTER supplierId (map qua PeSupplier.supplierId) +
// detailId → set IsSelected cho quotes hạng mục này, derive Supplier.IsWinner toàn phiếu.
// 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[]) =>
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] }) },
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { detailId: detail.id, supplierIds }),
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)),
})
const toggleWinner = (supplierId: string, on: boolean) =>
setWinners.mutate(on
? [...winnerIds, supplierId]
: winnerIds.filter(id => id !== supplierId))
const toggleDetailWinner = (supplierId: string, on: boolean) =>
setDetailWinners.mutate(on
? [...detailWinnerSupplierIds, supplierId]
: detailWinnerSupplierIds.filter(id => id !== supplierId))
return (
<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">
{ev.suppliers.map((s, idx) => {
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 hasQuotes = ev.details.some(dd => dd.quotes.some(qq => qq.purchaseEvaluationSupplierId === s.id))
const canDelete = !isWinner && !hasQuotes
@ -2684,15 +2645,15 @@ function HangMucCard({
key={s.id}
className={cn(
'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'
: cn(palette, 'hover:bg-white/80 hover:shadow-sm'),
)}
>
<td className="border-r border-slate-200 px-2 py-1.5">
<div className="font-medium text-slate-900">
{isWinner && <span className="mr-1 text-base font-bold text-emerald-700"></span>}
<span className={cn(isWinner && 'text-emerald-900')}>{s.supplierName}</span>
{cellSelected && <span className="mr-1 text-base font-bold text-emerald-700"></span>}
<span className={cn(cellSelected && 'text-emerald-900')}>{s.supplierName}</span>
</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>}
@ -2724,7 +2685,7 @@ function HangMucCard({
className={cn(
'w-full rounded border px-2 py-1 text-right font-mono text-[11px] transition',
q
? isWinner
? cellSelected
? '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-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'}
</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>}
</div>
)}
@ -2743,13 +2704,13 @@ function HangMucCard({
<td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5">
<button
onClick={() => toggleWinner(s.supplierId, !isWinner)}
disabled={setWinners.isPending}
onClick={() => toggleDetailWinner(s.supplierId, !cellSelected)}
disabled={setDetailWinners.isPending}
className={cn(
'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" />
</button>

View File

@ -179,7 +179,8 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
[HttpPost("{id:guid}/select-winner")]
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();
}
@ -359,7 +360,7 @@ public record AddSupplierBody(
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
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(
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,

View File

@ -11,8 +11,8 @@ using SolutionErp.Domain.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:
// SupplierId = PE.SelectedSupplierId, ProjectId, DepartmentId, TenHopDong,
// GiaTri (sum ThanhTienNganSach của details). ContractType do user chọn
// SupplierId = mỗi NCC winner (DERIVED IsWinner == Any IsSelected), ProjectId, DepartmentId,
// 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).
//
// 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)
.FirstOrDefaultAsync(ct);
// GiaTri: 1 winner → tổng ngân sách details (HÀNH VI CŨ giữ nguyên); ≥2 liên-danh →
// mỗi HĐ = tổng báo giá của NCC đó (giá HĐ = giá NCC chào; HĐ draft sửa được sau —
// anh Kiệt UAT con số này). 0 nếu NCC chưa báo giá.
var detailsSum = pe.Details.Sum(d => d.ThanhTienNganSach);
var isSingle = winners.Count == 1;
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED A1/D4] GiaTri mỗi HĐ = SUM báo giá ĐƯỢC CHỌN
// (Quote.IsSelected) của NCC đó — CẢ single lẫn multi. BỎ shortcut detailsSum cũ (single
// trước đây = tổng ngân sách; NAY = giá thầu chào, đúng D4 "1 HĐ/NCC = SUM IsSelected
// quotes của NCC"). 0 nếu NCC chưa có báo giá được chọn.
// [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
@ -86,11 +85,9 @@ public class CreateContractFromEvaluationCommandHandler(
if (!supplierMap.TryGetValue(w.SupplierId, out var supplier))
throw new NotFoundException("Supplier", w.SupplierId);
var giaTri = isSingle
? detailsSum
: pe.Details.SelectMany(d => d.Quotes)
.Where(q => q.PurchaseEvaluationSupplierId == w.Id)
.Sum(q => q.ThanhTien);
var giaTri = pe.Details.SelectMany(d => d.Quotes)
.Where(q => q.PurchaseEvaluationSupplierId == w.Id && q.IsSelected)
.Sum(q => q.ThanhTien);
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.
bool IsUrgentByPro,
bool IsUrgentByCcm,
// [S69 · Mig 58 multi-winner] Tổng giá chào của các đơn vị NCC/TP ĐƯỢC CHỌN
// (winner quote total) — SUM ThanhTien báo giá của MỌI đơn vị IsWinner (multi-winner
// Mig 58), KHÔNG còn chỉ SelectedSupplierId. 0 khi chưa chọn. Mirror predicate
// submit-guard (PurchaseEvaluationWorkflowService ~:188). FE so với
// CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO".
// [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
// total) — SUM ThanhTien các quote IsSelected (NGUỒN-SỰ-THẬT per hạng mục × NCC; Supplier.IsWinner
// = DERIVED == Any IsSelected). 0 khi chưa chọn. Mirror predicate submit-guard
// (PurchaseEvaluationWorkflowService ~:196). FE so với CeoApprovalThreshold hiển thị
// "CCM duyệt-final" hoặc "cần CEO".
decimal WinnerQuoteTotal,
// [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.
@ -325,8 +325,8 @@ public record PurchaseEvaluationDetailBundleDto(
// 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:
// PreviousSubmittedTotal = SUM(BudgetPeriodAmount) WHERE Phase IN (ChoDuyet, DaDuyet)
// PreviousSelectedTotal = SUM(quote ThanhTien của SelectedSupplier) WHERE Phase = DaDuyet
// CurrentProposalTotal = SUM(quote ThanhTien của SelectedSupplier) phiếu NÀY (0 khi chưa chọn).
// PreviousSelectedTotal = SUM(quote ThanhTien WHERE IsSelected) WHERE Phase = DaDuyet
// CurrentProposalTotal = SUM(quote ThanhTien WHERE IsSelected) phiếu NÀY (0 khi chưa chọn).
public record PeBudgetSummaryDto(
Guid? BudgetId,
decimal? ProEstimateAmount,

View File

@ -261,6 +261,9 @@ public class DeletePurchaseEvaluationDetailCommandHandler(
db.PurchaseEvaluationDetails.Remove(entity);
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.IsSelected = request.IsSelected;
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
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
@ -333,6 +343,12 @@ public class UpsertPurchaseEvaluationQuoteCommandHandler(
Note = request.Note,
};
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
{
PurchaseEvaluationId = request.PurchaseEvaluationId,
@ -382,20 +398,65 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
db.PurchaseEvaluationQuotes.Remove(quote);
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
// trúng (whole-supplier multi-select). SupplierIds = tập đơn vị thắng:
// - List rỗng [] = BỎ CHỌN tất cả (un-select về none) — thay null cũ (anh Kiệt C5).
// - 1 phần tử = 1 winner (SelectedSupplierId sync = đơn vị đó — backward compat).
// - ≥2 phần tử = joint winner (SelectedSupplierId = null vì không còn "1 winner").
// IsWinner trên supplier-row là nguồn-sự-thật mới; winnerQuoteTotal = SUM ThanhTien
// MỌI winner-row. Mỗi id phải nằm trong danh sách NCC tham gia của phiếu.
// Supplier-row có ÍT NHẤT 1 quote IsSelected (bất kỳ hạng mục nào) = winner.
var selectedRowIds = (await (
from q in db.PurchaseEvaluationQuotes.AsNoTracking()
join d in db.PurchaseEvaluationDetails.AsNoTracking()
on q.PurchaseEvaluationDetailId equals d.Id
where d.PurchaseEvaluationId == peId && q.IsSelected
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(
Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds) : IRequest;
Guid PurchaseEvaluationId, Guid DetailId, IReadOnlyList<Guid> SupplierIds) : IRequest;
public class SelectPurchaseEvaluationWinnerCommandHandler(
IApplicationDbContext db,
@ -406,29 +467,48 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
// Load mọi supplier-row của phiếu (tracking — để set IsWinner).
var rows = await db.PurchaseEvaluationSuppliers
// Hạng mục phải thuộc phiếu (chặn cross-phiếu / stale).
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)
.ToListAsync(ct);
// Distinct các id được chọn (FE có thể gửi trùng).
var selectedIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>();
// Mỗi id chọn phải là 1 đơn vị tham gia phiếu (chặn forge/stale).
var participatingSupplierIds = rows.Select(r => r.SupplierId).ToHashSet();
foreach (var sid in selectedIds)
// Distinct id chọn (FE có thể gửi trùng) + map SupplierId(master) → supplier-row Id.
var selectedSupplierIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>();
var rowIdBySupplierId = supplierRows.ToDictionary(r => r.SupplierId, r => r.Id);
foreach (var sid in selectedSupplierIds)
{
if (!participatingSupplierIds.Contains(sid))
if (!rowIdBySupplierId.ContainsKey(sid))
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.
foreach (var r in rows)
r.IsWinner = selectedIds.Contains(r.SupplierId);
// Load MỌI quote của phiếu (JOIN qua Detail) — tracking. Cần cả phiếu để derive IsWinner
// đú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).
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
// tạo HĐ + DTO cũ); ≥2 hoặc 0 → null.
entity.SelectedSupplierId = selectedIds.Count == 1 ? selectedIds.First() : null;
// Set IsSelected cho quotes CỦA HẠNG MỤC NÀY: true nếu supplier-row thắng, false còn lại.
foreach (var q in allQuotes.Where(q => q.PurchaseEvaluationDetailId == request.DetailId))
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
{
@ -438,9 +518,9 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = selectedIds.Count == 0
? "Bỏ chọn đơn vị NCC/TP trúng thầu"
: $"Chọn {selectedIds.Count} đơn vị NCC/TP trúng thầu",
Summary = selectedSupplierIds.Count == 0
? $"Bỏ chọn NCC/TP trúng thầu cho hạng mục {detail.GroupCode}"
: $"Chọn {selectedSupplierIds.Count} NCC/TP trúng thầu cho hạng mục {detail.GroupCode}",
});
await db.SaveChangesAsync(ct);

View File

@ -901,29 +901,28 @@ public class GetPurchaseEvaluationQueryHandler(
var prevSubmittedCount = submitted.Count;
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ị
// IsWinner (thay `SelectedSupplierId != null` cũ — null khi ≥2 đồng-trúng nên
// bỏ sót phiếu liên-danh). Tổng = mọi báo giá của các đơn vị IsWinner.
// "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị IsWinner (existence — IsWinner
// là DERIVED == Any quote IsSelected, giữ đúng ngữ nghĩa "có chọn thầu"; A3(ii)).
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
&& p.Suppliers.Any(s => s.IsWinner));
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 (
from p in selectedPeers
join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
on p.Id equals s.PurchaseEvaluationId
where s.IsWinner
join q in db.PurchaseEvaluationQuotes.AsNoTracking()
on s.Id equals q.PurchaseEvaluationSupplierId
where q.IsSelected
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
// [Mig 58 multi-winner] (khớp winnerQuoteTotal :1087 — dùng IsWinner thay
// SelectedSupplierId vì null khi ≥2 đồng-trúng → trước đây báo 0 cho liên-danh).
var curWinnerRowIds = e.Suppliers.Where(s => s.IsWinner)
.Select(s => s.Id).ToList();
var currentProposalTotal = curWinnerRowIds.Count == 0 ? 0m
// Row 4 "Giá trị kỳ này" = SUM báo giá ĐƯỢC CHỌN (Quote.IsSelected) phiếu này
// [multi-NCC A3(i)[5]] (khớp winnerQuoteTotal :1189 — nguồn-sự-thật per Detail×Supplier).
var curSupplierRowIds = e.Suppliers.Select(s => s.Id).ToList();
var currentProposalTotal = curSupplierRowIds.Count == 0 ? 0m
: 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;
// [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).
// Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi
// chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO".
var winnerSupplierRowIds = e.Suppliers
.Where(s => s.IsWinner) // [Mig 58] multi-winner: tổng MỌI đơn vị IsWinner
.Select(s => s.Id)
.ToHashSet();
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED D1] Giá trị gói = SUM ThanhTien báo giá ĐƯỢC
// CHỌN per hạng mục (Quote.IsSelected = nguồn-sự-thật). Tính từ data đã load (Details.Quotes)
// — KHÔNG query thêm. 0 khi chưa chọn NCC nào. FE so CeoApprovalThreshold → "CCM duyệt-final".
var winnerQuoteTotal = e.Details
.SelectMany(det => det.Quotes)
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
.Where(q => q.IsSelected)
.Sum(q => q.ThanhTien);
// [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();
// [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
PurchaseEvaluationPhase[] flow = type == PurchaseEvaluationType.DuyetNcc
? [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,
// tránh mismatch 2 tầng).
var missing = new List<string>();
// [Mig 58 CCM item 1] Multi-winner: winner = các supplier-row IsWinner (≥1). Tổng giá
// gói = SUM ThanhTien MỌI winner-row (vd 3 đơn vị 37.8+9+10=56.8tr). 0 winner →
// "chưa chọn"; có winner nhưng tổng ≤ 0 → "chưa có giá chào thầu" (item 2 chỉ nới
// NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá).
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner)
.Select(s => s.Id)
.ToListAsync(ct);
if (winnerRowIds.Count == 0)
// [multi-NCC A3(i)[2] + D5] Winner = có ÍT NHẤT 1 báo giá ĐƯỢC CHỌN (Quote.IsSelected
// = nguồn-sự-thật per Detail×Supplier). Tổng giá gói = SUM ThanhTien mọi quote IsSelected
// toàn phiếu (D5 giữ >0). 0 selected → "chưa chọn"; có selected nhưng tổng ≤0 → "chưa có giá"
// (item 2 chỉ nới NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá).
var selectedThanhTien = await (
from q in db.PurchaseEvaluationQuotes.AsNoTracking()
join d in db.PurchaseEvaluationDetails.AsNoTracking()
on q.PurchaseEvaluationDetailId equals d.Id
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");
}
else
else if (selectedThanhTien.Sum() <= 0)
{
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
if (winnerQuoteTotal <= 0)
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
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"
// (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
@ -881,14 +879,14 @@ public class PurchaseEvaluationWorkflowService(
throw new ForbiddenException(
"Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO.");
var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) // [Mig 58] multi-winner
.Select(s => s.Id)
.ToListAsync(ct);
var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m
: await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
// [multi-NCC A3(i)[3]] Giá gói = SUM ThanhTien báo giá ĐƯỢC CHỌN (Quote.IsSelected =
// nguồn-sự-thật) toàn phiếu. CCM duyệt done miễn CEO chỉ khi giá gói < ngưỡng (strict).
var winnerQuoteTotal = await (
from q in db.PurchaseEvaluationQuotes.AsNoTracking()
join d in db.PurchaseEvaluationDetails.AsNoTracking()
on q.PurchaseEvaluationDetailId equals d.Id
where d.PurchaseEvaluationId == evaluation.Id && q.IsSelected
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
if (winnerQuoteTotal >= ceoThreshold)
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
// 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.
// [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(
IdentityFixture fix,
TestApplicationDbContext db,
@ -71,7 +76,8 @@ public class CreateContractFromEvaluationMultiWinnerTests
int[] winnerIndexes,
decimal[] detailBudgets,
decimal[][] detailQuotes,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet)
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet,
bool[][]? detailQuoteSelected = null)
{
var actor = await fix.CreateUserAsync("drafter-d2@test.local", "Drafter D2",
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.
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
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = suppliers[j].Item2.Id,
ThanhTien = detailQuotes[i][j],
IsSelected = isSelected,
});
}
pe.Details.Add(detail);
@ -206,16 +216,19 @@ public class CreateContractFromEvaluationMultiWinnerTests
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]
public async Task Create_SingleWinner_GiaTriIsDetailsBudgetSum_NotQuoteTotal()
public async Task Create_SingleWinner_GiaTriIsSelectedQuoteTotal_NotBudgetSum()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
// 1 winner (idx 0). detailBudgets sum = 500 + 300 = 800. winner0 quote total =
// 100 + 40 = 140. Single-winner PHẢI = 800 (budget), KHÔNG = 140 (quote).
// 1 winner (idx 0). detailBudgets sum = 500 + 300 = 800 (KHÔNG dùng nữa). winner0 quote
// ĐƯỢC CHỌN = 100 + 40 = 140. Single-winner NAY = 140 (quote IsSelected), KHÔNG = 800 (budget).
var seeded = await SeedAsync(fix, db,
supplierCount: 2,
winnerIndexes: new[] { 0 },
@ -235,7 +248,7 @@ public class CreateContractFromEvaluationMultiWinnerTests
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(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.MaHopDong.Should().NotBeNull();
// TenHopDong null trong request → fallback pe.TenGoiThau.
@ -245,6 +258,45 @@ public class CreateContractFromEvaluationMultiWinnerTests
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 =====
[Fact]

View File

@ -20,6 +20,12 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// 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
// 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
{
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
// 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(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid detailId,
string code, decimal thanhTien, bool isWinner, int order)
@ -94,6 +98,7 @@ public class PeMultiWinnerTests
PurchaseEvaluationDetailId = detailId,
PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien,
IsSelected = isWinner, // [multi-NCC] winner ⟺ báo giá được chọn
});
await db.SaveChangesAsync(CancellationToken.None);
return supplier.Id;
@ -215,11 +220,13 @@ public class PeMultiWinnerTests
{
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1,
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
{
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id,
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 30_000_000m,
IsSelected = true,
});
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");
}
// ===== [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;
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-after] SelectPurchaseEvaluationWinnerCommand
// MULTI-WINNER. Command record ĐỔI `(Guid PurchaseEvaluationId, Guid? SupplierId)` →
// `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)`.
// Handler (PurchaseEvaluationDetailFeatures.cs ~404-447):
// • 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".
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED — cardinality-change test-before] SelectPurchaseEvaluationWinnerCommand
// đổi `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)` → thêm DetailId:
// `(Guid PurchaseEvaluationId, Guid DetailId, IReadOnlyList<Guid> SupplierIds)`. Winner NAY chọn
// 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.
//
// Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name.
// Handler (PurchaseEvaluationDetailFeatures.cs ~410-480):
// • 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
{
private const string DetailGroupCode = "A.I";
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid? UserId { get; init; } = Guid.NewGuid();
@ -43,6 +49,10 @@ public class PeSelectWinnerClearTests
TestApplicationDbContext db)
=> new(db, new FakeCurrentUser());
private static UpsertPurchaseEvaluationQuoteCommandHandler BuildUpsertQuoteHandler(
TestApplicationDbContext db)
=> new(db, new FakeCurrentUser());
private static async Task<Supplier> SeedSupplierAsync(
TestApplicationDbContext db, string code)
{
@ -52,8 +62,8 @@ public class PeSelectWinnerClearTests
return s;
}
// Phiếu PE + (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).
// 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 vs select.
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db, Guid? selectedSupplierId, string code = "PE-SW")
{
@ -72,9 +82,54 @@ public class PeSelectWinnerClearTests
return pe;
}
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu.
// Return PES row Id để assert IsWinner.
private static async Task<Guid> AddParticipatingAsync(
// 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.
private static async Task<Guid> SeedDetailAsync(
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)
{
var row = new PurchaseEvaluationSupplier
@ -98,52 +153,48 @@ public class PeSelectWinnerClearTests
.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]
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull_AllRowsNotWinner()
{
// ⭐ Phiếu đang có 1 winner (s1, IsWinner=true) + s1 trong participating-list →
// gọi SupplierIds=[] → clear: SelectedSupplierId=null + s1.IsWinner=false.
// ⭐ Phiếu đang có 1 winner (s1, quote IsSelected=true → IsWinner=true) + s1 trong
// participating-list → gọi SupplierIds=[] cho hạng mục → clear: quote IsSelected=false,
// IsWinner derive=false, SelectedSupplierId=null.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id);
// Pre-set s1 = winner (mô phỏng phiếu đã chọn trước).
var row = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
row.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
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);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
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]
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
// có id cần kiểm). Phiếu 2 NCC tham gia, s1 đang là winner → clear []
// KHÔNG ném + cả 2 row IsWinner=false + SelectedSupplierId=null.
// Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không có id
// 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 IsWinner=false + SelectedSupplierId=null.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-1");
var s2 = await SeedSupplierAsync(db, "NCC-2");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
r1.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true, order: 0);
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
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);
await act.Should().NotThrowAsync("clear [] KHÔNG validate participating-list");
@ -156,14 +207,16 @@ public class PeSelectWinnerClearTests
[Fact]
public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
{
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không row IsWinner) →
// clear [] lần nữa = idempotent, vẫn null, không ném. Empty list cũng không cản.
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không quote IsSelected) →
// 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();
var db = fix.Db;
var pe = await SeedPeAsync(db, selectedSupplierId: null);
var detailId = await SeedDetailAsync(db, pe.Id);
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);
await act.Should().NotThrowAsync();
@ -174,22 +227,23 @@ public class PeSelectWinnerClearTests
[Fact]
public async Task Clear_WritesChangelog_WithBoChonSummary()
{
// Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn đơn vị
// NCC/TP trúng thầu" (phân biệt với "Chọn {n} đơn vị..." của nhánh select).
// Nhánh clear ghi 1 Changelog Header/Update Summary "Bỏ chọn NCC/TP trúng thầu cho
// hạng mục {GroupCode}" (phân biệt với "Chọn {n}..." của nhánh select).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A");
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 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);
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == pe.Id)
.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.Action.Should().Be(ChangelogAction.Update);
}
@ -201,16 +255,17 @@ public class PeSelectWinnerClearTests
[Fact]
public async Task Select_OneSupplierInList_SetsSelectedSupplierId_RowIsWinner()
{
// Happy-path single-winner: 1 supplier hợp lệ (thuộc participating-list) →
// SelectedSupplierId = đúng id (count==1 sync) + row IsWinner=true.
// Happy-path single-winner: 1 supplier hợp lệ (trong list, có quote cho hạng mục) →
// quote IsSelected=true → IsWinner=true + SelectedSupplierId = đúng id (count==1 sync).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-WIN");
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 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);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
@ -221,22 +276,20 @@ public class PeSelectWinnerClearTests
[Fact]
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
// chuyển s2 + s2.IsWinner=true + s1.IsWinner=false (set false phần còn lại).
// Đổi winner: phiếu đang chọn s1 (quote sel=true) → chọn [s2] cho hạng mục → s2 quote
// IsSelected=true + s1 quote IsSelected=false → SelectedSupplierId=s2 + s2.IsWinner=true
// + s1.IsWinner=false.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-1");
var s2 = await SeedSupplierAsync(db, "NCC-2");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
// s1 đang là winner.
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
r1.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true, order: 0);
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
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);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
@ -248,20 +301,18 @@ public class PeSelectWinnerClearTests
[Fact]
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
{
// Supplier không thuộc participating-list của phiếu → Conflict. IsWinner +
// SelectedSupplierId giữ nguyên (throw TRƯỚC khi set + SaveChanges).
// Supplier không thuộc participating-list → Conflict. IsWinner + SelectedSupplierId giữ
// nguyên (throw TRƯỚC khi flip IsSelected + SaveChanges).
using var fix = new SqliteDbFixture();
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 pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
await AddParticipatingAsync(db, pe.Id, s1.Id);
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
r1.IsWinner = true;
await db.SaveChangesAsync(CancellationToken.None);
var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
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);
await act.Should().ThrowAsync<ConflictException>()
@ -275,18 +326,18 @@ public class PeSelectWinnerClearTests
[Fact]
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
// phải thuộc list). KHÔNG partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ
// (validate TRƯỚC khi set IsWinner cho toàn bộ rows).
// Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict. KHÔNG
// partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ (validate TRƯỚC khi flip IsSelected).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-OK");
var outsider = await SeedSupplierAsync(db, "NCC-FORGE");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
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 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);
await act.Should().ThrowAsync<ConflictException>()
@ -295,25 +346,26 @@ public class PeSelectWinnerClearTests
}
// ============================================================
// 3. SELECT branch — MULTI winner (≥2 → joint winner, SelectedSupplierId=null)
// [TASK 2.1] regression cho hành vi multi-winner mới (Mig 58).
// 3. SELECT branch — MULTI winner CÙNG HẠNG MỤC (≥2 → joint, SelectedSupplierId=null)
// [multi-NCC] ≥2 NCC/TP trúng CÙNG 1 hạng mục.
// ============================================================
[Fact]
public async Task Select_TwoSuppliers_BothIsWinner_SelectedSupplierIdNull()
{
// Chọn 2 đơn vị cùng trúng → cả 2 IsWinner=true, SelectedSupplierId=null (≥2 →
// không còn "1 winner" duy nhất). Trục multi-winner cốt lõi.
// Chọn 2 đơn vị cùng trúng 1 hạng mục → cả 2 quote IsSelected=true → cả 2 IsWinner=true,
// SelectedSupplierId=null (≥2 → không "1 winner" duy nhất). Trục multi-NCC cốt lõi.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-J1");
var s2 = await SeedSupplierAsync(db, "NCC-J2");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var detailId = await SeedDetailAsync(db, pe.Id);
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 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);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
@ -325,8 +377,8 @@ public class PeSelectWinnerClearTests
[Fact]
public async Task Select_ThreeOfFour_ThreeIsWinner_NonSelectedIsFalse_SelectedSupplierIdNull()
{
// 4 NCC tham gia, chọn 3 → đúng 3 row IsWinner=true, NCC thứ 4 IsWinner=false,
// SelectedSupplierId=null. Verify "set false phần còn lại" với nhiều row.
// 4 NCC chào cùng hạng mục, chọn 3 → đúng 3 quote IsSelected=true (3 IsWinner), NCC thứ 4
// quote IsSelected=false (IsWinner=false), SelectedSupplierId=null.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-Q1");
@ -334,13 +386,14 @@ public class PeSelectWinnerClearTests
var s3 = await SeedSupplierAsync(db, "NCC-Q3");
var s4 = await SeedSupplierAsync(db, "NCC-Q4");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
await AddParticipatingAsync(db, pe.Id, s3.Id, order: 2);
await AddParticipatingAsync(db, pe.Id, s4.Id, order: 3);
var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
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 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);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
@ -354,16 +407,17 @@ public class PeSelectWinnerClearTests
[Fact]
public async Task Select_DuplicateIds_Deduped_TreatedAsSingleWinner_SelectedSupplierIdSet()
{
// FE gửi trùng id [s1, s1] → Distinct → count==1 → đối xử single-winner:
// s1.IsWinner=true + SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng).
// FE gửi trùng id [s1, s1] → Distinct → count==1 → single-winner: s1.IsWinner=true +
// SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-DUP");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id);
var detailId = await SeedDetailAsync(db, pe.Id);
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
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);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
@ -374,25 +428,25 @@ public class PeSelectWinnerClearTests
[Fact]
public async Task Select_MultiThenClear_RoundTrip_AllWinnersReset()
{
// Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false +
// SelectedSupplierId=null. Chứng minh clear reset đúng kể cả từ trạng thái multi.
// Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false + SelectedSupplierId=null.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-R1");
var s2 = await SeedSupplierAsync(db, "NCC-R2");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var detailId = await SeedDetailAsync(db, pe.Id);
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);
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);
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("sau chọn 2");
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
await handler.Handle(
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>()),
new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>()),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
@ -404,43 +458,42 @@ public class PeSelectWinnerClearTests
[Fact]
public async Task Select_MultiWinner_WritesChangelog_WithChonNSummary()
{
// Multi-winner ghi Changelog Summary "Chọn {n} đơn vị NCC/TP trúng thầu" (n = số
// đơn vị chọn). Verify count chính xác trong message.
// Multi-winner ghi Changelog Summary "Chọn {n} NCC/TP trúng thầu cho hạng mục {GroupCode}".
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-CL1");
var s2 = await SeedSupplierAsync(db, "NCC-CL2");
var pe = await SeedPeAsync(db, selectedSupplierId: null);
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
var detailId = await SeedDetailAsync(db, pe.Id);
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);
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);
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == pe.Id)
.SingleAsync();
log.Summary.Should().Be("Chọn 2 đơn vị NCC/TP trúng thầu");
log.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.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]
public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch()
{
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC
// khi xét SupplierIds (kể cả nhánh clear [] cũng không bỏ qua existence).
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler (TRƯỚC Detail + SupplierIds).
using var fix = new SqliteDbFixture();
var db = fix.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);
await act.Should().ThrowAsync<NotFoundException>();
@ -454,9 +507,190 @@ public class PeSelectWinnerClearTests
var s1 = await SeedSupplierAsync(db, "NCC-A");
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);
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ể).
// [Mig 58 multi-winner] Tiêu chí "đã chọn thầu" nay = IsWinner trên supplier-row
// (KHÔNG còn SelectedSupplierId). Helper này set CẢ HAI để mô phỏng single-winner đúng
// nghiệp vụ (đúng 1 winner ⇒ SelectedSupplierId = winner đó được sync). Code GetDetail
// chỉ đọc IsWinner cho prevSelected*; SelectedSupplierId giữ để các path khác (banner FE).
// [multi-NCC 2026-07-12 cardinality-change] budgetSummary PreviousSelectedTotal +
// CurrentProposalTotal NAY = SUM ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected —
// PurchaseEvaluationFeatures.cs:911-926 `where q.IsSelected`), KHÔNG còn build từ IsWinner.
// 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(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
{
@ -606,7 +607,7 @@ public class PeWorkItemBudgetTests
PurchaseEvaluationId = pe.Id,
SupplierId = supplierId,
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
{
@ -625,6 +626,7 @@ public class PeWorkItemBudgetTests
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien,
IsSelected = true, // [multi-NCC] báo giá được chọn → vào PrevSelectedTotal/CurrentProposalTotal
});
await db.SaveChangesAsync(CancellationToken.None);
}
@ -665,6 +667,7 @@ public class PeWorkItemBudgetTests
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id,
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.

View File

@ -80,6 +80,11 @@ public class PeCcmThresholdFinalizeTests
// Seed 1 NCC tham gia (winner) + 1 detail + 1 quote ThanhTien=amount → winner
// 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(
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
{
@ -89,7 +94,7 @@ public class PeCcmThresholdFinalizeTests
PurchaseEvaluationId = pe.Id,
SupplierId = supplierId,
Order = 0,
IsWinner = true, // [Mig 58] ccm-delegate winnerQuoteTotal đếm SUM theo IsWinner rows
IsWinner = true, // derived == Any(quote.IsSelected)
};
var detail = new PurchaseEvaluationDetail
{
@ -106,6 +111,47 @@ public class PeCcmThresholdFinalizeTests
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id,
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);
return supplierId;
@ -611,4 +657,50 @@ public class PeCcmThresholdFinalizeTests
.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
// quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref).
// [Mig 58] Winner = supplier-row IsWinner=true (nguồn-sự-thật mới — submit guard +
// ccm-delegate + read DTO SUM theo IsWinner, KHÔNG còn theo SelectedSupplierId). Set
// IsWinner=true + sync SelectedSupplierId trong caller để mirror handler select-winner.
// [multi-NCC 2026-07-12 cardinality-change] Submit guard NAY đọc SUM theo
// Quote.IsSelected (nguồn-sự-thật per Detail×Supplier — PurchaseEvaluationWorkflowService
// :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(
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
{
@ -108,7 +112,7 @@ public class PeSubmitGuardAndBypassTests
PurchaseEvaluationId = pe.Id,
SupplierId = supplierId,
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
{
@ -125,6 +129,7 @@ public class PeSubmitGuardAndBypassTests
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = quoteThanhTien,
IsSelected = true, // [multi-NCC] báo giá ĐƯỢC CHỌN → lọt SUM submit-guard/DTO/threshold
});
await db.SaveChangesAsync(CancellationToken.None);
return supplierId;