[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>