[CLAUDE] PurchaseEvaluation: Mig 57 ghi chu gia de xuat PRO/CCM + so phan cach VND + sua chinh ta + guard #70
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m52s

Theo Tra Sol + anh Kiet FDC (Zalo): A) o nhap tien VndInlineEdit + BudgetCell nhay phan cach vi-VN (300.000.000) on-keystroke (o dialog da co san). B) them o ghi chu PRO + CCM canh nut Luu trong khoi Gia de xuat (giai thich vi sao chon Min/Max) — Mig 57 AddPeSuggestedPriceNotes (+ProSuggestedPriceNote +CcmSuggestedPriceNote nvarchar(1000) null, additive no-backfill no-table); 2 setter command +Note absolute-set rides role-gate PRO/CCM/Admin; DTO +2 field; controller body +note. C) sua chinh ta 'd. Ban so sanh' -> 'd. Bang so sanh gia' (2 app). GUARD gotcha #70: o gia + ghi chu echo nhau absolute-set -> them peFetching khoa nut Luu toi khi pe-detail refetch land, tranh stale-echo mat du lieu (mirror bang ngan sach S76; em-main review bat impl-frontend sot guard). BE slnx 0-warn 0-err; FE build PASS x2; test 344->351 (+7); PeDetailTabs/PeWorkspaceCreateView 2 app SHA256-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-19 14:08:45 +07:00
parent 3b98845976
commit 94e0e12f77
16 changed files with 6714 additions and 36 deletions

View File

