[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
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:
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user