[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
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:
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -24,17 +24,33 @@ type Props = {
|
|||||||
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
|
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
|
||||||
* Fetch as blob → object URL → iframe (PDF) hoặc img (image).
|
* 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ì
|
* 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({
|
export function AttachmentPreviewDialog({
|
||||||
open, evaluationId, attachmentId, fileName, onClose,
|
open, evaluationId, attachmentId, fileName, onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
|
||||||
const ext = fileName.toLowerCase().split('.').pop() ?? ''
|
const ext = fileName.toLowerCase().split('.').pop() ?? ''
|
||||||
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@ -64,34 +80,65 @@ export function AttachmentPreviewDialog({
|
|||||||
}
|
}
|
||||||
}, [open, evaluationId, attachmentId])
|
}, [open, evaluationId, attachmentId])
|
||||||
|
|
||||||
|
const ready = blobUrl && !loading && !error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<>
|
||||||
open={open}
|
<Dialog
|
||||||
onClose={onClose}
|
open={open}
|
||||||
title={`Xem file: ${fileName}`}
|
onClose={onClose}
|
||||||
size="lg"
|
title={`Xem file: ${fileName}`}
|
||||||
footer={<Button variant="outline" onClick={onClose}>Đóng</Button>}
|
size="lg"
|
||||||
>
|
footer={
|
||||||
<div className="h-[70vh] w-full bg-slate-100">
|
<>
|
||||||
{loading && (
|
<Button variant="outline" onClick={() => setFullscreen(true)} disabled={!ready}>
|
||||||
<div className="flex h-full items-center justify-center gap-2 text-slate-500">
|
<Maximize2 className="mr-1 h-3.5 w-3.5" /> Toàn màn hình
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
</Button>
|
||||||
<span>Đang tải file…</span>
|
<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>
|
</div>
|
||||||
)}
|
<div className="flex flex-1 items-center justify-center overflow-auto p-2">
|
||||||
{error && !loading && (
|
{isImage
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-red-600">
|
? <img src={blobUrl} alt={fileName} className="max-h-full max-w-full object-contain" />
|
||||||
<AlertTriangle className="h-8 w-8" />
|
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0 bg-white" />}
|
||||||
<div className="font-medium">Không tải được file</div>
|
|
||||||
<div className="text-xs text-red-500">{error}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -907,10 +907,10 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
|||||||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const setWinner = useMutation({
|
const setWinner = useMutation({
|
||||||
mutationFn: async (supplierId: string) =>
|
mutationFn: async (supplierId: string | null) =>
|
||||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||||||
onSuccess: () => {
|
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-detail', ev.id] })
|
||||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
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">
|
<div className="relative min-w-0 flex-1">
|
||||||
<Select
|
<Select
|
||||||
value={ev.selectedSupplierId ?? ''}
|
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}
|
disabled={ev.suppliers.length === 0 || setWinner.isPending}
|
||||||
className="text-sm"
|
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')} đ`
|
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
|
||||||
const fmtPct = (num: number, denom: number): string | null =>
|
const fmtPct = (num: number, denom: number): string | null =>
|
||||||
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : 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
|
// 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.
|
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
|
||||||
function VndInlineEdit({
|
function VndInlineEdit({
|
||||||
initial, allowNegative = false, onSave, saving, label,
|
initial, allowNegative = false, onSave, saving, label, onLiveChange,
|
||||||
}: {
|
}: {
|
||||||
initial: number | null
|
initial: number | null
|
||||||
allowNegative?: boolean
|
allowNegative?: boolean
|
||||||
onSave: (v: number | null) => void
|
onSave: (v: number | null) => void
|
||||||
saving: boolean
|
saving: boolean
|
||||||
label?: string
|
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 [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
|
||||||
const [neg, setNeg] = useState((initial ?? 0) < 0)
|
const [neg, setNeg] = useState((initial ?? 0) < 0)
|
||||||
const parse = (): number | null => {
|
const valueOf = (raw: string, isNeg: boolean): number | null => {
|
||||||
const n = parseVnd(text)
|
const n = parseVnd(raw)
|
||||||
if (n === 0 && text.trim() === '') return null
|
if (n === 0 && raw.trim() === '') return null
|
||||||
return allowNegative && neg ? -n : n
|
return allowNegative && isNeg ? -n : n
|
||||||
}
|
}
|
||||||
|
const parse = (): number | null => valueOf(text, neg)
|
||||||
const dirty = parse() !== initial
|
const dirty = parse() !== initial
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
{allowNegative && (
|
{allowNegative && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNeg(v => !v)}
|
onClick={() => { const nv = !neg; setNeg(nv); onLiveChange?.(valueOf(text, nv)) }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
|
'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',
|
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||||||
@ -1017,7 +1026,10 @@ function VndInlineEdit({
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={text}
|
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"
|
placeholder="0"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="h-7 pr-6 font-mono text-right text-[13px]"
|
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 ?? '')
|
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
|
||||||
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [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.
|
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
|
||||||
if (!bs) {
|
if (!bs) {
|
||||||
return (
|
return (
|
||||||
@ -1249,12 +1269,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
|
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
|
||||||
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
|
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 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 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 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 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 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 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)
|
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)
|
const cmpFull = full - row9 // So sánh với Ngân sách full (full − row9)
|
||||||
|
|
||||||
// Cờ tô màu cảnh báo
|
// Cờ tô màu cảnh báo
|
||||||
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
|
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
|
||||||
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
|
const remainingOver = draftRow8 != null && draftRow8 > row7
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||||||
@ -1417,6 +1437,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
initial={ev.budgetPeriodAmount}
|
initial={ev.budgetPeriodAmount}
|
||||||
saving={adjustMut.isPending}
|
saving={adjustMut.isPending}
|
||||||
label="Ngân sách kỳ này"
|
label="Ngân sách kỳ này"
|
||||||
|
onLiveChange={setDraftRow3}
|
||||||
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
|
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
|
||||||
/>
|
/>
|
||||||
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400">—</span>
|
) : 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"
|
label="So sánh với ngân sách kỳ này"
|
||||||
indent
|
indent
|
||||||
sub="= 3 − 4"
|
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}
|
third={fmtPct(cmpPeriod, row3) ?? undefined}
|
||||||
danger={cmpPeriod < 0}
|
danger={cmpPeriod < 0}
|
||||||
/>
|
/>
|
||||||
@ -1471,7 +1492,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
label="So với NS"
|
label="So với NS"
|
||||||
indent
|
indent
|
||||||
sub="= 5 − 6"
|
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}
|
third={fmtPct(cmp56, row5) ?? undefined}
|
||||||
danger={cmp56 < 0}
|
danger={cmp56 < 0}
|
||||||
/>
|
/>
|
||||||
@ -1496,10 +1517,11 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
<div className="w-48 shrink-0 text-right">
|
<div className="w-48 shrink-0 text-right">
|
||||||
{drafterEditable ? (
|
{drafterEditable ? (
|
||||||
<VndInlineEdit
|
<VndInlineEdit
|
||||||
initial={ev.expectedRemainingAmount}
|
initial={ev.expectedRemainingAmount ?? row7}
|
||||||
allowNegative
|
allowNegative
|
||||||
saving={adjustMut.isPending}
|
saving={adjustMut.isPending}
|
||||||
label="Giá trị thực hiện dự kiến còn lại"
|
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 })}
|
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"
|
label="So sánh với Ngân sách full"
|
||||||
indent
|
indent
|
||||||
sub="= Ngân sách full − 9"
|
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}
|
third={fmtPct(cmpFull, full) ?? undefined}
|
||||||
danger={cmpFull < 0}
|
danger={cmpFull < 0}
|
||||||
/>
|
/>
|
||||||
@ -2383,7 +2405,7 @@ function HangMucCard({
|
|||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
const setWinner = useMutation({
|
const setWinner = useMutation({
|
||||||
mutationFn: async (supplierId: string) =>
|
mutationFn: async (supplierId: string | null) =>
|
||||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
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] }) },
|
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
@ -2552,12 +2574,12 @@ function HangMucCard({
|
|||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
<div className="flex justify-end gap-0.5">
|
<div className="flex justify-end gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setWinner.mutate(s.supplierId)}
|
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-1 py-0.5',
|
'rounded px-1 py-0.5',
|
||||||
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
|
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" />
|
<Check className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -270,14 +270,8 @@ export function PeWorkspaceCreateView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormRow
|
{/* [C1 anh Kiệt FDC] "c. Giá chào thầu" + "d. Bảng so sánh giá" ẨN khỏi form
|
||||||
label="c. Giá chào thầu"
|
TẠO (chưa dùng được lúc tạo) — vẫn hiện đầy đủ ở Detail tabs sau khi tạo phiế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." />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* e. Link hồ sơ (anh Kiệt FDC) — dán link thư mục hồ sơ trên NAS công ty
|
{/* 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ở. */}
|
(1 cột HoSoLink). Create = Input; khi xem phiếu render thẻ <a> bấm-mở. */}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { Dialog } from '@/components/ui/Dialog'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
@ -24,17 +24,33 @@ type Props = {
|
|||||||
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
|
/** Preview file inline qua BE endpoint `/view` (Content-Disposition: inline).
|
||||||
* Fetch as blob → object URL → iframe (PDF) hoặc img (image).
|
* 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ì
|
* 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({
|
export function AttachmentPreviewDialog({
|
||||||
open, evaluationId, attachmentId, fileName, onClose,
|
open, evaluationId, attachmentId, fileName, onClose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
|
||||||
const ext = fileName.toLowerCase().split('.').pop() ?? ''
|
const ext = fileName.toLowerCase().split('.').pop() ?? ''
|
||||||
const isImage = ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(ext)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@ -64,34 +80,65 @@ export function AttachmentPreviewDialog({
|
|||||||
}
|
}
|
||||||
}, [open, evaluationId, attachmentId])
|
}, [open, evaluationId, attachmentId])
|
||||||
|
|
||||||
|
const ready = blobUrl && !loading && !error
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<>
|
||||||
open={open}
|
<Dialog
|
||||||
onClose={onClose}
|
open={open}
|
||||||
title={`Xem file: ${fileName}`}
|
onClose={onClose}
|
||||||
size="lg"
|
title={`Xem file: ${fileName}`}
|
||||||
footer={<Button variant="outline" onClick={onClose}>Đóng</Button>}
|
size="lg"
|
||||||
>
|
footer={
|
||||||
<div className="h-[70vh] w-full bg-slate-100">
|
<>
|
||||||
{loading && (
|
<Button variant="outline" onClick={() => setFullscreen(true)} disabled={!ready}>
|
||||||
<div className="flex h-full items-center justify-center gap-2 text-slate-500">
|
<Maximize2 className="mr-1 h-3.5 w-3.5" /> Toàn màn hình
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
</Button>
|
||||||
<span>Đang tải file…</span>
|
<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>
|
</div>
|
||||||
)}
|
<div className="flex flex-1 items-center justify-center overflow-auto p-2">
|
||||||
{error && !loading && (
|
{isImage
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-red-600">
|
? <img src={blobUrl} alt={fileName} className="max-h-full max-w-full object-contain" />
|
||||||
<AlertTriangle className="h-8 w-8" />
|
: <iframe src={blobUrl} title={fileName} className="h-full w-full border-0 bg-white" />}
|
||||||
<div className="font-medium">Không tải được file</div>
|
|
||||||
<div className="text-xs text-red-500">{error}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -907,10 +907,10 @@ function NccSelectorRow({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolea
|
|||||||
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
const canEdit = !readOnly && isEditablePhase(ev.phase)
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const setWinner = useMutation({
|
const setWinner = useMutation({
|
||||||
mutationFn: async (supplierId: string) =>
|
mutationFn: async (supplierId: string | null) =>
|
||||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
||||||
onSuccess: () => {
|
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-detail', ev.id] })
|
||||||
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
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">
|
<div className="relative min-w-0 flex-1">
|
||||||
<Select
|
<Select
|
||||||
value={ev.selectedSupplierId ?? ''}
|
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}
|
disabled={ev.suppliers.length === 0 || setWinner.isPending}
|
||||||
className="text-sm"
|
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')} đ`
|
v < 0 ? `(${Math.round(Math.abs(v)).toLocaleString('vi-VN')}) đ` : `${Math.round(v).toLocaleString('vi-VN')} đ`
|
||||||
const fmtPct = (num: number, denom: number): string | null =>
|
const fmtPct = (num: number, denom: number): string | null =>
|
||||||
denom > 0 ? `${((num / denom) * 100).toFixed(1)}%` : 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
|
// 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.
|
// cho dòng "hiệu chỉnh tăng giảm" (CCM nhập số âm). onSave nhận number|null.
|
||||||
function VndInlineEdit({
|
function VndInlineEdit({
|
||||||
initial, allowNegative = false, onSave, saving, label,
|
initial, allowNegative = false, onSave, saving, label, onLiveChange,
|
||||||
}: {
|
}: {
|
||||||
initial: number | null
|
initial: number | null
|
||||||
allowNegative?: boolean
|
allowNegative?: boolean
|
||||||
onSave: (v: number | null) => void
|
onSave: (v: number | null) => void
|
||||||
saving: boolean
|
saving: boolean
|
||||||
label?: string
|
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 [text, setText] = useState(initial != null ? Math.abs(initial).toLocaleString('vi-VN') : '')
|
||||||
const [neg, setNeg] = useState((initial ?? 0) < 0)
|
const [neg, setNeg] = useState((initial ?? 0) < 0)
|
||||||
const parse = (): number | null => {
|
const valueOf = (raw: string, isNeg: boolean): number | null => {
|
||||||
const n = parseVnd(text)
|
const n = parseVnd(raw)
|
||||||
if (n === 0 && text.trim() === '') return null
|
if (n === 0 && raw.trim() === '') return null
|
||||||
return allowNegative && neg ? -n : n
|
return allowNegative && isNeg ? -n : n
|
||||||
}
|
}
|
||||||
|
const parse = (): number | null => valueOf(text, neg)
|
||||||
const dirty = parse() !== initial
|
const dirty = parse() !== initial
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-end gap-1.5">
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
{allowNegative && (
|
{allowNegative && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNeg(v => !v)}
|
onClick={() => { const nv = !neg; setNeg(nv); onLiveChange?.(valueOf(text, nv)) }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-6 w-6 shrink-0 rounded border text-xs font-bold',
|
'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',
|
neg ? 'border-red-300 bg-red-50 text-red-600' : 'border-slate-300 text-slate-400',
|
||||||
@ -1017,7 +1026,10 @@ function VndInlineEdit({
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={text}
|
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"
|
placeholder="0"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="h-7 pr-6 font-mono text-right text-[13px]"
|
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 ?? '')
|
const [ccmNoteText, setCcmNoteText] = useState(bs?.ccmNote ?? '')
|
||||||
useEffect(() => { setCcmNoteText(bs?.ccmNote ?? '') }, [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.
|
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null.
|
||||||
if (!bs) {
|
if (!bs) {
|
||||||
return (
|
return (
|
||||||
@ -1249,12 +1269,12 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
|
const ccmHasData = bs.initialAmount != null || bs.adjustmentAmount != null
|
||||||
const row1 = bs.previousSubmittedTotal // Ngân sách trình duyệt trước
|
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 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 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 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 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 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 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)
|
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)
|
const cmpFull = full - row9 // So sánh với Ngân sách full (full − row9)
|
||||||
|
|
||||||
// Cờ tô màu cảnh báo
|
// Cờ tô màu cảnh báo
|
||||||
const proposalOver = bs.currentProposalTotal > (ev.budgetPeriodAmount ?? 0) && ev.budgetPeriodAmount != null
|
const proposalOver = bs.currentProposalTotal > (draftRow3 ?? 0) && draftRow3 != null
|
||||||
const remainingOver = ev.expectedRemainingAmount != null && ev.expectedRemainingAmount > row7
|
const remainingOver = draftRow8 != null && draftRow8 > row7
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border border-slate-300">
|
<div className="overflow-hidden rounded-lg border border-slate-300">
|
||||||
@ -1417,6 +1437,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
initial={ev.budgetPeriodAmount}
|
initial={ev.budgetPeriodAmount}
|
||||||
saving={adjustMut.isPending}
|
saving={adjustMut.isPending}
|
||||||
label="Ngân sách kỳ này"
|
label="Ngân sách kỳ này"
|
||||||
|
onLiveChange={setDraftRow3}
|
||||||
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
|
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
|
||||||
/>
|
/>
|
||||||
) : ev.budgetPeriodAmount != null ? fmtVnd(row3) : <span className="text-slate-400">—</span>
|
) : 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"
|
label="So sánh với ngân sách kỳ này"
|
||||||
indent
|
indent
|
||||||
sub="= 3 − 4"
|
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}
|
third={fmtPct(cmpPeriod, row3) ?? undefined}
|
||||||
danger={cmpPeriod < 0}
|
danger={cmpPeriod < 0}
|
||||||
/>
|
/>
|
||||||
@ -1471,7 +1492,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
label="So với NS"
|
label="So với NS"
|
||||||
indent
|
indent
|
||||||
sub="= 5 − 6"
|
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}
|
third={fmtPct(cmp56, row5) ?? undefined}
|
||||||
danger={cmp56 < 0}
|
danger={cmp56 < 0}
|
||||||
/>
|
/>
|
||||||
@ -1496,10 +1517,11 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
<div className="w-48 shrink-0 text-right">
|
<div className="w-48 shrink-0 text-right">
|
||||||
{drafterEditable ? (
|
{drafterEditable ? (
|
||||||
<VndInlineEdit
|
<VndInlineEdit
|
||||||
initial={ev.expectedRemainingAmount}
|
initial={ev.expectedRemainingAmount ?? row7}
|
||||||
allowNegative
|
allowNegative
|
||||||
saving={adjustMut.isPending}
|
saving={adjustMut.isPending}
|
||||||
label="Giá trị thực hiện dự kiến còn lại"
|
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 })}
|
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"
|
label="So sánh với Ngân sách full"
|
||||||
indent
|
indent
|
||||||
sub="= Ngân sách full − 9"
|
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}
|
third={fmtPct(cmpFull, full) ?? undefined}
|
||||||
danger={cmpFull < 0}
|
danger={cmpFull < 0}
|
||||||
/>
|
/>
|
||||||
@ -2383,7 +2405,7 @@ function HangMucCard({
|
|||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
})
|
})
|
||||||
const setWinner = useMutation({
|
const setWinner = useMutation({
|
||||||
mutationFn: async (supplierId: string) =>
|
mutationFn: async (supplierId: string | null) =>
|
||||||
api.post(`/purchase-evaluations/${ev.id}/select-winner`, { supplierId }),
|
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] }) },
|
onSuccess: () => { toast.success('Đã chọn đơn vị NCC/TP.'); qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) },
|
||||||
onError: e => toast.error(getErrorMessage(e)),
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
@ -2552,12 +2574,12 @@ function HangMucCard({
|
|||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
<div className="flex justify-end gap-0.5">
|
<div className="flex justify-end gap-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setWinner.mutate(s.supplierId)}
|
onClick={() => setWinner.mutate(isWinner ? null : s.supplierId)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-1 py-0.5',
|
'rounded px-1 py-0.5',
|
||||||
isWinner ? 'bg-emerald-100 text-emerald-700' : 'text-slate-400 hover:bg-emerald-50 hover:text-emerald-700',
|
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" />
|
<Check className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -270,14 +270,8 @@ export function PeWorkspaceCreateView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormRow
|
{/* [C1 anh Kiệt FDC] "c. Giá chào thầu" + "d. Bảng so sánh giá" ẨN khỏi form
|
||||||
label="c. Giá chào thầu"
|
TẠO (chưa dùng được lúc tạo) — vẫn hiện đầy đủ ở Detail tabs sau khi tạo phiế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." />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* e. Link hồ sơ (anh Kiệt FDC) — dán link thư mục hồ sơ trên NAS công ty
|
{/* 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ở. */}
|
(1 cột HoSoLink). Create = Input; khi xem phiếu render thẻ <a> bấm-mở. */}
|
||||||
|
|||||||
@ -345,7 +345,7 @@ public record AddSupplierBody(
|
|||||||
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
|
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
|
||||||
string? PaymentTermText, string? Note);
|
string? PaymentTermText, string? Note);
|
||||||
|
|
||||||
public record SelectWinnerBody(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(
|
public record DetailBody(
|
||||||
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,
|
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,
|
||||||
|
|||||||
@ -387,7 +387,9 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
|
|||||||
|
|
||||||
// ========== Select winner (NCC được chọn tổng thể) ==========
|
// ========== 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(
|
public class SelectPurchaseEvaluationWinnerCommandHandler(
|
||||||
IApplicationDbContext db,
|
IApplicationDbContext db,
|
||||||
@ -398,15 +400,27 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
|
|||||||
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
||||||
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||||
|
|
||||||
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
|
if (request.SupplierId is Guid supplierId)
|
||||||
?? throw new NotFoundException("Supplier", request.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
|
// Verify supplier nằm trong danh sách phiếu
|
||||||
var hasSupplier = await db.PurchaseEvaluationSuppliers
|
var hasSupplier = await db.PurchaseEvaluationSuppliers
|
||||||
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct);
|
.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á.");
|
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
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
{
|
{
|
||||||
@ -416,7 +430,7 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
|
|||||||
PhaseAtChange = entity.Phase,
|
PhaseAtChange = entity.Phase,
|
||||||
UserId = currentUser.UserId,
|
UserId = currentUser.UserId,
|
||||||
UserName = currentUser.FullName ?? currentUser.Email,
|
UserName = currentUser.FullName ?? currentUser.Email,
|
||||||
Summary = "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);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|||||||
@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user