@ -989,7 +989,7 @@ function VndInlineEdit({
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={text} value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))} onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
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]"
@ -1101,7 +1101,7 @@ function BudgetCell({ value, editable, allowNegative = false, saving, onSave }:
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={text} value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))} onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
placeholder="0" placeholder="0"
className="h-7 min-w-0 flex-1 px-1.5 text-right font-mono text-[12px]" className="h-7 min-w-0 flex-1 px-1.5 text-right font-mono text-[12px]"
/> />
@ -1512,16 +1512,17 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] }) qc.invalidateQueries({ queryKey: ['pe-list'] })
} }
// PRO Min/Max — ABSOLUTE SET cả cặp (mirror budget CCM dual-field: field không đổi // PRO Min/Max + note — ABSOLUTE SET cả 3 field (mirror S74 CcmNote: field không đổi
// echo giá trị hiện tại để không bị clear). // echo giá trị hiện tại để không bị clear). Lưu giá → echo note hiện tại; lưu note →
// echo min/max hiện tại.
const proPriceMut = useMutation({ const proPriceMut = useMutation({
mutationFn: async (body: { minPrice: number | null; maxPrice: number | null }) => mutationFn: async (body: { minPrice: number | null; maxPrice: number | null; note: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body), api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() }, onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const ccmPriceMut = useMutation({ const ccmPriceMut = useMutation({
mutationFn: async (body: { ccmPrice: number | null }) => mutationFn: async (body: { ccmPrice: number | null; note: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body), api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() }, onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
@ -1529,9 +1530,21 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
const canEditPro = !readOnly && ev.canEditProSuggestedPrice const canEditPro = !readOnly && ev.canEditProSuggestedPrice
const canEditCcm = !readOnly && ev.canEditCcmSuggestedPrice const canEditCcm = !readOnly && ev.canEditCcmSuggestedPrice
// Ghi chú PRO/CCM inline-edit state (Textarea). Echo cùng body absolute-set khi lưu giá.
const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '')
useEffect(() => { setProNoteText(ev.proSuggestedPriceNote ?? '') }, [ev.proSuggestedPriceNote])
const [ccmNoteText, setCcmNoteText] = useState(ev.ccmSuggestedPriceNote ?? '')
useEffect(() => { setCcmNoteText(ev.ccmSuggestedPriceNote ?? '') }, [ev.ccmSuggestedPriceNote])
// [gotcha #70] khoá nút Lưu tới khi pe-detail refetch land — lưu giá xong lưu
// ghi chú ngay (hoặc ngược lại) sẽ echo ev.* CŨ từ snapshot → mất dữ liệu.
// Mirror peFetching của bảng ngân sách (S76).
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
const hasAnyValue = ev.proSuggestedMinPrice != null const hasAnyValue = ev.proSuggestedMinPrice != null
|| ev.proSuggestedMaxPrice != null || ev.proSuggestedMaxPrice != null
|| ev.ccmSuggestedPrice != null || ev.ccmSuggestedPrice != null
|| !!ev.proSuggestedPriceNote
|| !!ev.ccmSuggestedPriceNote
const approved = ev.phase === PurchaseEvaluationPhase.DaDuyet && ev.approvedPriceAmount != null const approved = ev.phase === PurchaseEvaluationPhase.DaDuyet && ev.approvedPriceAmount != null
// Ẩn hoàn toàn khi không edit được + chưa có giá nào + chưa chốt → tránh khối rỗng. // Ẩn hoàn toàn khi không edit được + chưa có giá nào + chưa chốt → tránh khối rỗng.
@ -1552,9 +1565,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditPro ? ( {canEditPro ? (
<VndInlineEdit <VndInlineEdit
initial={ev.proSuggestedMinPrice} initial={ev.proSuggestedMinPrice}
saving={proPriceMut.isPending} saving={proPriceMut.isPending || peFetching}
label="Giá đề xuất PRO — Min" label="Giá đề xuất PRO — Min"
onSave={v => proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice })} onSave={v => proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice, note: ev.proSuggestedPriceNote })}
/> />
) : ( ) : (
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">
@ -1567,9 +1580,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditPro ? ( {canEditPro ? (
<VndInlineEdit <VndInlineEdit
initial={ev.proSuggestedMaxPrice} initial={ev.proSuggestedMaxPrice}
saving={proPriceMut.isPending} saving={proPriceMut.isPending || peFetching}
label="Giá đề xuất PRO — Max" label="Giá đề xuất PRO — Max"
onSave={v => proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v })} onSave={v => proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v, note: ev.proSuggestedPriceNote })}
/> />
) : ( ) : (
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">
@ -1580,6 +1593,42 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
</div> </div>
</div> </div>
{/* Ghi chú PRO — vì sao chọn Min/Max. Editable khi canEditPro (Textarea + nút Lưu),
read-only hiện text khi có note. Lưu qua proPriceMut (echo min/max hiện tại). */}
{(canEditPro || ev.proSuggestedPriceNote) && (
<div className="flex items-start gap-3 text-[12px]">
<span className="w-44 shrink-0 pt-1 text-slate-500">Ghi chú (PRO)</span>
<div className="min-w-0 flex-1">
{canEditPro ? (
<div className="space-y-1">
<textarea
value={proNoteText}
onChange={e => setProNoteText(e.target.value)}
placeholder="Ghi chú: vì sao chọn Min / Max…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => proPriceMut.mutate({
minPrice: ev.proSuggestedMinPrice,
maxPrice: ev.proSuggestedMaxPrice,
note: proNoteText.trim() || null,
})}
disabled={proNoteText === (ev.proSuggestedPriceNote ?? '') || proPriceMut.isPending || peFetching}
className="h-6 px-2 text-[11px]"
>
{proPriceMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-slate-700">{ev.proSuggestedPriceNote}</p>
)}
</div>
</div>
)}
{/* CCM — 1 giá */} {/* CCM — 1 giá */}
<div className="flex items-center gap-3 text-[12px]"> <div className="flex items-center gap-3 text-[12px]">
<span className="w-44 shrink-0 text-slate-500">Giá đ xuất (CCM)</span> <span className="w-44 shrink-0 text-slate-500">Giá đ xuất (CCM)</span>
@ -1587,9 +1636,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditCcm ? ( {canEditCcm ? (
<VndInlineEdit <VndInlineEdit
initial={ev.ccmSuggestedPrice} initial={ev.ccmSuggestedPrice}
saving={ccmPriceMut.isPending} saving={ccmPriceMut.isPending || peFetching}
label="Giá đề xuất CCM" label="Giá đề xuất CCM"
onSave={v => ccmPriceMut.mutate({ ccmPrice: v })} onSave={v => ccmPriceMut.mutate({ ccmPrice: v, note: ev.ccmSuggestedPriceNote })}
/> />
) : ( ) : (
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">
@ -1599,6 +1648,41 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
</div> </div>
</div> </div>
{/* Ghi chú CCM — vì sao 1 giá. Editable khi canEditCcm, read-only hiện text khi có note.
Lưu qua ccmPriceMut (echo ccmPrice hiện tại). */}
{(canEditCcm || ev.ccmSuggestedPriceNote) && (
<div className="flex items-start gap-3 text-[12px]">
<span className="w-44 shrink-0 pt-1 text-slate-500">Ghi chú (CCM)</span>
<div className="min-w-0 flex-1">
{canEditCcm ? (
<div className="space-y-1">
<textarea
value={ccmNoteText}
onChange={e => setCcmNoteText(e.target.value)}
placeholder="Ghi chú: vì sao chọn mức giá này…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => ccmPriceMut.mutate({
ccmPrice: ev.ccmSuggestedPrice,
note: ccmNoteText.trim() || null,
})}
disabled={ccmNoteText === (ev.ccmSuggestedPriceNote ?? '') || ccmPriceMut.isPending || peFetching}
className="h-6 px-2 text-[11px]"
>
{ccmPriceMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-slate-700">{ev.ccmSuggestedPriceNote}</p>
)}
</div>
</div>
)}
{/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */} {/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */}
{approved && ( {approved && (
<div className="flex items-center gap-3 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5 text-[12px]"> <div className="flex items-center gap-3 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5 text-[12px]">
@ -1658,7 +1742,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
<SuggestedPriceRows ev={ev} readOnly={readOnly} /> <SuggestedPriceRows ev={ev} readOnly={readOnly} />
<div> <div>
<div className="flex gap-3"> <div className="flex gap-3">
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span> <span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bảng so sánh giá</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<GeneralAttachmentsSection <GeneralAttachmentsSection
evaluationId={ev.id} evaluationId={ev.id}

View File

@ -275,7 +275,7 @@ export function PeWorkspaceCreateView({
value={<span className="text-slate-400"> (auto-tính từ báo giá NCC sau khi chọn winner)</span>} value={<span className="text-slate-400"> (auto-tính từ báo giá NCC sau khi chọn winner)</span>}
/> />
<FormRow <FormRow
label="d. Bản so sánh" label="d. Bảng so sánh giá"
value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />} value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />}
/> />

View File

@ -456,6 +456,10 @@ export type PeDetailBundle = {
proSuggestedMinPrice: number | null proSuggestedMinPrice: number | null
proSuggestedMaxPrice: number | null proSuggestedMaxPrice: number | null
ccmSuggestedPrice: number | null ccmSuggestedPrice: number | null
// Ghi chú giải thích vì sao chọn Min/Max (PRO) / vì sao 1 giá (CCM). Lưu kèm
// body /suggested-price/pro|ccm (absolute-set field `note`).
proSuggestedPriceNote: string | null
ccmSuggestedPriceNote: string | null
approvedPriceAmount: number | null approvedPriceAmount: number | null
approvedPriceSource: string | null approvedPriceSource: string | null
canEditProSuggestedPrice: boolean canEditProSuggestedPrice: boolean

View File

@ -989,7 +989,7 @@ function VndInlineEdit({
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={text} value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))} onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
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]"
@ -1101,7 +1101,7 @@ function BudgetCell({ value, editable, allowNegative = false, saving, onSave }:
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={text} value={text}
onChange={e => setText(e.target.value.replace(/[^\d.]/g, ''))} onChange={e => setText(formatVndInput(parseVnd(e.target.value)))}
placeholder="0" placeholder="0"
className="h-7 min-w-0 flex-1 px-1.5 text-right font-mono text-[12px]" className="h-7 min-w-0 flex-1 px-1.5 text-right font-mono text-[12px]"
/> />
@ -1512,16 +1512,17 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] }) qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] }) qc.invalidateQueries({ queryKey: ['pe-list'] })
} }
// PRO Min/Max — ABSOLUTE SET cả cặp (mirror budget CCM dual-field: field không đổi // PRO Min/Max + note — ABSOLUTE SET cả 3 field (mirror S74 CcmNote: field không đổi
// echo giá trị hiện tại để không bị clear). // echo giá trị hiện tại để không bị clear). Lưu giá → echo note hiện tại; lưu note →
// echo min/max hiện tại.
const proPriceMut = useMutation({ const proPriceMut = useMutation({
mutationFn: async (body: { minPrice: number | null; maxPrice: number | null }) => mutationFn: async (body: { minPrice: number | null; maxPrice: number | null; note: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body), api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() }, onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
const ccmPriceMut = useMutation({ const ccmPriceMut = useMutation({
mutationFn: async (body: { ccmPrice: number | null }) => mutationFn: async (body: { ccmPrice: number | null; note: string | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body), api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() }, onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
@ -1529,9 +1530,21 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
const canEditPro = !readOnly && ev.canEditProSuggestedPrice const canEditPro = !readOnly && ev.canEditProSuggestedPrice
const canEditCcm = !readOnly && ev.canEditCcmSuggestedPrice const canEditCcm = !readOnly && ev.canEditCcmSuggestedPrice
// Ghi chú PRO/CCM inline-edit state (Textarea). Echo cùng body absolute-set khi lưu giá.
const [proNoteText, setProNoteText] = useState(ev.proSuggestedPriceNote ?? '')
useEffect(() => { setProNoteText(ev.proSuggestedPriceNote ?? '') }, [ev.proSuggestedPriceNote])
const [ccmNoteText, setCcmNoteText] = useState(ev.ccmSuggestedPriceNote ?? '')
useEffect(() => { setCcmNoteText(ev.ccmSuggestedPriceNote ?? '') }, [ev.ccmSuggestedPriceNote])
// [gotcha #70] khoá nút Lưu tới khi pe-detail refetch land — lưu giá xong lưu
// ghi chú ngay (hoặc ngược lại) sẽ echo ev.* CŨ từ snapshot → mất dữ liệu.
// Mirror peFetching của bảng ngân sách (S76).
const peFetching = useIsFetching({ queryKey: ['pe-detail', ev.id] }) > 0
const hasAnyValue = ev.proSuggestedMinPrice != null const hasAnyValue = ev.proSuggestedMinPrice != null
|| ev.proSuggestedMaxPrice != null || ev.proSuggestedMaxPrice != null
|| ev.ccmSuggestedPrice != null || ev.ccmSuggestedPrice != null
|| !!ev.proSuggestedPriceNote
|| !!ev.ccmSuggestedPriceNote
const approved = ev.phase === PurchaseEvaluationPhase.DaDuyet && ev.approvedPriceAmount != null const approved = ev.phase === PurchaseEvaluationPhase.DaDuyet && ev.approvedPriceAmount != null
// Ẩn hoàn toàn khi không edit được + chưa có giá nào + chưa chốt → tránh khối rỗng. // Ẩn hoàn toàn khi không edit được + chưa có giá nào + chưa chốt → tránh khối rỗng.
@ -1552,9 +1565,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditPro ? ( {canEditPro ? (
<VndInlineEdit <VndInlineEdit
initial={ev.proSuggestedMinPrice} initial={ev.proSuggestedMinPrice}
saving={proPriceMut.isPending} saving={proPriceMut.isPending || peFetching}
label="Giá đề xuất PRO — Min" label="Giá đề xuất PRO — Min"
onSave={v => proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice })} onSave={v => proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice, note: ev.proSuggestedPriceNote })}
/> />
) : ( ) : (
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">
@ -1567,9 +1580,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditPro ? ( {canEditPro ? (
<VndInlineEdit <VndInlineEdit
initial={ev.proSuggestedMaxPrice} initial={ev.proSuggestedMaxPrice}
saving={proPriceMut.isPending} saving={proPriceMut.isPending || peFetching}
label="Giá đề xuất PRO — Max" label="Giá đề xuất PRO — Max"
onSave={v => proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v })} onSave={v => proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v, note: ev.proSuggestedPriceNote })}
/> />
) : ( ) : (
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">
@ -1580,6 +1593,42 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
</div> </div>
</div> </div>
{/* Ghi chú PRO — vì sao chọn Min/Max. Editable khi canEditPro (Textarea + nút Lưu),
read-only hiện text khi có note. Lưu qua proPriceMut (echo min/max hiện tại). */}
{(canEditPro || ev.proSuggestedPriceNote) && (
<div className="flex items-start gap-3 text-[12px]">
<span className="w-44 shrink-0 pt-1 text-slate-500">Ghi chú (PRO)</span>
<div className="min-w-0 flex-1">
{canEditPro ? (
<div className="space-y-1">
<textarea
value={proNoteText}
onChange={e => setProNoteText(e.target.value)}
placeholder="Ghi chú: vì sao chọn Min / Max…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => proPriceMut.mutate({
minPrice: ev.proSuggestedMinPrice,
maxPrice: ev.proSuggestedMaxPrice,
note: proNoteText.trim() || null,
})}
disabled={proNoteText === (ev.proSuggestedPriceNote ?? '') || proPriceMut.isPending || peFetching}
className="h-6 px-2 text-[11px]"
>
{proPriceMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-slate-700">{ev.proSuggestedPriceNote}</p>
)}
</div>
</div>
)}
{/* CCM — 1 giá */} {/* CCM — 1 giá */}
<div className="flex items-center gap-3 text-[12px]"> <div className="flex items-center gap-3 text-[12px]">
<span className="w-44 shrink-0 text-slate-500">Giá đ xuất (CCM)</span> <span className="w-44 shrink-0 text-slate-500">Giá đ xuất (CCM)</span>
@ -1587,9 +1636,9 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
{canEditCcm ? ( {canEditCcm ? (
<VndInlineEdit <VndInlineEdit
initial={ev.ccmSuggestedPrice} initial={ev.ccmSuggestedPrice}
saving={ccmPriceMut.isPending} saving={ccmPriceMut.isPending || peFetching}
label="Giá đề xuất CCM" label="Giá đề xuất CCM"
onSave={v => ccmPriceMut.mutate({ ccmPrice: v })} onSave={v => ccmPriceMut.mutate({ ccmPrice: v, note: ev.ccmSuggestedPriceNote })}
/> />
) : ( ) : (
<span className="font-semibold text-slate-800"> <span className="font-semibold text-slate-800">
@ -1599,6 +1648,41 @@ function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: bo
</div> </div>
</div> </div>
{/* Ghi chú CCM — vì sao 1 giá. Editable khi canEditCcm, read-only hiện text khi có note.
Lưu qua ccmPriceMut (echo ccmPrice hiện tại). */}
{(canEditCcm || ev.ccmSuggestedPriceNote) && (
<div className="flex items-start gap-3 text-[12px]">
<span className="w-44 shrink-0 pt-1 text-slate-500">Ghi chú (CCM)</span>
<div className="min-w-0 flex-1">
{canEditCcm ? (
<div className="space-y-1">
<textarea
value={ccmNoteText}
onChange={e => setCcmNoteText(e.target.value)}
placeholder="Ghi chú: vì sao chọn mức giá này…"
rows={2}
className="w-full rounded border border-slate-300 px-2 py-1 text-[12px]"
/>
<div className="flex justify-end">
<Button
onClick={() => ccmPriceMut.mutate({
ccmPrice: ev.ccmSuggestedPrice,
note: ccmNoteText.trim() || null,
})}
disabled={ccmNoteText === (ev.ccmSuggestedPriceNote ?? '') || ccmPriceMut.isPending || peFetching}
className="h-6 px-2 text-[11px]"
>
{ccmPriceMut.isPending ? '…' : 'Lưu ghi chú'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-slate-700">{ev.ccmSuggestedPriceNote}</p>
)}
</div>
</div>
)}
{/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */} {/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */}
{approved && ( {approved && (
<div className="flex items-center gap-3 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5 text-[12px]"> <div className="flex items-center gap-3 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5 text-[12px]">
@ -1658,7 +1742,7 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
<SuggestedPriceRows ev={ev} readOnly={readOnly} /> <SuggestedPriceRows ev={ev} readOnly={readOnly} />
<div> <div>
<div className="flex gap-3"> <div className="flex gap-3">
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span> <span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bảng so sánh giá</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<GeneralAttachmentsSection <GeneralAttachmentsSection
evaluationId={ev.id} evaluationId={ev.id}

View File

@ -275,7 +275,7 @@ export function PeWorkspaceCreateView({
value={<span className="text-slate-400"> (auto-tính từ báo giá NCC sau khi chọn winner)</span>} value={<span className="text-slate-400"> (auto-tính từ báo giá NCC sau khi chọn winner)</span>}
/> />
<FormRow <FormRow
label="d. Bản so sánh" label="d. Bảng so sánh giá"
value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />} value={<LockedHint text="Tải bảng so sánh sau khi tạo phiếu." />}
/> />

View File

@ -458,6 +458,10 @@ export type PeDetailBundle = {
proSuggestedMinPrice: number | null proSuggestedMinPrice: number | null
proSuggestedMaxPrice: number | null proSuggestedMaxPrice: number | null
ccmSuggestedPrice: number | null ccmSuggestedPrice: number | null
// Ghi chú giải thích vì sao chọn Min/Max (PRO) / vì sao 1 giá (CCM). Lưu kèm
// body /suggested-price/pro|ccm (absolute-set field `note`).
proSuggestedPriceNote: string | null
ccmSuggestedPriceNote: string | null
approvedPriceAmount: number | null approvedPriceAmount: number | null
approvedPriceSource: string | null approvedPriceSource: string | null
canEditProSuggestedPrice: boolean canEditProSuggestedPrice: boolean

View File

@ -102,18 +102,18 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
[HttpPut("{id:guid}/suggested-price/pro")] [HttpPut("{id:guid}/suggested-price/pro")]
public async Task<IActionResult> SetSuggestedPricePro(Guid id, [FromBody] SuggestedPriceProBody body, CancellationToken ct) public async Task<IActionResult> SetSuggestedPricePro(Guid id, [FromBody] SuggestedPriceProBody body, CancellationToken ct)
{ {
await mediator.Send(new UpdatePeSuggestedPriceProCommand(id, body.MinPrice, body.MaxPrice), ct); await mediator.Send(new UpdatePeSuggestedPriceProCommand(id, body.MinPrice, body.MaxPrice, body.Note), ct);
return NoContent(); return NoContent();
} }
public record SuggestedPriceProBody(decimal? MinPrice, decimal? MaxPrice); public record SuggestedPriceProBody(decimal? MinPrice, decimal? MaxPrice, string? Note);
[HttpPut("{id:guid}/suggested-price/ccm")] [HttpPut("{id:guid}/suggested-price/ccm")]
public async Task<IActionResult> SetSuggestedPriceCcm(Guid id, [FromBody] SuggestedPriceCcmBody body, CancellationToken ct) public async Task<IActionResult> SetSuggestedPriceCcm(Guid id, [FromBody] SuggestedPriceCcmBody body, CancellationToken ct)
{ {
await mediator.Send(new UpdatePeSuggestedPriceCcmCommand(id, body.CcmPrice), ct); await mediator.Send(new UpdatePeSuggestedPriceCcmCommand(id, body.CcmPrice, body.Note), ct);
return NoContent(); return NoContent();
} }
public record SuggestedPriceCcmBody(decimal? CcmPrice); public record SuggestedPriceCcmBody(decimal? CcmPrice, string? Note);
[HttpPost("{id:guid}/transitions")] [HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct) public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct)

View File

@ -257,6 +257,10 @@ public record PurchaseEvaluationDetailBundleDto(
decimal? ProSuggestedMinPrice, decimal? ProSuggestedMinPrice,
decimal? ProSuggestedMaxPrice, decimal? ProSuggestedMaxPrice,
decimal? CcmSuggestedPrice, decimal? CcmSuggestedPrice,
// [Mig 57 2026-06-19] Ghi chú giải thích giá đề xuất — vì sao min vs max (PRO) / 1 giá
// (CCM). FE render <Textarea> kế ô giá, set cùng endpoint suggested-price/{pro,ccm}.
string? ProSuggestedPriceNote,
string? CcmSuggestedPriceNote,
decimal? ApprovedPriceAmount, decimal? ApprovedPriceAmount,
string? ApprovedPriceSource, string? ApprovedPriceSource,
bool CanEditProSuggestedPrice, bool CanEditProSuggestedPrice,

View File

@ -25,7 +25,8 @@ namespace SolutionErp.Application.PurchaseEvaluations;
public record UpdatePeSuggestedPriceProCommand( public record UpdatePeSuggestedPriceProCommand(
Guid PeId, Guid PeId,
decimal? MinPrice, decimal? MinPrice,
decimal? MaxPrice) : IRequest; decimal? MaxPrice,
string? Note = null) : IRequest; // [Mig 57] ghi chú giải thích min vs max
public class UpdatePeSuggestedPriceProCommandValidator : AbstractValidator<UpdatePeSuggestedPriceProCommand> public class UpdatePeSuggestedPriceProCommandValidator : AbstractValidator<UpdatePeSuggestedPriceProCommand>
{ {
@ -37,6 +38,7 @@ public class UpdatePeSuggestedPriceProCommandValidator : AbstractValidator<Updat
RuleFor(x => x).Must(x => x.MinPrice!.Value <= x.MaxPrice!.Value) RuleFor(x => x).Must(x => x.MinPrice!.Value <= x.MaxPrice!.Value)
.When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue) .When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue)
.WithMessage("Giá Min phải ≤ Giá Max."); .WithMessage("Giá Min phải ≤ Giá Max.");
RuleFor(x => x.Note).MaximumLength(1000); // [Mig 57] MATCH EF HasMaxLength(1000)
} }
} }
@ -59,14 +61,18 @@ public class UpdatePeSuggestedPriceProCommandHandler(
var oldMin = pe.ProSuggestedMinPrice; var oldMin = pe.ProSuggestedMinPrice;
var oldMax = pe.ProSuggestedMaxPrice; var oldMax = pe.ProSuggestedMaxPrice;
var oldNote = pe.ProSuggestedPriceNote;
pe.ProSuggestedMinPrice = request.MinPrice; // absolute-set (null = clear) pe.ProSuggestedMinPrice = request.MinPrice; // absolute-set (null = clear)
pe.ProSuggestedMaxPrice = request.MaxPrice; pe.ProSuggestedMaxPrice = request.MaxPrice;
pe.ProSuggestedPriceNote = request.Note; // [Mig 57] absolute-set (null = clear)
var parts = new List<string>(); var parts = new List<string>();
if (oldMin != request.MinPrice) if (oldMin != request.MinPrice)
parts.Add($"giá Min {oldMin?.ToString("N0") ?? "(trống)"}đ → {request.MinPrice?.ToString("N0") ?? "(trống)"}đ"); parts.Add($"giá Min {oldMin?.ToString("N0") ?? "(trống)"}đ → {request.MinPrice?.ToString("N0") ?? "(trống)"}đ");
if (oldMax != request.MaxPrice) if (oldMax != request.MaxPrice)
parts.Add($"giá Max {oldMax?.ToString("N0") ?? "(trống)"}đ → {request.MaxPrice?.ToString("N0") ?? "(trống)"}đ"); parts.Add($"giá Max {oldMax?.ToString("N0") ?? "(trống)"}đ → {request.MaxPrice?.ToString("N0") ?? "(trống)"}đ");
if (oldNote != request.Note)
parts.Add("ghi chú giá");
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {
@ -87,13 +93,15 @@ public class UpdatePeSuggestedPriceProCommandHandler(
public record UpdatePeSuggestedPriceCcmCommand( public record UpdatePeSuggestedPriceCcmCommand(
Guid PeId, Guid PeId,
decimal? CcmPrice) : IRequest; decimal? CcmPrice,
string? Note = null) : IRequest; // [Mig 57] ghi chú giải thích giá CCM
public class UpdatePeSuggestedPriceCcmCommandValidator : AbstractValidator<UpdatePeSuggestedPriceCcmCommand> public class UpdatePeSuggestedPriceCcmCommandValidator : AbstractValidator<UpdatePeSuggestedPriceCcmCommand>
{ {
public UpdatePeSuggestedPriceCcmCommandValidator() public UpdatePeSuggestedPriceCcmCommandValidator()
{ {
RuleFor(x => x.CcmPrice).GreaterThanOrEqualTo(0).When(x => x.CcmPrice.HasValue); RuleFor(x => x.CcmPrice).GreaterThanOrEqualTo(0).When(x => x.CcmPrice.HasValue);
RuleFor(x => x.Note).MaximumLength(1000); // [Mig 57] MATCH EF HasMaxLength(1000)
} }
} }
@ -114,8 +122,11 @@ public class UpdatePeSuggestedPriceCcmCommandHandler(
} }
var oldCcm = pe.CcmSuggestedPrice; var oldCcm = pe.CcmSuggestedPrice;
var oldNote = pe.CcmSuggestedPriceNote;
pe.CcmSuggestedPrice = request.CcmPrice; // absolute-set (null = clear) pe.CcmSuggestedPrice = request.CcmPrice; // absolute-set (null = clear)
pe.CcmSuggestedPriceNote = request.Note; // [Mig 57] absolute-set (null = clear)
var noteChanged = oldNote != request.Note ? " (kèm ghi chú)" : "";
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{ {
PurchaseEvaluationId = pe.Id, PurchaseEvaluationId = pe.Id,
@ -124,7 +135,7 @@ public class UpdatePeSuggestedPriceCcmCommandHandler(
PhaseAtChange = pe.Phase, PhaseAtChange = pe.Phase,
UserId = currentUser.UserId, UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email, UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Giá đề xuất (CCM): {oldCcm?.ToString("N0") ?? "(trống)"}đ → {request.CcmPrice?.ToString("N0") ?? "(trống)"}đ", Summary = $"Giá đề xuất (CCM): {oldCcm?.ToString("N0") ?? "(trống)"}đ → {request.CcmPrice?.ToString("N0") ?? "(trống)"}đ{noteChanged}",
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@ -1098,6 +1098,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary, e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary,
e.IsUrgentByPro, e.IsUrgentByCcm, winnerQuoteTotal, awCeoThreshold, // [S69] cờ gấp + giá trị gói + ngưỡng CEO e.IsUrgentByPro, e.IsUrgentByCcm, winnerQuoteTotal, awCeoThreshold, // [S69] cờ gấp + giá trị gói + ngưỡng CEO
e.ProSuggestedMinPrice, e.ProSuggestedMaxPrice, e.CcmSuggestedPrice, // [Mig 54] giá đề xuất PRO/CCM e.ProSuggestedMinPrice, e.ProSuggestedMaxPrice, e.CcmSuggestedPrice, // [Mig 54] giá đề xuất PRO/CCM
e.ProSuggestedPriceNote, e.CcmSuggestedPriceNote, // [Mig 57] ghi chú giải thích giá
e.ApprovedPriceAmount, e.ApprovedPriceSource, // [Mig 54] giá chốt người duyệt chọn e.ApprovedPriceAmount, e.ApprovedPriceSource, // [Mig 54] giá chốt người duyệt chọn
canEditProSuggested, canEditCcmSuggested, // [Mig 54] capability role-gate canEditProSuggested, canEditCcmSuggested, // [Mig 54] capability role-gate
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions, e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,

View File

@ -74,6 +74,14 @@ public class PurchaseEvaluation : AuditableEntity
public decimal? ProSuggestedMaxPrice { get; set; } public decimal? ProSuggestedMaxPrice { get; set; }
public decimal? CcmSuggestedPrice { get; set; } public decimal? CcmSuggestedPrice { get; set; }
// [Mig 57 2026-06-19 — anh Kiệt FDC + Tra Sol] Ghi chú GIẢI THÍCH giá đề xuất — vì
// dải Min/Max của PRO (và 1 giá CCM) cần ghi rõ LÝ DO chọn min vs max để CEO duyệt
// có ngữ cảnh. Mirror ProSuggestedPriceNote ↔ PRO, CcmSuggestedPriceNote ↔ CCM. Set
// absolute (null=clear) trong cùng handler nhập giá, role-gate y như giá. Nullable,
// max 1000. KHÔNG entity con / bảng mới (mirror HoSoLink + CcmNote pattern).
public string? ProSuggestedPriceNote { get; set; }
public string? CcmSuggestedPriceNote { get; set; }
// [Mig 54] Giá CHỐT người duyệt cấp cuối chọn khi duyệt (① "duyệt theo giá đề xuất"). // [Mig 54] Giá CHỐT người duyệt cấp cuối chọn khi duyệt (① "duyệt theo giá đề xuất").
// Set tại MỌI nhánh DaDuyet của ApproveV2Async (terminal + CCM-delegation-finalize). // Set tại MỌI nhánh DaDuyet của ApproveV2Async (terminal + CCM-delegation-finalize).
// ApprovedPriceSource ∈ {Ncc, ProMin, ProMax, Ccm} = nguồn giá đã chọn; Amount = // ApprovedPriceSource ∈ {Ncc, ProMin, ProMax, Ccm} = nguồn giá đã chọn; Amount =

View File

@ -31,6 +31,10 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.Property(x => x.CcmSuggestedPrice).HasPrecision(18, 2); b.Property(x => x.CcmSuggestedPrice).HasPrecision(18, 2);
b.Property(x => x.ApprovedPriceAmount).HasPrecision(18, 2); b.Property(x => x.ApprovedPriceAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedPriceSource).HasMaxLength(20); b.Property(x => x.ApprovedPriceSource).HasMaxLength(20);
// [Mig 57 2026-06-19] Ghi chú giải thích giá đề xuất PRO/CCM — nvarchar(1000)
// nullable, KHÔNG index (free-text). Mirror HoSoLink + PeWorkItemBudget.CcmNote.
b.Property(x => x.ProSuggestedPriceNote).HasMaxLength(1000);
b.Property(x => x.CcmSuggestedPriceNote).HasMaxLength(1000);
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL"); b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted }); b.HasIndex(x => new { x.Phase, x.IsDeleted });

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeSuggestedPriceNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CcmSuggestedPriceNote",
table: "PurchaseEvaluations",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ProSuggestedPriceNote",
table: "PurchaseEvaluations",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CcmSuggestedPriceNote",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ProSuggestedPriceNote",
table: "PurchaseEvaluations");
}
}
}

View File

@ -4610,6 +4610,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<string>("CcmSuggestedPriceNote")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<Guid?>("ContractId") b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
@ -4680,6 +4684,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<string>("ProSuggestedPriceNote")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<Guid>("ProjectId") b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");

View File

@ -266,4 +266,171 @@ public class PeSuggestedPriceSetterAuthzTests
await act.Should().ThrowAsync<NotFoundException>(); await act.Should().ThrowAsync<NotFoundException>();
} }
// ====================================================================
// ===== NEW [Mig 57 2026-06-19] suggested-price NOTE (ProSuggestedPriceNote /
// CcmSuggestedPriceNote) — mirror PeWorkItemBudgetTests §4b CcmNote shape.
// - PRO/CCM command +Note (string?) absolute-set (overwrite always, null = clear).
// - Note rides the SAME role-gate as the price (PRO/Admin; CCM/Admin) fail-closed.
// - Validator: Note MaximumLength(1000).
// ====================================================================
// 11. PRO note set + persist — note rides alongside Min/Max (doesn't clobber price).
[Fact]
public async Task ProSetter_Procurement_SetsNote_AlongsideMinMax()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-001");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 100_000_000m, MaxPrice: 200_000_000m,
Note: "Min theo NCC A, Max theo NCC B (đã gồm VAT)"),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedPriceNote.Should().Be("Min theo NCC A, Max theo NCC B (đã gồm VAT)",
"PRO ghi chú giải thích Min vs Max");
reloaded.ProSuggestedMinPrice.Should().Be(100_000_000m, "note đi kèm KHÔNG đè giá Min");
reloaded.ProSuggestedMaxPrice.Should().Be(200_000_000m, "note đi kèm KHÔNG đè giá Max");
}
// 11b. Admin set PRO note (allow-list thứ 2 của PRO command).
[Fact]
public async Task ProSetter_Admin_SetsNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-001B");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 50_000_000m, MaxPrice: null, Note: "admin ghi chú giá PRO"),
CancellationToken.None);
(await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id))
.ProSuggestedPriceNote.Should().Be("admin ghi chú giá PRO", "Admin được ghi chú giá PRO");
}
// 12. CCM note set + persist — note rides alongside CcmPrice.
[Fact]
public async Task CcmSetter_CostControl_SetsNote_AlongsidePrice()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-002");
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 333_000_000m, Note: "giá CCM khuyến nghị cho CEO duyệt"),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPriceNote.Should().Be("giá CCM khuyến nghị cho CEO duyệt", "CCM ghi chú giá CCM");
reloaded.CcmSuggestedPrice.Should().Be(333_000_000m, "note đi kèm KHÔNG đè giá CCM");
}
// 13. Absolute-set null-clear — bắt đầu từ note CÓ SẴN rồi gửi Note=null → CLEAR
// (chứng minh absolute-set, KHÔNG skip-if-null giữ giá trị cũ). Cả PRO + CCM.
[Fact]
public async Task ProSetter_NullNote_ClearsExistingNote_AbsoluteSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-003");
// Pre-seed note có sẵn để chứng minh bị xoá (không phải vốn-dĩ-null).
pe.ProSuggestedPriceNote = "ghi chú PRO cũ cần xoá";
pe.ProSuggestedMinPrice = 10_000_000m;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 10_000_000m, MaxPrice: null, Note: null),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedPriceNote.Should().BeNull("absolute-set: Note=null CLEAR field, KHÔNG giữ ghi chú cũ");
}
[Fact]
public async Task CcmSetter_NullNote_ClearsExistingNote_AbsoluteSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-003C");
pe.CcmSuggestedPriceNote = "ghi chú CCM cũ cần xoá";
pe.CcmSuggestedPrice = 20_000_000m;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 20_000_000m, Note: null),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPriceNote.Should().BeNull("absolute-set: Note=null CLEAR field, KHÔNG giữ ghi chú cũ");
}
// 14. Non-privileged role → ForbiddenException + note (và giá) GIỮ NGUYÊN trong DB.
// Fail-closed: role-gate TRƯỚC mọi side-effect (NotFound → gate → mutate), nên PE
// tồn tại + role sai = Forbidden trước khi ghi → KHÔNG partial-write.
[Fact]
public async Task ProSetter_NonPrivilegedRole_WithNote_ThrowsForbidden_AndDoesNotMutate()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-004");
// Pre-seed note + giá để chứng minh KHÔNG bị mutate khi Forbidden.
pe.ProSuggestedPriceNote = "ghi chú PRO giữ nguyên";
pe.ProSuggestedMinPrice = 700_000_000m;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: 2m, Note: "không được ghi"),
CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ PRO | Admin được nhập giá đề xuất PRO + ghi chú");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedPriceNote.Should().Be("ghi chú PRO giữ nguyên",
"Forbidden TRƯỚC side-effect → note KHÔNG bị mutate");
reloaded.ProSuggestedMinPrice.Should().Be(700_000_000m, "giá cũng giữ nguyên (no partial-write)");
}
// 15. Độc lập PRO note ↔ CCM note — set CCM note KHÔNG đụng PRO note (và ngược lại
// ngầm định: 2 lệnh, 2 field riêng). Cũng kiểm Note MaximumLength(1000) ở validator.
[Fact]
public async Task ProAndCcmNotes_AreIndependent_AndValidatorEnforcesMaxLength()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SPN-005");
// Đặt PRO note trước (Admin set được cả 2 command).
await new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin))
.Handle(new UpdatePeSuggestedPriceProCommand(pe.Id, 1m, 2m, Note: "PRO note"), CancellationToken.None);
// Rồi đặt CCM note — phải KHÔNG làm mất PRO note.
await new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Admin))
.Handle(new UpdatePeSuggestedPriceCcmCommand(pe.Id, 3m, Note: "CCM note"), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedPriceNote.Should().Be("PRO note", "set CCM note KHÔNG đụng PRO note (field độc lập)");
reloaded.CcmSuggestedPriceNote.Should().Be("CCM note");
// Validator: Note > 1000 ký tự → invalid; đúng 1000 → valid.
var proValidator = new UpdatePeSuggestedPriceProCommandValidator();
proValidator.Validate(new UpdatePeSuggestedPriceProCommand(pe.Id, null, null, Note: new string('x', 1001)))
.IsValid.Should().BeFalse("Note > 1000 ký tự vi phạm MaximumLength(1000)");
proValidator.Validate(new UpdatePeSuggestedPriceProCommand(pe.Id, null, null, Note: new string('x', 1000)))
.IsValid.Should().BeTrue("Note đúng 1000 ký tự hợp lệ");
var ccmValidator = new UpdatePeSuggestedPriceCcmCommandValidator();
ccmValidator.Validate(new UpdatePeSuggestedPriceCcmCommand(pe.Id, null, Note: new string('x', 1001)))
.IsValid.Should().BeFalse("Note > 1000 ký tự vi phạm MaximumLength(1000)");
}
} }