[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>

View File

@ -40,6 +40,10 @@ export function PeWorkflowPanel({
// Mig 31 (S23 t1) — F2 Approver duyệt thẳng Cấp cuối. Default false (admin opt-in
// per slot tick → checkbox visible trong dialog Approve, default unchecked).
const [skipToFinalApprover, setSkipToFinalApprover] = useState(false)
// [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" + ① người duyệt
// cuối chọn 1 giá chốt (Ncc / ProMin / ProMax / Ccm).
const [finalizeByCcm, setFinalizeByCcm] = useState(false)
const [approvedPriceSource, setApprovedPriceSource] = useState<string | null>(null)
const qc = useQueryClient()
const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false
@ -81,6 +85,27 @@ export function PeWorkflowPanel({
const isV2Pending = !!evaluation.currentApproval
const blockedByV2Level = isV2Pending && !actorInV2Level
// [Mig 54] ③ CCM duyệt-done miễn CEO: chỉ đủ điều kiện khi user là CCM (CostControl) +
// workflow có ngưỡng CEO + giá gói < ngưỡng (khớp guard fail-closed BE). ① bộ chọn giá
// chốt hiện khi đây là duyệt CUỐI (Cấp cuối Bước cuối đang "Current") HOẶC CCM tích done.
const isCcm = currentUser?.roles?.includes('CostControl') ?? false
const ccmDelegationEligible = isCcm
&& evaluation.ceoApprovalThreshold != null
&& evaluation.winnerQuoteTotal < evaluation.ceoApprovalThreshold
const flowStepsForFinal = evaluation.approvalFlow?.steps ?? []
const lastFlowStep = flowStepsForFinal[flowStepsForFinal.length - 1]
const lastFlowLevel = lastFlowStep?.levels[lastFlowStep.levels.length - 1]
const currentIsFinalApprover = lastFlowLevel?.status === 'Current'
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
const priceCandidates = ([
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
{ source: 'ProMin', label: 'PRO — Giá Min', amount: evaluation.proSuggestedMinPrice },
{ source: 'ProMax', label: 'PRO — Giá Max', amount: evaluation.proSuggestedMaxPrice },
{ source: 'Ccm', label: 'CCM đề xuất', amount: evaluation.ccmSuggestedPrice },
] as { source: string; label: string; amount: number | null | undefined }[])
.filter((c): c is { source: string; label: string; amount: number } => c.amount != null)
const selectedPriceAmount = priceCandidates.find(c => c.source === approvedPriceSource)?.amount ?? null
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({
@ -110,6 +135,8 @@ export function PeWorkflowPanel({
// Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== PurchaseEvaluationPhase.TraLai
// [Mig 54] ① gửi giá chốt khi đây là duyệt cuối (CEO/NV cuối) HOẶC CCM tích done.
const sendPrice = !isReject && (currentIsFinalApprover || finalizeByCcm)
return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target,
decision: isReject ? 2 : 1,
@ -120,6 +147,10 @@ export function PeWorkflowPanel({
// Mig 31 (S23 t1) — F2 Approver scope ChoDuyet duyệt thẳng Cấp cuối.
// BE check matchingLevel.AllowApproverSkipToFinal (admin opt-in per slot).
skipToFinal: !isReject && skipToFinalApprover,
// [Mig 54 2026-06-18] ③ CCM duyệt done miễn CEO + ① giá chốt người duyệt chọn.
finalizeByCcmDelegation: !isReject && finalizeByCcm,
approvedPriceAmount: sendPrice ? selectedPriceAmount : null,
approvedPriceSource: sendPrice ? approvedPriceSource : null,
})
},
onSuccess: () => {
@ -132,6 +163,8 @@ export function PeWorkflowPanel({
setReturnMode(WorkflowReturnMode.Drafter)
setReturnTargetUserId(null)
setSkipToFinalApprover(false)
setFinalizeByCcm(false)
setApprovedPriceSource(null)
},
onError: e => toast.error(getErrorMessage(e)),
})
@ -322,6 +355,10 @@ export function PeWorkflowPanel({
: isSendBack
? '← Trả lại Drafter sửa'
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}`
// [Mig 54] ① bộ chọn giá chốt khi duyệt CUỐI hoặc CCM tích done — bắt buộc chọn.
const isApproveAction = !isCancel && !isSendBack
const shouldPickPrice = isApproveAction && (currentIsFinalApprover || finalizeByCcm)
const priceMissing = shouldPickPrice && priceCandidates.length > 0 && !approvedPriceSource
return (
<Dialog
open
@ -329,7 +366,7 @@ export function PeWorkflowPanel({
title={dialogTitle}
footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending}>Xác nhận</Button>
<Button onClick={() => transition.mutate()} disabled={transition.isPending || priceMissing}>Xác nhận</Button>
</>}
>
{isCancel && (
@ -455,6 +492,53 @@ export function PeWorkflowPanel({
vẫn phải duyệt thật đ phiếu thành "Đã duyệt".
</div>
)}
{/* [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done, miễn CEO" —
chỉ hiện khi user là CCM + gói < ngưỡng CEO uỷ quyền. */}
{isApproveAction && ccmDelegationEligible && (
<div className="mb-3">
<label className="flex cursor-pointer items-start gap-2 rounded border border-emerald-300 bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800 hover:bg-emerald-100/60">
<input
type="checkbox"
className="mt-0.5"
checked={finalizeByCcm}
onChange={e => setFinalizeByCcm(e.target.checked)}
/>
<span>
<span className="font-medium">Duyệt done, miễn CEO (CEO uỷ quyền cho TP CCM)</span>
<span className="mt-0.5 block text-[11px] text-emerald-700/80">
Gói {fmtMoney(evaluation.winnerQuoteTotal)}đ &lt; ngưỡng CEO {fmtMoney(evaluation.ceoApprovalThreshold ?? 0)}đ tích đ hoàn tất ngay, không cần trình CEO.
</span>
</span>
</label>
</div>
)}
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
{shouldPickPrice && (
<div className="mb-3 space-y-1.5">
<Label className="text-[12px]">
Chọn giá chốt (duyệt theo giá đ xuất) <span className="text-red-500">*</span>
</Label>
<div className="space-y-1">
{priceCandidates.map(c => (
<label key={c.source} className="flex items-center gap-2 rounded border border-brand-200 bg-white px-2 py-1.5 text-[12px] hover:bg-brand-50/40">
<input
type="radio"
checked={approvedPriceSource === c.source}
onChange={() => setApprovedPriceSource(c.source)}
/>
<span className="flex-1 text-slate-700">{c.label}</span>
<span className="font-mono font-semibold text-slate-900">{fmtMoney(c.amount)}đ</span>
</label>
))}
{priceCandidates.length === 0 && (
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
Chưa giá nào đ chọn nhập giá đ xuất (PRO/CCM) hoặc chọn NCC thắng thầu trước khi duyệt cuối.
</div>
)}
</div>
</div>
)}
<Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog>
@ -557,3 +641,7 @@ function fmtTime(iso: string): string {
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function fmtMoney(n: number): string {
return n.toLocaleString('vi-VN')
}

View File

@ -444,6 +444,16 @@ export type PeDetailBundle = {
// S69 — ngưỡng gói CEO của workflow đã pin (PE.approvalWorkflowId). Null khi
// chưa pin workflow V2 hoặc admin chưa set ngưỡng.
ceoApprovalThreshold: number | null
// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại "c. Giá chào thầu" (NGOÀI giá NCC):
// PRO nhập dải Min/Max + CCM 1 giá. approvedPrice* = giá CHỐT người duyệt cuối chọn
// (source ∈ Ncc/ProMin/ProMax/Ccm). canEdit* = capability theo role (BE-computed).
proSuggestedMinPrice: number | null
proSuggestedMaxPrice: number | null
ccmSuggestedPrice: number | null
approvedPriceAmount: number | null
approvedPriceSource: string | null
canEditProSuggestedPrice: boolean
canEditCcmSuggestedPrice: boolean
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
approvalWorkflowId: string | null
approvalWorkflowCode: string | null