[CLAUDE] PurchaseEvaluation: CV PRO refinement batch (anh Kiệt FDC) — live budget recompute + clear winner + fullscreen preview + hide create c/d
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m59s

C1 ẩn "c. Giá chào thầu" + "d. Bảng so sánh giá" khỏi form TẠO (vẫn hiện ở Detail tabs)
C3 ô 8 "Giá trị TH dự kiến còn lại" pre-fill từ dòng 7 (Ngân sách còn lại)
C4a live-recompute: VndInlineEdit +onLiveChange, PeBudgetSummaryTable draftRow3/8 → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ ô 3/8 (chưa cần Lưu)
C4b So sánh = 0 → "Bằng ngân sách" / "—" thay "0 đ" (chỉ khi base>0)
C5 hủy/đổi NCC winner: BE SelectWinnerBody(Guid?) + Command/Handler 2 nhánh (null=clear bỏ-qua-validate-list / non-null=giữ logic cũ); FE dropdown ""→clear + nút ✓ toggle. KHÔNG migration (SelectedSupplierId đã Guid?)
C7 nút "Toàn màn hình" preview file báo giá (overlay inset-0 + Esc thoát)

BE 2 file (Api controller record + Application command/handler). FE 3 file × 2 app SHA-identical. Test +10 PeSelectWinnerClearTests (4 clear + 4 select-regression + 2 PE-existence) → 366 PASS. Both FE build PASS, slnx build 0 err.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-23 08:47:51 +07:00
parent 0e159908d6
commit 5a6125494b
9 changed files with 546 additions and 124 deletions

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Loader2, AlertTriangle } from 'lucide-react'
import { Loader2, AlertTriangle, Maximize2, Minimize2 } from 'lucide-react'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
@ -24,17 +24,33 @@ type Props = {
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
* Fetch as blob → object URL → iframe (PDF) hoặc img (image).
* Bearer auth qua axios api client (KHÔNG thể set iframe src trực tiếp vì
* iframe không inherit Authorization header). */
* iframe không inherit Authorization header).
* [C7 anh Kiệt FDC] Nút "Toàn màn hình" → lớp phủ inset-0 phóng to preview hết
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose,
}: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fullscreen, setFullscreen] = useState(false)
const ext = fileName.toLowerCase().split('.').pop() ?? ''
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext)
// Reset toàn-màn-hình mỗi khi đóng / đổi file.
useEffect(() => { if (!open) setFullscreen(false) }, [open])
// Esc khi đang toàn-màn-hình → thoát toàn-màn-hình TRƯỚC (không đóng luôn Dialog).
useEffect(() => {
if (!fullscreen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false) }
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [fullscreen])
useEffect(() => {
if (!open) return
let cancelled = false
@ -64,34 +80,65 @@ export function AttachmentPreviewDialog({
}
}, [open, evaluationId, attachmentId])
const ready = blobUrl && !loading && !error
return (
<Dialog
open={open}
onClose={onClose}
title={`Xem file: ${fileName}`}
size="lg"
footer={<Button variant="outline" onClick={onClose}>Đóng</Button>}
>
<div className="h-[70vh] w-full bg-slate-100">
{loading && (
<div className="flex h-full items-center justify-center gap-2 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Đang tải file</span>
<>
<Dialog
open={open}
onClose={onClose}
title={`Xem file: ${fileName}`}
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setFullscreen(true)} disabled={!ready}>
<Maximize2 className="mr-1 h-3.5 w-3.5" /> Toàn màn hình
</Button>
<Button variant="outline" onClick={onClose}>Đóng</Button>
</>
}
>
<div className="h-[70vh] w-full bg-slate-100">
{loading && (
<div className="flex h-full items-center justify-center gap-2 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Đang tải file</span>
</div>
)}
{error && !loading && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-red-600">
<AlertTriangle className="h-8 w-8" />
<div className="font-medium">Không tải đưc file</div>
<div className="text-xs text-red-500">{error}</div>
</div>
)}
{ready && (
isImage
? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" />
)}
</div>
</Dialog>
{/* [C7] Lớp phủ toàn-màn-hình — preview lớn hết cỡ viewport, trên cả Dialog. */}
{open && fullscreen && ready && (
<div className="fixed inset-0 z-[60] flex flex-col bg-black/95">
<div className="flex items-center justify-between gap-3 px-4 py-2 text-white">
<span className="truncate text-sm font-medium">{fileName}</span>
<button
onClick={() => setFullscreen(false)}
className="inline-flex shrink-0 items-center gap-1 rounded-md bg-white/10 px-3 py-1.5 text-xs hover:bg-white/20"
>
<Minimize2 className="h-4 w-4" /> Thu nhỏ (Esc)
</button>
</div>
)}
{error && !loading && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-red-600">
<AlertTriangle className="h-8 w-8" />
<div className="font-medium">Không tải đưc file</div>
<div className="text-xs text-red-500">{error}</div>
<div className="flex flex-1 items-center justify-center overflow-auto p-2">
{isImage
? <img src={blobUrl} alt={fileName} className="max-h-full max-w-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0 bg-white" />}
</div>
)}
{blobUrl && !loading && !error && (
isImage
? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" />
)}
</div>
</Dialog>
</div>
)}
</>
)
}

