[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
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:
@ -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>
|
||||
|
||||
@ -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 ký 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)}đ < 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 có 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')
|
||||
}
|
||||
|
||||
|
||||
@ -446,6 +446,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
|
||||
|
||||
Reference in New Issue
Block a user