[CLAUDE] PurchaseEvaluation: Mig 54 giá đề xuất PRO/CCM + CEO chọn giá chốt + CCM duyệt-done ô-tích
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s

Theo note anh Kiệt FDC (go-live so-sánh-giá thứ Hai):
- (1) Giá chào thầu thêm giá đề xuất NGOÀI giá NCC: PRO nhập dải Min/Max +
  CCM nhập 1 giá (2 lệnh role-gate Procurement/CostControl, fail-closed).
  Khi duyệt cấp cuối, người duyệt CHỌN 1 giá chốt (Ncc/ProMin/ProMax/Ccm)
  -> luu ApprovedPriceAmount/Source (bind tai moi nhanh DaDuyet, bat buoc
  chon; auto-approve he thong mien).
- (3) CCM duyet-done mien CEO: DOI tu AUTO-threshold (S69) sang O-TICH-TAY
  (finalizeByCcmDelegation) -- CCM chu dong tich, fail-closed theo nguong
  + role + gia goi. An toan hon (khong vo tinh bo CEO).
- Mig 54 additive-nullable (5 cot PE) - FE 2 app SHA-mirror - test 306->334
  (+28: opt-in 6->11, +10 gia chot, +13 setter authz).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-18 15:51:39 +07:00
parent 77ad219361
commit 1d86abcdc5
20 changed files with 7931 additions and 101 deletions

View File

@ -1380,6 +1380,128 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
)
}
// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại khối "c. Giá chào thầu".
// PRO nhập dải Min/Max (PUT /suggested-price/pro {minPrice,maxPrice}); CCM nhập 1
// giá (PUT /suggested-price/ccm {ccmPrice}). Role-gate qua canEditPro/CcmSuggestedPrice
// (BE-computed capability — mirror budget PRO/CCM, KHÔNG ràng phase). Read-only khi
// !canEdit → hiện text giá. Khi DaDuyet + approvedPriceAmount → dòng "Giá chốt duyệt".
const APPROVED_PRICE_SOURCE_LABEL: Record<string, string> = {
Ncc: 'Giá NCC',
ProMin: 'PRO Min',
ProMax: 'PRO Max',
Ccm: 'CCM',
}
function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
const qc = useQueryClient()
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
qc.invalidateQueries({ queryKey: ['pe-list'] })
}
// PRO Min/Max — ABSOLUTE SET cả cặp (mirror budget CCM dual-field: field không đổi
// echo giá trị hiện tại để không bị clear).
const proPriceMut = useMutation({
mutationFn: async (body: { minPrice: number | null; maxPrice: number | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
const ccmPriceMut = useMutation({
mutationFn: async (body: { ccmPrice: number | null }) =>
api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body),
onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() },
onError: e => toast.error(getErrorMessage(e)),
})
const canEditPro = !readOnly && ev.canEditProSuggestedPrice
const canEditCcm = !readOnly && ev.canEditCcmSuggestedPrice
const hasAnyValue = ev.proSuggestedMinPrice != null
|| ev.proSuggestedMaxPrice != null
|| ev.ccmSuggestedPrice != 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.
if (!canEditPro && !canEditCcm && !hasAnyValue && !approved) return null
return (
<div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50/60 px-3 py-2.5">
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
Giá đ xuất (ngoài giá chào thầu)
</div>
{/* PRO — Giá Min / Giá Max */}
<div className="flex items-start gap-3 text-[12px]">
<span className="w-44 shrink-0 pt-1 text-slate-500">Giá đ xuất (PRO)</span>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-[11px] text-slate-500">Min</span>
{canEditPro ? (
<VndInlineEdit
initial={ev.proSuggestedMinPrice}
saving={proPriceMut.isPending}
label="Giá đề xuất PRO — Min"
onSave={v => proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice })}
/>
) : (
<span className="font-semibold text-slate-800">
{ev.proSuggestedMinPrice != null ? fmtVnd(ev.proSuggestedMinPrice) : <span className="font-normal text-slate-400"></span>}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="w-12 shrink-0 text-[11px] text-slate-500">Max</span>
{canEditPro ? (
<VndInlineEdit
initial={ev.proSuggestedMaxPrice}
saving={proPriceMut.isPending}
label="Giá đề xuất PRO — Max"
onSave={v => proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v })}
/>
) : (
<span className="font-semibold text-slate-800">
{ev.proSuggestedMaxPrice != null ? fmtVnd(ev.proSuggestedMaxPrice) : <span className="font-normal text-slate-400"></span>}
</span>
)}
</div>
</div>
</div>
{/* CCM — 1 giá */}
<div className="flex items-center gap-3 text-[12px]">
<span className="w-44 shrink-0 text-slate-500">Giá đ xuất (CCM)</span>
<div className="min-w-0 flex-1">
{canEditCcm ? (
<VndInlineEdit
initial={ev.ccmSuggestedPrice}
saving={ccmPriceMut.isPending}
label="Giá đề xuất CCM"
onSave={v => ccmPriceMut.mutate({ ccmPrice: v })}
/>
) : (
<span className="font-semibold text-slate-800">
{ev.ccmSuggestedPrice != null ? fmtVnd(ev.ccmSuggestedPrice) : <span className="font-normal text-slate-400"></span>}
</span>
)}
</div>
</div>
{/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */}
{approved && (
<div className="flex items-center gap-3 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5 text-[12px]">
<span className="w-44 shrink-0 font-medium text-emerald-800">Giá chốt duyệt</span>
<span className="min-w-0 flex-1 font-semibold text-emerald-900">
{fmtVnd(ev.approvedPriceAmount!)}
{ev.approvedPriceSource && (
<span className="ml-2 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
{APPROVED_PRICE_SOURCE_LABEL[ev.approvedPriceSource] ?? ev.approvedPriceSource}
</span>
)}
</span>
</div>
)}
</div>
)
}
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId
@ -1415,6 +1537,10 @@ function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly
)
}
/>
{/* [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất PRO (Min/Max) + CCM —
NGOÀI giá NCC. Role-gate qua canEditPro/CcmSuggestedPrice (mirror budget,
KHÔNG ràng phase). Khi DaDuyet + approvedPriceAmount → show giá chốt + nguồn. */}
<SuggestedPriceRows ev={ev} readOnly={readOnly} />
<div>
<div className="flex gap-3">
<span className="w-44 shrink-0 text-[12px] text-slate-500">d. Bản so sánh</span>