View File

@ -907,10 +907,10 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => {
toast.success('Đã chọn NCC.')
toast.success('Đã cập nhật NCC được chọn.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
@ -934,7 +934,7 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
<div className="relative min-w-0 flex-1">
<Select
value={ev.selectedSupplierId ?? ''}
onChange={e => setWinner.mutate(e.target.value)}
onChange={e => setWinner.mutate(e.target.value || null)}
disabled={ev.suppliers.length === 0 || setWinner.isPending}
className="text-sm"
>
@ -977,32 +977,41 @@ const fmtVndSigned = (v: number) =>
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
// [C4b anh Kiệt FDC] Dòng "So sánh" = 0 (đề xuất ĐÚNG BẰNG ngân sách) → chữ thay "0 đ"
// cho đỡ khó hiểu. base>0 (có ngân sách thật) → "Bằng ngân sách"; base<=0 (phiếu trống) → "—".
const fmtCompareValue = (v: number, base: number): React.ReactNode =>
v === 0
? <span className="font-sans text-[12px] font-normal text-slate-500">{base > 0 ? 'Bằng ngân sách' : '—'}</span>
: <span className={cn(v < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(v)}</span>
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label,
initial, allowNegative = false, onSave, saving, label, onLiveChange,
}: {
initial: number | null
allowNegative?: boolean
onSave: (v: number | null) => void
saving: boolean
label?: string
/** [C4a anh Kiệt FDC] báo giá trị ĐANG GÕ lên cha mỗi keystroke → live-recompute. */
onLiveChange?: (v: number | null) => void
}) {
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => {
const n = parseVnd(text)
if (n === 0 && text.trim() === '') return null
return allowNegative && neg ? -n : n
const valueOf = (raw: string, isNeg: boolean): number | null => {
const n = parseVnd(raw)
if (n === 0 && raw.trim() === '') return null
return allowNegative && isNeg ? -n : n
}
const parse = (): number | null => valueOf(text, neg)
const dirty = parse() !== initial
return (
<div className="flex items-center justify-end gap-1.5">
{allowNegative && (
<button
type="button"
onClick={() => setNeg(v => !v)}
onClick={() => { const nv = !neg; setNeg(nv); onLiveChange?.(valueOf(text, nv)) }}
className={cn(
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
@ -1017,7 +1026,10 @@ function VndInlineEdit({
type="text"
inputMode="numeric"
value={text}
onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
onChange={e => {
setText(formatVndInput(parseVnd(e.target.value)))
onLiveChange?.(valueOf(e.target.value, neg))
}}
placeholder="0"
aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]"
@ -1230,6 +1242,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [bs?.ccmNote])
// [C4a anh Kiệt FDC] Live-recompute: giữ giá trị ĐANG GÕ của ô 3 (NS kỳ này) + ô 8 (giá
// trị TH dự kiến còn lại) ở state cục bộ → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ
// (chưa cần bấm Lưu). Sync lại từ server (ev.*) sau mỗi save/refetch.
const [draftRow3, setDraftRow3] = useState<number | null>(ev.budgetPeriodAmount)
const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount)
useEffect(() => { setDraftRow3(ev.budgetPeriodAmount) }, [ev.budgetPeriodAmount])
useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) {
return (
@ -1249,12 +1269,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
const row3 = draftRow3 ?? 0 // Ngân sách - kỳ này (drafter, live)
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
const row8 = draftRow8 ?? row7 // Giá trị thực hiện dự kiến còn lại (live)
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4)
@ -1262,8 +1282,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = draftRow8 != null && draftRow8 > row7
return (
<div className="overflow-hidden rounded-lg border border-slate-300">
@ -1417,6 +1437,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này"
onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span>
@ -1449,7 +1470,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So sánh với ngân sách kỳ này"
indent
sub="= 3 4"
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
value={fmtCompareValue(cmpPeriod, row3)}
third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0}
/>
@ -1471,7 +1492,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So với NS"
indent
sub="= 5 6"
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
value={fmtCompareValue(cmp56, row5)}
third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0}
/>
@ -1496,10 +1517,11 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<div className="w-48 shrink-0 text-right">
{drafterEditable ? (
<VndInlineEdit
initial={ev.expectedRemainingAmount}
initial={ev.expectedRemainingAmount ?? row7}
allowNegative
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
@ -1523,7 +1545,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So sánh với Ngân sách full"
indent
sub="= Ngân sách full 9"
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
value={fmtCompareValue(cmpFull, full)}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
@ -2383,7 +2405,7 @@ function HangMucCard({
onError: e => toast.error(getErrorMessage(e)),
})
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
@ -2552,12 +2574,12 @@ function HangMucCard({
<td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5">
<button
onClick={() => setWinner.mutate(s.supplierId)}
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
className={cn(
'rounded px-1 py-0.5',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={isWinner ? 'Đơn vị NCC/TP đã được chọn' : 'Chọn đơn vị NCC/TP'}
title={isWinner ? 'Bỏ chọn đơn vị này (click để hủy)' : 'Chọn đơn vị NCC/TP'}
>
<Check className="h-3 w-3" />
</button>

View File

@ -270,14 +270,8 @@ export function PeWorkspaceCreateView({
</div>
</div>
<FormRow
label="c. Giá chào thầu"
value={<span className="text-slate-400"> (auto-tính từ báo giá NCC sau khi chọn winner)</span>}
/>
<FormRow
label="d. Bảng so sánh giá"
value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />}
/>
{/* [C1 anh Kiệt FDC] "c. Giá chào thầu" + "d. Bảng so sánh giá" ẨN khỏi form
TẠO (chưa dùng được lúc tạo) — vẫn hiện đầy đủ ở Detail tabs sau khi tạo phiếu. */}
{/* e. Link hồ sơ (anh Kiệt FDC) — dán link thư mục hồ sơ trên NAS công ty
(1 cột HoSoLink). Create = Input; khi xem phiếu render thẻ <a> bấm-mở. */}

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Loader2, AlertTriangle } from 'lucide-react'
import { Loader2, AlertTriangle, Maximize2, Minimize2 } from 'lucide-react'
import { Dialog } from '@/components/ui/Dialog'
import { Button } from '@/components/ui/Button'
import { api } from '@/lib/api'
@ -24,17 +24,33 @@ type Props = {
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
* Fetch as blob → object URL → iframe (PDF) hoặc img (image).
* Bearer auth qua axios api client (KHÔNG thể set iframe src trực tiếp vì
* iframe không inherit Authorization header). */
* iframe không inherit Authorization header).
* [C7 anh Kiệt FDC] Nút "Toàn màn hình" → lớp phủ inset-0 phóng to preview hết
* cỡ viewport (Dialog dùng chung chỉ có sm/md/lg → tự render overlay riêng). */
export function AttachmentPreviewDialog({
open, evaluationId, attachmentId, fileName, onClose,
}: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fullscreen, setFullscreen] = useState(false)
const ext = fileName.toLowerCase().split('.').pop() ?? ''
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext)
// Reset toàn-màn-hình mỗi khi đóng / đổi file.
useEffect(() => { if (!open) setFullscreen(false) }, [open])
// Esc khi đang toàn-màn-hình → thoát toàn-màn-hình TRƯỚC (không đóng luôn Dialog).
useEffect(() => {
if (!fullscreen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); setFullscreen(false) }
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [fullscreen])
useEffect(() => {
if (!open) return
let cancelled = false
@ -64,34 +80,65 @@ export function AttachmentPreviewDialog({
}
}, [open, evaluationId, attachmentId])
const ready = blobUrl && !loading && !error
return (
<Dialog
open={open}
onClose={onClose}
title={`Xem file: ${fileName}`}
size="lg"
footer={<Button variant="outline" onClick={onClose}>Đóng</Button>}
>
<div className="h-[70vh] w-full bg-slate-100">
{loading && (
<div className="flex h-full items-center justify-center gap-2 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Đang tải file</span>
<>
<Dialog
open={open}
onClose={onClose}
title={`Xem file: ${fileName}`}
size="lg"
footer={
<>
<Button variant="outline" onClick={() => setFullscreen(true)} disabled={!ready}>
<Maximize2 className="mr-1 h-3.5 w-3.5" /> Toàn màn hình
</Button>
<Button variant="outline" onClick={onClose}>Đóng</Button>
</>
}
>
<div className="h-[70vh] w-full bg-slate-100">
{loading && (
<div className="flex h-full items-center justify-center gap-2 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Đang tải file</span>
</div>
)}
{error && !loading && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-red-600">
<AlertTriangle className="h-8 w-8" />
<div className="font-medium">Không tải đưc file</div>
<div className="text-xs text-red-500">{error}</div>
</div>
)}
{ready && (
isImage
? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" />
)}
</div>
</Dialog>
{/* [C7] Lớp phủ toàn-màn-hình — preview lớn hết cỡ viewport, trên cả Dialog. */}
{open && fullscreen && ready && (
<div className="fixed inset-0 z-[60] flex flex-col bg-black/95">
<div className="flex items-center justify-between gap-3 px-4 py-2 text-white">
<span className="truncate text-sm font-medium">{fileName}</span>
<button
onClick={() => setFullscreen(false)}
className="inline-flex shrink-0 items-center gap-1 rounded-md bg-white/10 px-3 py-1.5 text-xs hover:bg-white/20"
>
<Minimize2 className="h-4 w-4" /> Thu nhỏ (Esc)
</button>
</div>
)}
{error && !loading && (
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-red-600">
<AlertTriangle className="h-8 w-8" />
<div className="font-medium">Không tải đưc file</div>
<div className="text-xs text-red-500">{error}</div>
<div className="flex flex-1 items-center justify-center overflow-auto p-2">
{isImage
? <img src={blobUrl} alt={fileName} className="max-h-full max-w-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0 bg-white" />}
</div>
)}
{blobUrl && !loading && !error && (
isImage
? <img src={blobUrl} alt={fileName} className="mx-auto h-full object-contain" />
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0" />
)}
</div>
</Dialog>
</div>
)}
</>
)
}

View File

@ -907,10 +907,10 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
const canEdit = !readOnly && isEditablePhase(ev.phase)
const qc = useQueryClient()
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => {
toast.success('Đã chọn NCC.')
toast.success('Đã cập nhật NCC được chọn.')
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
},
@ -934,7 +934,7 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
<div className="relative min-w-0 flex-1">
<Select
value={ev.selectedSupplierId ?? ''}
onChange={e => setWinner.mutate(e.target.value)}
onChange={e => setWinner.mutate(e.target.value || null)}
disabled={ev.suppliers.length === 0 || setWinner.isPending}
className="text-sm"
>
@ -977,32 +977,41 @@ const fmtVndSigned = (v: number) =>
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
const fmtPct = (num: number, denom: number): string | null =>
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : null
// [C4b anh Kiệt FDC] Dòng "So sánh" = 0 (đề xuất ĐÚNG BẰNG ngân sách) → chữ thay "0 đ"
// cho đỡ khó hiểu. base>0 (có ngân sách thật) → "Bằng ngân sách"; base<=0 (phiếu trống) → "—".
const fmtCompareValue = (v: number, base: number): React.ReactNode =>
v === 0
? <span className="font-sans text-[12px] font-normal text-slate-500">{base > 0 ? 'Bằng ngân sách' : '—'}</span>
: <span className={cn(v < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(v)}</span>
// Inline-edit số tiền VND (reuse formatVndInput/parseVnd module-level). allowNegative
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
function VndInlineEdit({
initial, allowNegative = false, onSave, saving, label,
initial, allowNegative = false, onSave, saving, label, onLiveChange,
}: {
initial: number | null
allowNegative?: boolean
onSave: (v: number | null) => void
saving: boolean
label?: string
/** [C4a anh Kiệt FDC] báo giá trị ĐANG GÕ lên cha mỗi keystroke → live-recompute. */
onLiveChange?: (v: number | null) => void
}) {
const [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
const [neg, setNeg] = useState((initial ?? 0) < 0)
const parse = (): number | null => {
const n = parseVnd(text)
if (n === 0 && text.trim() === '') return null
return allowNegative && neg ? -n : n
const valueOf = (raw: string, isNeg: boolean): number | null => {
const n = parseVnd(raw)
if (n === 0 && raw.trim() === '') return null
return allowNegative && isNeg ? -n : n
}
const parse = (): number | null => valueOf(text, neg)
const dirty = parse() !== initial
return (
<div className="flex items-center justify-end gap-1.5">
{allowNegative && (
<button
type="button"
onClick={() => setNeg(v => !v)}
onClick={() => { const nv = !neg; setNeg(nv); onLiveChange?.(valueOf(text, nv)) }}
className={cn(
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
@ -1017,7 +1026,10 @@ function VndInlineEdit({
type="text"
inputMode="numeric"
value={text}
onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
onChange={e => {
setText(formatVndInput(parseVnd(e.target.value)))
onLiveChange?.(valueOf(e.target.value, neg))
}}
placeholder="0"
aria-label={label}
className="h-7 pr-6 font-mono text-right text-[13px]"
@ -1230,6 +1242,14 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [bs?.ccmNote])
// [C4a anh Kiệt FDC] Live-recompute: giữ giá trị ĐANG GÕ của ô 3 (NS kỳ này) + ô 8 (giá
// trị TH dự kiến còn lại) ở state cục bộ → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ
// (chưa cần bấm Lưu). Sync lại từ server (ev.*) sau mỗi save/refetch.
const [draftRow3, setDraftRow3] = useState<number | null>(ev.budgetPeriodAmount)
const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount)
useEffect(() => { setDraftRow3(ev.budgetPeriodAmount) }, [ev.budgetPeriodAmount])
useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
if (!bs) {
return (
@ -1249,12 +1269,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
const row2 = bs.previousSelectedTotal // Kỳ trước đã chọn thầu
const row3 = ev.budgetPeriodAmount ?? 0 // Ngân sách - kỳ này (drafter)
const row3 = draftRow3 ?? 0 // Ngân sách - kỳ này (drafter, live)
const row4 = bs.currentProposalTotal // Giá trị kỳ này (đề xuất NCC được chọn)
const row5 = row1 + row3 // Lũy kế ngân sách đã sử dụng (= 1 + 3)
const row6 = row2 + row4 // Lũy kế thực hiện (= 2 + 4)
const row7 = full - row5 // Ngân sách còn lại
const row8 = ev.expectedRemainingAmount ?? row7 // Giá trị thực hiện dự kiến còn lại
const row8 = draftRow8 ?? row7 // Giá trị thực hiện dự kiến còn lại (live)
const row9 = row4 + row8 // Giá trị tổng thực hiện dự kiến (= 4 + 8)
const cmpPeriod = row3 - row4 // So sánh với ngân sách kỳ này (row3 row4)
@ -1262,8 +1282,8 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
const cmpFull = full - row9 // So sánh với Ngân sách full (full row9)
// Cờ tô màu cảnh báo
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
const remainingOver = draftRow8 != null && draftRow8 > row7
return (
<div className="overflow-hidden rounded-lg border border-slate-300">
@ -1417,6 +1437,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
initial={ev.budgetPeriodAmount}
saving={adjustMut.isPending}
label="Ngân sách kỳ này"
onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
/>
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400"></span>
@ -1449,7 +1470,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So sánh với ngân sách kỳ này"
indent
sub="= 3 4"
value={<span className={cn(cmpPeriod < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmpPeriod)}</span>}
value={fmtCompareValue(cmpPeriod, row3)}
third={fmtPct(cmpPeriod, row3) ?? undefined}
danger={cmpPeriod < 0}
/>
@ -1471,7 +1492,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So với NS"
indent
sub="= 5 6"
value={<span className={cn(cmp56 < 0 && 'font-semibold text-red-600')}>{fmtVndSigned(cmp56)}</span>}
value={fmtCompareValue(cmp56, row5)}
third={fmtPct(cmp56, row5) ?? undefined}
danger={cmp56 < 0}
/>
@ -1496,10 +1517,11 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<div className="w-48 shrink-0 text-right">
{drafterEditable ? (
<VndInlineEdit
initial={ev.expectedRemainingAmount}
initial={ev.expectedRemainingAmount ?? row7}
allowNegative
saving={adjustMut.isPending}
label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
/>
) : (
@ -1523,7 +1545,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
label="So sánh với Ngân sách full"
indent
sub="= Ngân sách full 9"
value={<span className={cn(cmpFull < 0 && 'font-bold text-red-600')}>{fmtVndSigned(cmpFull)}</span>}
value={fmtCompareValue(cmpFull, full)}
third={fmtPct(cmpFull, full) ?? undefined}
danger={cmpFull < 0}
/>
@ -2383,7 +2405,7 @@ function HangMucCard({
onError: e => toast.error(getErrorMessage(e)),
})
const setWinner = useMutation({
mutationFn: async (supplierId: string) =>
mutationFn: async (supplierId: string | null) =>
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
onError: e => toast.error(getErrorMessage(e)),
@ -2552,12 +2574,12 @@ function HangMucCard({
<td className="px-2 py-1.5">
<div className="flex justify-end gap-0.5">
<button
onClick={() => setWinner.mutate(s.supplierId)}
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
className={cn(
'rounded px-1 py-0.5',
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
)}
title={isWinner ? 'Đơn vị NCC/TP đã được chọn' : 'Chọn đơn vị NCC/TP'}
title={isWinner ? 'Bỏ chọn đơn vị này (click để hủy)' : 'Chọn đơn vị NCC/TP'}
>
<Check className="h-3 w-3" />
</button>

View File

@ -270,14 +270,8 @@ export function PeWorkspaceCreateView({
</div>
</div>
<FormRow
label="c. Giá chào thầu"
value={<span className="text-slate-400"> (auto-tính từ báo giá NCC sau khi chọn winner)</span>}
/>
<FormRow
label="d. Bảng so sánh giá"
value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />}
/>
{/* [C1 anh Kiệt FDC] "c. Giá chào thầu" + "d. Bảng so sánh giá" ẨN khỏi form
TẠO (chưa dùng được lúc tạo) — vẫn hiện đầy đủ ở Detail tabs sau khi tạo phiếu. */}
{/* e. Link hồ sơ (anh Kiệt FDC) — dán link thư mục hồ sơ trên NAS công ty
(1 cột HoSoLink). Create = Input; khi xem phiếu render thẻ <a> bấm-mở. */}

View File

@ -345,7 +345,7 @@ public record AddSupplierBody(
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
string? PaymentTermText, string? Note);
public record SelectWinnerBody(Guid SupplierId);
public record SelectWinnerBody(Guid? SupplierId); // null = bỏ chọn NCC (cho phép xóa + điền lại — anh Kiệt FDC C5)
public record DetailBody(
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,

View File

@ -387,7 +387,9 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
// ========== Select winner (NCC được chọn tổng thể) ==========
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid SupplierId) : IRequest;
// SupplierId nullable [anh Kiệt FDC C5]: null = BỎ CHỌN (xóa đơn vị đã chọn, điền
// lại) — un-select winner về none; có giá trị = chọn/đổi NCC (logic cũ giữ 100%).
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid? SupplierId) : IRequest;
public class SelectPurchaseEvaluationWinnerCommandHandler(
IApplicationDbContext db,
@ -398,15 +400,27 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
?? throw new NotFoundException("Supplier", request.SupplierId);
if (request.SupplierId is Guid supplierId)
{
// ── Chọn / đổi NCC trúng thầu (logic cũ giữ nguyên 100%) ──
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct)
?? throw new NotFoundException("Supplier", supplierId);
// Verify supplier nằm trong danh sách phiếu
var hasSupplier = await db.PurchaseEvaluationSuppliers
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct);
if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
// Verify supplier nằm trong danh sách phiếu
var hasSupplier = await db.PurchaseEvaluationSuppliers
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == supplierId, ct);
if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
entity.SelectedSupplierId = request.SupplierId;
entity.SelectedSupplierId = supplierId;
}
else
{
// ── Bỏ chọn (clear về none) — KHÔNG validate participating list ──
// Row 4 "Giá trị kỳ này" + winnerQuoteTotal tính-khi-đọc theo
// SelectedSupplierId (PurchaseEvaluationFeatures GetDetail) → tự về 0.
// ApprovedPriceAmount/Source là cột chốt giai-đoạn DaDuyet, KHÔNG đụng ở đây.
entity.SelectedSupplierId = null;
}
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
@ -416,7 +430,7 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = "Chọn NCC trúng thầu",
Summary = request.SupplierId is null ? "Bỏ chọn NCC trúng thầu" : "Chọn NCC trúng thầu",
});
await db.SaveChangesAsync(ct);

View File

@ -0,0 +1,282 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Contracts; // ChangelogAction enum (reuse — PurchaseEvaluationChangelog dùng chung)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Master;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [anh Kiệt FDC C5 — test-after] SelectPurchaseEvaluationWinnerCommand: NCC winner
// nullable hoá. Command record `(Guid PurchaseEvaluationId, Guid? SupplierId)`.
// Handler (PurchaseEvaluationDetailFeatures.cs ~388-438) 2 nhánh theo SupplierId:
// • `SupplierId is Guid x` → CHỌN/ĐỔI (logic cũ giữ 100%): NotFound nếu supplier
// không có ở master + Conflict nếu supplier KHÔNG thuộc participating-list của
// phiếu (PurchaseEvaluationSuppliers) + set entity.SelectedSupplierId = x.
// • `SupplierId == null` → BỎ CHỌN (clear về none): set SelectedSupplierId = null,
// BỎ QUA validate participating-list (nhánh MỚI — trục test quan trọng nhất).
// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary phân theo null vs có-giá-trị.
//
// Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name.
public class PeSelectWinnerClearTests
{
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid? UserId { get; init; } = Guid.NewGuid();
public string? Email { get; init; } = "procurement@test.local";
public string? FullName { get; init; } = "Procurement Test";
public IReadOnlyList<string> Roles { get; init; } = new[] { AppRoles.Procurement };
public bool IsAuthenticated => UserId is not null;
}
private static SelectPurchaseEvaluationWinnerCommandHandler BuildHandler(
TestApplicationDbContext db)
=> new(db, new FakeCurrentUser());
private static async Task<Supplier> SeedSupplierAsync(
TestApplicationDbContext db, string code)
{
var s = new Supplier { Id = Guid.NewGuid(), Code = code, Name = "NCC " + code };
db.Suppliers.Add(s);
await db.SaveChangesAsync(CancellationToken.None);
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).
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db, Guid? selectedSupplierId, string code = "PE-SW")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.DangSoanThao,
MaPhieu = code,
TenGoiThau = "Gói thầu chọn NCC",
DrafterUserId = Guid.NewGuid(),
SelectedSupplierId = selectedSupplierId,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu.
private static async Task AddParticipatingAsync(
TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
{
db.PurchaseEvaluationSuppliers.Add(new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = peId,
SupplierId = supplierId,
Order = order,
});
await db.SaveChangesAsync(CancellationToken.None);
}
// ============================================================
// 1. CLEAR branch (MỚI — trục quan trọng nhất)
// ============================================================
[Fact]
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull()
{
// ⭐ Phiếu đang có SelectedSupplierId = winner (s1) + s1 VẪN nằm trong
// participating-list → gọi SupplierId=null → clear về null, KHÔNG ném.
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 handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
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");
}
[Fact]
public async Task Clear_WinnerNotInParticipatingList_StillClears_NoConflict()
{
// Trục cốt lõi nhánh clear: SKIP validate participating-list. Phiếu có
// SelectedSupplierId = s1 (winner cũ) nhưng s1 KHÔNG còn trong list (vd bị
// gỡ khỏi bảng so sánh). Clear vẫn phải thành công, KHÔNG ném Conflict/NotFound.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-GONE");
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
// CỐ Ý không AddParticipating → s1 không thuộc list của phiếu.
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().NotThrowAsync("clear KHÔNG validate participating-list");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull();
}
[Fact]
public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
{
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null) → clear lần nữa =
// idempotent, vẫn null, không ném. Empty participating-list cũng không cản.
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pe = await SeedPeAsync(db, selectedSupplierId: null);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().NotThrowAsync();
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().BeNull();
}
[Fact]
public async Task Clear_WritesChangelog_WithBoChonSummary()
{
// Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn NCC
// trúng thầu" (phân biệt với "Chọn NCC trúng thầu" 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);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: null);
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 NCC trúng thầu");
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
log.Action.Should().Be(ChangelogAction.Update);
}
// ============================================================
// 2. SELECT branch (regression — logic cũ giữ nguyên 100%)
// ============================================================
[Fact]
public async Task Select_SupplierInParticipatingList_SetsSelectedSupplierId()
{
// Happy-path cũ: supplier hợp lệ (tồn tại master + thuộc participating-list)
// → set SelectedSupplierId = đúng id.
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 handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s1.Id);
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s1.Id);
}
[Fact]
public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2()
{
// Đổi winner: phiếu đang chọn s1 → chọn s2 (cả 2 trong list) → SelectedSupplierId
// chuyển s2 (chứng minh nhánh select overwrite, không chỉ set-from-null).
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 handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: s2.Id);
await handler.Handle(cmd, CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s2.Id);
}
[Fact]
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
{
// Supplier tồn tại ở master NHƯNG chưa được thêm vào phiếu → Conflict.
// entity.SelectedSupplierId giữ nguyên (throw TRƯỚC khi gán + SaveChanges).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list
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 handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: outsider.Id);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*NCC chưa được thêm vào phiếu*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.SelectedSupplierId.Should().Be(s1.Id, "Conflict TRƯỚC gán → winner cũ s1 giữ nguyên");
}
[Fact]
public async Task Select_SupplierNotInMaster_ThrowsNotFound()
{
// Supplier Guid không tồn tại trong Suppliers master → NotFound (check master
// existence TRƯỚC participating-list).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var pe = await SeedPeAsync(db, selectedSupplierId: null);
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierId: Guid.NewGuid());
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
// ============================================================
// 3. PE existence — chung cả 2 nhánh (check TRƯỚC khi rẽ nhánh SupplierId)
// ============================================================
[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 SupplierId (kể cả nhánh clear null cũng không bỏ qua existence).
using var fix = new SqliteDbFixture();
var db = fix.Db;
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: null);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task UnknownPe_Select_ThrowsNotFound_BeforeBranch()
{
using var fix = new SqliteDbFixture();
var db = fix.Db;
var s1 = await SeedSupplierAsync(db, "NCC-A");
var handler = BuildHandler(db);
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierId: s1.Id);
var act = async () => await handler.Handle(cmd, CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}