[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) ===== // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId 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>
<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ả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 // 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). // per slot tick → checkbox visible trong dialog Approve, default unchecked).
const [skipToFinalApprover, setSkipToFinalApprover] = useState(false) 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 qc = useQueryClient()
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false const isAdmin = currentUser?.roles?.includes('Admin') ?? false
@ -81,6 +85,27 @@ export function PeWorkflowPanel({
const isV2Pending = !!evaluation.currentApproval const isV2Pending = !!evaluation.currentApproval
const blockedByV2Level = isV2Pending && !actorInV2Level 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 // 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB). // hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({ 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 // Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== 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`, { return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target, targetPhase: target,
decision: isReject ? 2 : 1, 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. // 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). // BE check matchingLevel.AllowApproverSkipToFinal (admin opt-in per slot).
skipToFinal: !isReject && skipToFinalApprover, 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: () => { onSuccess: () => {
@ -132,6 +163,8 @@ export function PeWorkflowPanel({
setReturnMode(WorkflowReturnMode.Drafter) setReturnMode(WorkflowReturnMode.Drafter)
setReturnTargetUserId(null) setReturnTargetUserId(null)
setSkipToFinalApprover(false) setSkipToFinalApprover(false)
setFinalizeByCcm(false)
setApprovedPriceSource(null)
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
@ -322,6 +355,10 @@ export function PeWorkflowPanel({
: isSendBack : isSendBack
? '← Trả lại Drafter sửa' ? '← Trả lại Drafter sửa'
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` : `✓ 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 ( return (
<Dialog <Dialog
open open
@ -329,7 +366,7 @@ export function PeWorkflowPanel({
title={dialogTitle} title={dialogTitle}
footer={<> footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button> <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 && ( {isCancel && (
@ -455,6 +492,53 @@ export function PeWorkflowPanel({
vẫn phải duyệt thật đ phiếu thành "Đã duyệt". vẫn phải duyệt thật đ phiếu thành "Đã duyệt".
</div> </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> <Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} /> <Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog> </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' }) 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 // 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. // chưa pin workflow V2 hoặc admin chưa set ngưỡng.
ceoApprovalThreshold: number | null 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). // Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
approvalWorkflowId: string | null approvalWorkflowId: string | null
approvalWorkflowCode: string | null approvalWorkflowCode: string | null

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) ===== // ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) { function ChonNccSection({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boolean }) {
const canCreateContract = !readOnly && ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId 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>
<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ả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 // 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). // per slot tick → checkbox visible trong dialog Approve, default unchecked).
const [skipToFinalApprover, setSkipToFinalApprover] = useState(false) 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 qc = useQueryClient()
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
const isAdmin = currentUser?.roles?.includes('Admin') ?? false const isAdmin = currentUser?.roles?.includes('Admin') ?? false
@ -81,6 +85,27 @@ export function PeWorkflowPanel({
const isV2Pending = !!evaluation.currentApproval const isV2Pending = !!evaluation.currentApproval
const blockedByV2Level = isV2Pending && !actorInV2Level 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 // 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB). // hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
const { data: deptApprovals = [] } = useQuery<PeDepartmentApproval[]>({ 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 // Mig 28 (S21 t4) — F1: chỉ gửi returnMode khi target=TraLai + mode != null
const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai const isTraLaiAction = target === PurchaseEvaluationPhase.TraLai
&& evaluation.phase !== 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`, { return api.post(`/purchase-evaluations/${evaluation.id}/transitions`, {
targetPhase: target, targetPhase: target,
decision: isReject ? 2 : 1, 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. // 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). // BE check matchingLevel.AllowApproverSkipToFinal (admin opt-in per slot).
skipToFinal: !isReject && skipToFinalApprover, 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: () => { onSuccess: () => {
@ -132,6 +163,8 @@ export function PeWorkflowPanel({
setReturnMode(WorkflowReturnMode.Drafter) setReturnMode(WorkflowReturnMode.Drafter)
setReturnTargetUserId(null) setReturnTargetUserId(null)
setSkipToFinalApprover(false) setSkipToFinalApprover(false)
setFinalizeByCcm(false)
setApprovedPriceSource(null)
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })
@ -322,6 +355,10 @@ export function PeWorkflowPanel({
: isSendBack : isSendBack
? '← Trả lại Drafter sửa' ? '← Trả lại Drafter sửa'
: `✓ Duyệt → ${PurchaseEvaluationPhaseLabel[target]}` : `✓ 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 ( return (
<Dialog <Dialog
open open
@ -329,7 +366,7 @@ export function PeWorkflowPanel({
title={dialogTitle} title={dialogTitle}
footer={<> footer={<>
<Button variant="ghost" onClick={() => setTarget(null)}>Hủy</Button> <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 && ( {isCancel && (
@ -455,6 +492,53 @@ export function PeWorkflowPanel({
vẫn phải duyệt thật đ phiếu thành "Đã duyệt". vẫn phải duyệt thật đ phiếu thành "Đã duyệt".
</div> </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> <Label>Ghi chú (tùy chọn)</Label>
<Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} /> <Textarea value={comment} onChange={e => setComment(e.target.value)} rows={3} />
</Dialog> </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' }) 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

@ -446,6 +446,16 @@ export type PeDetailBundle = {
// S69 — ngưỡng gói CEO của workflow đã pin (PE.approvalWorkflowId). Null khi // 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. // chưa pin workflow V2 hoặc admin chưa set ngưỡng.
ceoApprovalThreshold: number | null 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). // Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
approvalWorkflowId: string | null approvalWorkflowId: string | null
approvalWorkflowCode: string | null approvalWorkflowCode: string | null

View File

@ -96,6 +96,25 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
} }
public record SetUrgentBody(bool IsUrgent); public record SetUrgentBody(bool IsUrgent);
// [Mig 54 2026-06-18 — anh Kiệt FDC] Nhập GIÁ ĐỀ XUẤT tại "c. Giá chào thầu" theo
// role. Class [Authorize] any-auth; handler fine-grained Forbidden (PRO=Procurement
// Min/Max, CCM=CostControl 1 giá, Admin cả 2). Set per-phiếu. Absolute-set (null=clear).
[HttpPut("{id:guid}/suggested-price/pro")]
public async Task<IActionResult> SetSuggestedPricePro(Guid id, [FromBody] SuggestedPriceProBody body, CancellationToken ct)
{
await mediator.Send(new UpdatePeSuggestedPriceProCommand(id, body.MinPrice, body.MaxPrice), ct);
return NoContent();
}
public record SuggestedPriceProBody(decimal? MinPrice, decimal? MaxPrice);
[HttpPut("{id:guid}/suggested-price/ccm")]
public async Task<IActionResult> SetSuggestedPriceCcm(Guid id, [FromBody] SuggestedPriceCcmBody body, CancellationToken ct)
{
await mediator.Send(new UpdatePeSuggestedPriceCcmCommand(id, body.CcmPrice), ct);
return NoContent();
}
public record SuggestedPriceCcmBody(decimal? CcmPrice);
[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)
{ {
@ -106,7 +125,8 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
// Root cause bro UAT 2026-05-15 "Trả lại Người chỉ định fail". // Root cause bro UAT 2026-05-15 "Trả lại Người chỉ định fail".
await mediator.Send(new TransitionPurchaseEvaluationCommand( await mediator.Send(new TransitionPurchaseEvaluationCommand(
id, body.TargetPhase, body.Decision, body.Comment, id, body.TargetPhase, body.Decision, body.Comment,
body.ReturnMode, body.ReturnTargetUserId, body.SkipToFinal), ct); body.ReturnMode, body.ReturnTargetUserId, body.SkipToFinal,
body.FinalizeByCcmDelegation, body.ApprovedPriceAmount, body.ApprovedPriceSource), ct);
return NoContent(); return NoContent();
} }
@ -314,7 +334,11 @@ public record TransitionPeBody(
string? Comment, string? Comment,
WorkflowReturnMode? ReturnMode = null, // F1 mode Trả lại WorkflowReturnMode? ReturnMode = null, // F1 mode Trả lại
Guid? ReturnTargetUserId = null, // F1 Assignee target Guid? ReturnTargetUserId = null, // F1 Assignee target
bool SkipToFinal = false); // F2 duyệt thẳng Cấp cuối bool SkipToFinal = false, // F2 duyệt thẳng Cấp cuối
// [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM duyệt done miễn CEO + ① giá chốt.
bool FinalizeByCcmDelegation = false,
decimal? ApprovedPriceAmount = null,
string? ApprovedPriceSource = null);
public record AddSupplierBody( public record AddSupplierBody(
Guid SupplierId, Guid SupplierId,

View File

@ -245,6 +245,18 @@ public record PurchaseEvaluationDetailBundleDto(
// [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi // [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi
// phiếu chưa pin workflow V2 hoặc admin chưa set ngưỡng. // phiếu chưa pin workflow V2 hoặc admin chưa set ngưỡng.
decimal? CeoApprovalThreshold, decimal? CeoApprovalThreshold,
// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại "c. Giá chào thầu" — NGOÀI giá
// NCC (WinnerQuoteTotal). PRO nhập dải Min/Max; CCM nhập 1 giá. ApprovedPrice* = giá
// CHỐT người duyệt cuối chọn (source ∈ Ncc/ProMin/ProMax/Ccm). CanEdit* = capability
// theo role (mirror PeBudgetSummary). FE tự suy "đủ điều kiện CCM duyệt-done" + "là
// người duyệt cuối" từ WinnerQuoteTotal / CeoApprovalThreshold / roles / ApprovalFlow.
decimal? ProSuggestedMinPrice,
decimal? ProSuggestedMaxPrice,
decimal? CcmSuggestedPrice,
decimal? ApprovedPriceAmount,
string? ApprovedPriceSource,
bool CanEditProSuggestedPrice,
bool CanEditCcmSuggestedPrice,
// Mig 23 — schema mới ApprovalWorkflowsV2 pin lúc create. Hiển thị Code + // Mig 23 — schema mới ApprovalWorkflowsV2 pin lúc create. Hiển thị Code +
// Name + Version để FE show "QT-DN-V2-001 - Quy trình Duyệt NCC (v01)". // Name + Version để FE show "QT-DN-V2-001 - Quy trình Duyệt NCC (v01)".
Guid? ApprovalWorkflowId, Guid? ApprovalWorkflowId,

View File

@ -0,0 +1,132 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
namespace SolutionErp.Application.PurchaseEvaluations;
// [Mig 54 2026-06-18 — anh Kiệt FDC] 2 handler nhập GIÁ ĐỀ XUẤT tại mục "c. Giá chào
// thầu" theo ROLE — NGOÀI giá NCC báo lên (WinnerQuoteTotal computed):
// - PRO (Procurement | Admin): ProSuggestedMinPrice + ProSuggestedMaxPrice (dải giá;
// chỉ 1 trong 2 = hiểu là giá chốt đó).
// - CCM (CostControl | Admin): CcmSuggestedPrice (1 giá để CEO nhìn + duyệt theo).
// Authz mirror UpdatePeBudgetPro/Ccm (S61): controller [Authorize] any-auth, handler
// ForbiddenException fail-closed TRƯỚC mọi side-effect (S56 #5). KHÔNG ràng Phase
// (mirror ngân sách — chỉnh được như tài liệu sống; trade-off ghi nhận). Set per-PHIẾU
// trực tiếp trên PurchaseEvaluation (KHÔNG per-cặp như ngân sách — giá chào thầu là của
// phiếu, không dùng chung mọi phiếu cùng Hạng mục).
// ===== PRO — dải giá đề xuất Min/Max =====
public record UpdatePeSuggestedPriceProCommand(
Guid PeId,
decimal? MinPrice,
decimal? MaxPrice) : IRequest;
public class UpdatePeSuggestedPriceProCommandValidator : AbstractValidator<UpdatePeSuggestedPriceProCommand>
{
public UpdatePeSuggestedPriceProCommandValidator()
{
RuleFor(x => x.MinPrice).GreaterThanOrEqualTo(0).When(x => x.MinPrice.HasValue);
RuleFor(x => x.MaxPrice).GreaterThanOrEqualTo(0).When(x => x.MaxPrice.HasValue);
// Cả 2 có → Min ≤ Max. Chỉ 1 trong 2 = giá chốt đó (không ràng).
RuleFor(x => x).Must(x => x.MinPrice!.Value <= x.MaxPrice!.Value)
.When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue)
.WithMessage("Giá Min phải ≤ Giá Max.");
}
}
public class UpdatePeSuggestedPriceProCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePeSuggestedPriceProCommand>
{
public async Task Handle(UpdatePeSuggestedPriceProCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PeId);
// Fail-closed TRƯỚC mọi side-effect.
if (!currentUser.Roles.Contains(AppRoles.Admin)
&& !currentUser.Roles.Contains(AppRoles.Procurement))
{
throw new ForbiddenException(
"Chỉ Phòng Cung ứng (PRO) hoặc Admin được nhập giá đề xuất PRO (Min/Max).");
}
var oldMin = pe.ProSuggestedMinPrice;
var oldMax = pe.ProSuggestedMaxPrice;
pe.ProSuggestedMinPrice = request.MinPrice; // absolute-set (null = clear)
pe.ProSuggestedMaxPrice = request.MaxPrice;
var parts = new List<string>();
if (oldMin != request.MinPrice)
parts.Add($"giá Min {oldMin?.ToString("N0") ?? "(trống)"}đ → {request.MinPrice?.ToString("N0") ?? "(trống)"}đ");
if (oldMax != request.MaxPrice)
parts.Add($"giá Max {oldMax?.ToString("N0") ?? "(trống)"}đ → {request.MaxPrice?.ToString("N0") ?? "(trống)"}đ");
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Giá đề xuất (PRO): {(parts.Count == 0 ? "không đi" : string.Join(", ", parts))}",
});
await db.SaveChangesAsync(ct);
}
}
// ===== CCM — 1 giá đề xuất (để CEO nhìn + duyệt theo) =====
public record UpdatePeSuggestedPriceCcmCommand(
Guid PeId,
decimal? CcmPrice) : IRequest;
public class UpdatePeSuggestedPriceCcmCommandValidator : AbstractValidator<UpdatePeSuggestedPriceCcmCommand>
{
public UpdatePeSuggestedPriceCcmCommandValidator()
{
RuleFor(x => x.CcmPrice).GreaterThanOrEqualTo(0).When(x => x.CcmPrice.HasValue);
}
}
public class UpdatePeSuggestedPriceCcmCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePeSuggestedPriceCcmCommand>
{
public async Task Handle(UpdatePeSuggestedPriceCcmCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PeId);
if (!currentUser.Roles.Contains(AppRoles.Admin)
&& !currentUser.Roles.Contains(AppRoles.CostControl))
{
throw new ForbiddenException(
"Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập giá đề xuất CCM.");
}
var oldCcm = pe.CcmSuggestedPrice;
pe.CcmSuggestedPrice = request.CcmPrice; // absolute-set (null = clear)
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"Giá đề xuất (CCM): {oldCcm?.ToString("N0") ?? "(trống)"}đ → {request.CcmPrice?.ToString("N0") ?? "(trống)"}đ",
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -457,7 +457,12 @@ public record TransitionPurchaseEvaluationCommand(
Guid? ReturnTargetUserId = null, Guid? ReturnTargetUserId = null,
// F2 — Approver skip thẳng Cấp cuối lúc duyệt ChoDuyet (Mig 31 admin opt-in // F2 — Approver skip thẳng Cấp cuối lúc duyệt ChoDuyet (Mig 31 admin opt-in
// per slot, AllowApproverSkipToFinal). Default false. // per slot, AllowApproverSkipToFinal). Default false.
bool SkipToFinal = false) : IRequest; bool SkipToFinal = false,
// [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" + ① giá CHỐT
// người duyệt cuối chọn (amount + source ∈ Ncc/ProMin/ProMax/Ccm).
bool FinalizeByCcmDelegation = false,
decimal? ApprovedPriceAmount = null,
string? ApprovedPriceSource = null) : IRequest;
public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand> public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
{ {
@ -472,6 +477,15 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<Tr
RuleFor(x => x.ReturnTargetUserId).NotEmpty() RuleFor(x => x.ReturnTargetUserId).NotEmpty()
.When(x => x.ReturnMode == WorkflowReturnMode.Assignee) .When(x => x.ReturnMode == WorkflowReturnMode.Assignee)
.WithMessage("ReturnTargetUserId yêu cầu khi mode=Assignee."); .WithMessage("ReturnTargetUserId yêu cầu khi mode=Assignee.");
// [Mig 54] Giá chốt ≥ 0; nguồn ∈ {Ncc,ProMin,ProMax,Ccm}. Quy tắc "bắt-buộc-
// chọn-khi-duyệt-cuối" enforce ở service (ApplyApprovedPriceOnFinalize — chỉ nó
// biết nhánh DaDuyet); validator chỉ chặn giá trị rác.
RuleFor(x => x.ApprovedPriceAmount).GreaterThanOrEqualTo(0)
.When(x => x.ApprovedPriceAmount.HasValue);
RuleFor(x => x.ApprovedPriceSource)
.Must(s => s is "Ncc" or "ProMin" or "ProMax" or "Ccm")
.When(x => x.ApprovedPriceSource is not null)
.WithMessage("Nguồn giá chốt phải là Ncc/ProMin/ProMax/Ccm.");
} }
} }
@ -498,6 +512,9 @@ public class TransitionPurchaseEvaluationCommandHandler(
request.ReturnMode, request.ReturnMode,
request.ReturnTargetUserId, request.ReturnTargetUserId,
request.SkipToFinal, request.SkipToFinal,
request.FinalizeByCcmDelegation,
request.ApprovedPriceAmount,
request.ApprovedPriceSource,
ct); ct);
} }
} }
@ -1050,6 +1067,10 @@ public class GetPurchaseEvaluationQueryHandler(
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId)) .Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
.Sum(q => q.ThanhTien); .Sum(q => q.ThanhTien);
// [Mig 54] Capability nhập giá đề xuất theo role (mirror PeBudgetSummary canEdit).
var canEditProSuggested = isAdmin || currentUser.Roles.Contains(AppRoles.Procurement);
var canEditCcmSuggested = isAdmin || currentUser.Roles.Contains(AppRoles.CostControl);
return new PurchaseEvaluationDetailBundleDto( return new PurchaseEvaluationDetailBundleDto(
e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa, e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
e.HoSoLink, // [HoSoLink] hyperlink thư mục hồ sơ NAS e.HoSoLink, // [HoSoLink] hyperlink thư mục hồ sơ NAS
@ -1062,6 +1083,9 @@ public class GetPurchaseEvaluationQueryHandler(
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt, e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
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.ApprovedPriceAmount, e.ApprovedPriceSource, // [Mig 54] giá chốt người duyệt chọn
canEditProSuggested, canEditCcmSuggested, // [Mig 54] capability role-gate
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions, e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
currentApproval, approvalFlow, currentApproval, approvalFlow,
e.Suppliers e.Suppliers

View File

@ -27,6 +27,11 @@ public interface IPurchaseEvaluationWorkflowService
WorkflowReturnMode? returnMode = null, WorkflowReturnMode? returnMode = null,
Guid? returnTargetUserId = null, Guid? returnTargetUserId = null,
bool skipToFinal = false, bool skipToFinal = false,
// [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" +
// ① giá CHỐT người duyệt cuối chọn (amount + source ∈ Ncc/ProMin/ProMax/Ccm).
bool finalizeByCcmDelegation = false,
decimal? approvedPriceAmount = null,
string? approvedPriceSource = null,
CancellationToken ct = default); CancellationToken ct = default);
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase); TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase);

View File

@ -65,6 +65,22 @@ public class PurchaseEvaluation : AuditableEntity
public bool IsUrgentByPro { get; set; } public bool IsUrgentByPro { get; set; }
public bool IsUrgentByCcm { get; set; } public bool IsUrgentByCcm { get; set; }
// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại mục "c. Giá chào thầu" — NGOÀI
// giá NCC báo lên (WinnerQuoteTotal = SUM báo giá của NCC được chọn, computed). PRO
// (role Procurement) nhập dải Min/Max — nếu chỉ 1 trong 2 → hiểu là giá chốt đó. CCM
// (role CostControl) nhập 1 giá đề xuất để CEO nhìn + duyệt theo. Role-gate qua
// UpdatePeSuggestedPrice{Pro,Ccm}Command (mirror ngân sách PRO/CCM Mig 50).
public decimal? ProSuggestedMinPrice { get; set; }
public decimal? ProSuggestedMaxPrice { get; set; }
public decimal? CcmSuggestedPrice { 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").
// 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 =
// snapshot giá trị lúc duyệt. Null cho phiếu duyệt trước Mig 54 hoặc luồng V1 legacy.
public decimal? ApprovedPriceAmount { get; set; }
public string? ApprovedPriceSource { get; set; }
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new(); public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
public List<PurchaseEvaluationDetail> Details { get; set; } = new(); public List<PurchaseEvaluationDetail> Details { get; set; } = new();
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new(); public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();

View File

@ -24,6 +24,13 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
// [S61 Mig 50] 2 cột ngân sách mới thay BudgetManual* — precision giữ (18,2). // [S61 Mig 50] 2 cột ngân sách mới thay BudgetManual* — precision giữ (18,2).
b.Property(x => x.BudgetPeriodAmount).HasPrecision(18, 2); b.Property(x => x.BudgetPeriodAmount).HasPrecision(18, 2);
b.Property(x => x.ExpectedRemainingAmount).HasPrecision(18, 2); b.Property(x => x.ExpectedRemainingAmount).HasPrecision(18, 2);
// [Mig 54 2026-06-18] Giá đề xuất PRO/CCM + giá chốt khi duyệt — precision (18,2)
// mirror các cột tiền khác. ApprovedPriceSource nvarchar(20): Ncc/ProMin/ProMax/Ccm.
b.Property(x => x.ProSuggestedMinPrice).HasPrecision(18, 2);
b.Property(x => x.ProSuggestedMaxPrice).HasPrecision(18, 2);
b.Property(x => x.CcmSuggestedPrice).HasPrecision(18, 2);
b.Property(x => x.ApprovedPriceAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedPriceSource).HasMaxLength(20);
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,77 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeSuggestedAndApprovedPrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "ApprovedPriceAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ApprovedPriceSource",
table: "PurchaseEvaluations",
type: "nvarchar(20)",
maxLength: 20,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CcmSuggestedPrice",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ProSuggestedMaxPrice",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ProSuggestedMinPrice",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApprovedPriceAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedPriceSource",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "CcmSuggestedPrice",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ProSuggestedMaxPrice",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ProSuggestedMinPrice",
table: "PurchaseEvaluations");
}
}
}

View File

@ -4582,10 +4582,22 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("ApprovalWorkflowId") b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
b.Property<decimal?>("ApprovedPriceAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ApprovedPriceSource")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<decimal?>("BudgetPeriodAmount") b.Property<decimal?>("BudgetPeriodAmount")
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<decimal?>("CcmSuggestedPrice")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<Guid?>("ContractId") b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");
@ -4648,6 +4660,14 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<int>("Phase") b.Property<int>("Phase")
.HasColumnType("int"); .HasColumnType("int");
b.Property<decimal?>("ProSuggestedMaxPrice")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("ProSuggestedMinPrice")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<Guid>("ProjectId") b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier"); .HasColumnType("uniqueidentifier");

View File

@ -44,6 +44,9 @@ public class PurchaseEvaluationWorkflowService(
WorkflowReturnMode? returnMode = null, WorkflowReturnMode? returnMode = null,
Guid? returnTargetUserId = null, Guid? returnTargetUserId = null,
bool skipToFinal = false, bool skipToFinal = false,
bool finalizeByCcmDelegation = false,
decimal? approvedPriceAmount = null,
string? approvedPriceSource = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var fromPhase = evaluation.Phase; var fromPhase = evaluation.Phase;
@ -245,7 +248,7 @@ public class PurchaseEvaluationWorkflowService(
// V2 path nhận flag, V1 legacy throw nếu non-admin gọi skipToFinal=true. // V2 path nhận flag, V1 legacy throw nếu non-admin gọi skipToFinal=true.
if (evaluation.ApprovalWorkflowId is Guid awId) if (evaluation.ApprovalWorkflowId is Guid awId)
{ {
await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, ct); await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, ct);
} }
else else
{ {
@ -664,6 +667,9 @@ public class PurchaseEvaluationWorkflowService(
bool isSystem, bool isSystem,
string? comment, string? comment,
bool skipToFinal, bool skipToFinal,
bool finalizeByCcmDelegation,
decimal? approvedPriceAmount,
string? approvedPriceSource,
CancellationToken ct) CancellationToken ct)
{ {
var aw = await db.ApprovalWorkflows.AsNoTracking() var aw = await db.ApprovalWorkflows.AsNoTracking()
@ -813,18 +819,23 @@ public class PurchaseEvaluationWorkflowService(
} }
} }
// [S69 2026-06-17] CCM duyệt-final theo NGƯỠNG GIÁ TRỊ (anh Kiệt FDC). Khi NV // [Mig 54 2026-06-18 — anh Kiệt FDC] CCM duyệt-DONE miễn CEO — ĐỔI TỪ AUTO (S69)
// duyệt có role CostControl (CCM) + quy trình set CeoApprovalThreshold + giá trị // SANG Ô-TÍCH-TAY (③). Trước S69: gói < ngưỡng + CCM duyệt = tự DaDuyet (im lặng).
// gói (tổng giá NCC được chọn, winnerQuoteTotal) < ngưỡng → DaDuyet luôn, BỎ các // NAY: CCM phải CHỦ ĐỘNG tích "Duyệt done, miễn CEO" (finalizeByCcmDelegation) thì
// Bước/Cấp còn lại (CEO). Q4 chốt: nhận diện theo ROLE người duyệt. Ngưỡng null = // mới done — an toàn hơn, KHÔNG vô tình bỏ CEO. Điều kiện uỷ-quyền (fail-closed):
// bỏ qua (luồng tuyến tính cũ — rollout an toàn). Cờ gấp KHÔNG ảnh hưởng routing // workflow đặt CeoApprovalThreshold + actor role CostControl + giá gói
// (visibility-only, Q3). Guard "chưa ở slot cuối" → chỉ skip-forward (nếu CCM đã // (winnerQuoteTotal = SUM báo giá NCC được chọn) < ngưỡng. Tích mà KHÔNG đủ điều
// ở Cấp cuối Bước cuối thì normal-advance bên dưới cũng ra DaDuyet). Giả định quy // kiện → Conflict/Forbidden. KHÔNG tích → bỏ block, advance bình thường lên CEO.
// trình đặt CCM ngay trước CEO — UAT anh Kiệt xác nhận cấu trúc. // Opinion + approval row đã ghi ở trên → finalize giữ đủ vết. ① giá chốt: ApplyApprovedPriceOnFinalize.
if (aw.CeoApprovalThreshold is decimal ceoThreshold if (finalizeByCcmDelegation)
&& actorRoles.Contains(AppRoles.CostControl)
&& !(currentIdx == steps.Count - 1 && currentLevelOrder == maxLevelOrder))
{ {
if (aw.CeoApprovalThreshold is not decimal ceoThreshold)
throw new ConflictException(
"Quy trình chưa đặt 'Ngưỡng giá trị gói CEO' — không thể duyệt done miễn CEO.");
if (!actorRoles.Contains(AppRoles.CostControl))
throw new ForbiddenException(
"Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO.");
var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking() var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id .Where(s => s.PurchaseEvaluationId == evaluation.Id
&& s.SupplierId == evaluation.SelectedSupplierId) && s.SupplierId == evaluation.SelectedSupplierId)
@ -835,22 +846,24 @@ public class PurchaseEvaluationWorkflowService(
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId)) .Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m; .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
if (winnerQuoteTotal < ceoThreshold) if (winnerQuoteTotal >= ceoThreshold)
{ throw new ConflictException(
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet; $"Giá gói {winnerQuoteTotal:N0}đ ≥ ngưỡng CEO {ceoThreshold:N0}đ — phải trình CEO duyệt, không được duyệt done miễn CEO.");
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null; ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource);
evaluation.SlaDeadline = null; evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
await LogTransitionAsync( evaluation.CurrentWorkflowStepIndex = null;
evaluation, evaluation.CurrentApprovalLevelOrder = null;
PurchaseEvaluationPhase.ChoDuyet, evaluation.SlaDeadline = null;
PurchaseEvaluationPhase.DaDuyet, await LogTransitionAsync(
actorUserId, evaluation,
ApprovalDecision.Approve, PurchaseEvaluationPhase.ChoDuyet,
$"[CCM duyệt cuối — gói {winnerQuoteTotal:N0}đ < ngưỡng CEO {ceoThreshold:N0}đ, không cần CEO duyệt] {comment ?? ""}".Trim(), PurchaseEvaluationPhase.DaDuyet,
ct); actorUserId,
return; ApprovalDecision.Approve,
} $"[CCM duyệt done miễn CEO — gói {winnerQuoteTotal:N0}đ < ngưỡng CEO {ceoThreshold:N0}đ] {comment ?? ""}".Trim(),
ct);
return;
} }
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1 // Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
@ -867,7 +880,9 @@ public class PurchaseEvaluationWorkflowService(
var nextIdx = currentIdx + 1; var nextIdx = currentIdx + 1;
if (nextIdx >= steps.Count) if (nextIdx >= steps.Count)
{ {
// All Steps done — terminal DaDuyet // All Steps done — terminal DaDuyet. [Mig 54 ①] người duyệt cấp cuối (CEO/NV
// cuối) chọn giá chốt khi duyệt — bind trước khi sang DaDuyet.
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource);
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet; evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null; evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null; evaluation.CurrentApprovalLevelOrder = null;
@ -885,6 +900,28 @@ public class PurchaseEvaluationWorkflowService(
} }
} }
// [Mig 54 2026-06-18 — anh Kiệt FDC] Gán giá CHỐT người duyệt chọn (①) tại nhánh
// DaDuyet. Người duyệt THẬT bắt buộc chọn 1 giá (Conflict nếu thiếu) → đúng ý "CEO
// phải chọn 1 giá làm giá chốt"; auto-approve hệ thống (SLA, isSystem) MIỄN — không
// có người để chọn. Source ∈ {Ncc,ProMin,ProMax,Ccm}; amount = snapshot lúc duyệt.
private static readonly string[] ValidApprovedPriceSources = { "Ncc", "ProMin", "ProMax", "Ccm" };
private static void ApplyApprovedPriceOnFinalize(
PurchaseEvaluation evaluation, bool isSystem, decimal? amount, string? source)
{
if (amount is null)
{
if (!isSystem)
throw new ConflictException(
"Chọn 1 giá chốt (NCC / PRO Min / PRO Max / CCM) trước khi duyệt cấp cuối.");
return; // auto-approve hệ thống (SLA) — không bắt chọn giá
}
if (source is null || !ValidApprovedPriceSources.Contains(source))
throw new ConflictException(
"Nguồn giá chốt không hợp lệ (phải là NCC / PRO Min / PRO Max / CCM).");
evaluation.ApprovedPriceAmount = amount;
evaluation.ApprovedPriceSource = source;
}
// ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps ===== // ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps =====
private async Task ApproveV1LegacyAsync( private async Task ApproveV1LegacyAsync(
PurchaseEvaluation evaluation, PurchaseEvaluation evaluation,

View File

@ -0,0 +1,269 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// ===== NEW (Mig 54 2026-06-18 anh Kiệt FDC) — ③ 2 setter giá-đề-xuất role-gate =====
// PeSuggestedPriceFeatures.cs: UpdatePeSuggestedPricePro/CcmCommand(+Handler+Validator).
// Test theo CODE đã land (S34 rule — KHÔNG touch production). Mirror harness
// PeWorkItemGuardTests (validator plain .Validate() API + handler 2-dep nhẹ
// db+ICurrentUser instantiate trực tiếp).
//
// Authz (mirror UpdatePeBudgetPro/Ccm S61):
// - PRO command: role Procurement HOẶC Admin set ProSuggestedMin/Max; role khác →
// ForbiddenException (fail-closed TRƯỚC mọi side-effect — guard sau NotFound check).
// - CCM command: role CostControl HOẶC Admin set CcmSuggestedPrice; role khác → Forbidden.
// - Validator PRO: Min/Max >= 0; cả 2 có → Min <= Max (invalid nếu Min > Max).
// - Validator CCM: CcmPrice >= 0.
//
// ⚠️ Handler check PE-existence (NotFound) TRƯỚC authz gate (PeSuggestedPriceFeatures
// line 49-50 rồi 53). Nên unknown-PE → NotFound bất kể role; Forbidden cần PE tồn tại.
public class PeSuggestedPriceSetterAuthzTests
{
private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
{
public Guid? UserId { get; } = Guid.NewGuid();
public string? Email { get; } = "actor@test.local";
public string? FullName { get; } = "Actor Test";
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
public bool IsAuthenticated => UserId is not null;
}
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db, string code = "PE-SP-001")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Gói thầu test giá đề xuất",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// ====================================================================
// ===== PRO setter (ProSuggestedMinPrice / ProSuggestedMaxPrice) =====
// ====================================================================
// 1. Procurement set được Min/Max.
[Fact]
public async Task ProSetter_Procurement_SetsMinMax()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db);
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 100_000_000m, MaxPrice: 200_000_000m),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedMinPrice.Should().Be(100_000_000m);
reloaded.ProSuggestedMaxPrice.Should().Be(200_000_000m);
}
// 2. Admin set được Min/Max (allow-list thứ 2).
[Fact]
public async Task ProSetter_Admin_SetsMinMax()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-002");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 50_000_000m, MaxPrice: null),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedMinPrice.Should().Be(50_000_000m);
reloaded.ProSuggestedMaxPrice.Should().BeNull("chỉ 1 trong 2 = giá chốt đó, Max để trống OK");
}
// 3. Role khác (CostControl) → ForbiddenException + KHÔNG set giá (fail-closed).
[Fact]
public async Task ProSetter_CostControl_ThrowsForbidden_NoSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-003");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: 2m),
CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*PRO*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedMinPrice.Should().BeNull("CCM bị chặn → không set giá PRO");
reloaded.ProSuggestedMaxPrice.Should().BeNull();
}
// 3b. Role thường khác (Drafter) → cũng Forbidden (chắc chắn không chỉ CCM bị chặn).
[Fact]
public async Task ProSetter_Drafter_ThrowsForbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-003B");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Drafter));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: null),
CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>();
}
// 4. Validator PRO: Min > Max → invalid (rule WithMessage "Giá Min phải ≤ Giá Max").
[Fact]
public void ProValidator_MinGreaterThanMax_Invalid()
{
var validator = new UpdatePeSuggestedPriceProCommandValidator();
var result = validator.Validate(
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), MinPrice: 500m, MaxPrice: 100m));
result.IsValid.Should().BeFalse("Min > Max vi phạm rule");
result.Errors.Should().Contain(e => e.ErrorMessage.Contains("Min phải ≤"));
}
// 4b. Validator PRO: Min <= Max → valid. Chỉ 1 trong 2 (Max null) → cũng valid
// (rule Min<=Max chỉ When cả 2 HasValue).
[Fact]
public void ProValidator_MinLeMax_AndSingleValue_Valid()
{
var validator = new UpdatePeSuggestedPriceProCommandValidator();
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 100m, 500m))
.IsValid.Should().BeTrue("Min <= Max hợp lệ");
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 100m, null))
.IsValid.Should().BeTrue("chỉ Min (Max null) hợp lệ — không ràng Min<=Max");
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), null, null))
.IsValid.Should().BeTrue("cả 2 null (clear) hợp lệ");
}
// 4c. Validator PRO: giá trị âm → invalid (GreaterThanOrEqualTo(0)).
[Fact]
public void ProValidator_NegativeValue_Invalid()
{
var validator = new UpdatePeSuggestedPriceProCommandValidator();
var result = validator.Validate(
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), MinPrice: -1m, MaxPrice: null));
result.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
}
// 5. PRO handler unknown PE → NotFound (existence check TRƯỚC authz).
[Fact]
public async Task ProSetter_UnknownPe_ThrowsNotFound()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 1m, 2m), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
// ====================================================================
// ===== CCM setter (CcmSuggestedPrice) =====
// ====================================================================
// 6. CostControl set được CcmSuggestedPrice.
[Fact]
public async Task CcmSetter_CostControl_SetsPrice()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-006");
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 333_000_000m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPrice.Should().Be(333_000_000m);
}
// 7. Admin set được CcmSuggestedPrice.
[Fact]
public async Task CcmSetter_Admin_SetsPrice()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-007");
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 1m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPrice.Should().Be(1m);
}
// 8. Role khác (Procurement) → ForbiddenException + KHÔNG set giá.
[Fact]
public async Task CcmSetter_Procurement_ThrowsForbidden_NoSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-008");
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 9m), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*CCM*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPrice.Should().BeNull("PRO bị chặn → không set giá CCM");
}
// 9. Validator CCM: giá âm → invalid.
[Fact]
public void CcmValidator_NegativeValue_Invalid()
{
var validator = new UpdatePeSuggestedPriceCcmCommandValidator();
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: -5m))
.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: 0m))
.IsValid.Should().BeTrue("0 hợp lệ");
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: null))
.IsValid.Should().BeTrue("null (clear) hợp lệ");
}
// 10. CCM handler unknown PE → NotFound.
[Fact]
public async Task CcmSetter_UnknownPe_ThrowsNotFound()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), 1m), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@ -0,0 +1,296 @@
using System.Reflection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Services;
// ===== NEW (Mig 54 2026-06-18 anh Kiệt FDC) — ① GIÁ CHỐT ApplyApprovedPriceOnFinalize =====
// PurchaseEvaluationWorkflowService.ApplyApprovedPriceOnFinalize (line 908-923 prod) +
// 2 call-site nhánh DaDuyet trong ApproveV2Async: (a) terminal normal advance (line 885)
// và (b) CCM-delegation finalize (line 853 — cover ở PeCcmThresholdFinalizeTests).
// Test theo CODE đã land (S34 rule — KHÔNG touch production).
//
// Contract ApplyApprovedPriceOnFinalize(evaluation, isSystem, amount, source):
// - amount null + !isSystem → ConflictException ("Chọn 1 giá chốt...")
// - amount null + isSystem → return im lặng (auto-approve hệ thống MIỄN chọn giá)
// - source null/rác (∉ {Ncc,ProMin,ProMax,Ccm}) → ConflictException
// - hợp lệ → set evaluation.ApprovedPriceAmount/Source
//
// ⚠️ OBSERVATION (REPORT em main, KHÔNG fix): isSystem KHÔNG reachable qua public
// ApproveV2Async — branch APPROVE STEP (TransitionAsync line 243) gate
// `decision == Approve`, trong khi isSystem cần `decision == AutoApprove`. PE KHÔNG có
// SLA-auto-job gọi TransitionAsync (chỉ Contract SlaExpiryJob). Nên nhánh isSystem-
// exempt trong PE là defensive/dead qua đường V2-approve. Vẫn test ĐƯỢC contract của nó
// ở mức UNIT qua reflection (private static) — verify hành vi tài liệu hoá đúng. Phần
// reachable (human approver) test qua integration TransitionAsync bên dưới.
//
// Mirror harness PeCcmThresholdFinalizeTests cùng folder.
public class PeApprovedPriceFinalizeTests
{
private const string ValidSource = "Ncc";
private const decimal ValidAmount = 750_000_000m;
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
TestApplicationDbContext db, FixedDateTime clock) CreateService()
{
var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var clock = new FixedDateTime(new DateTime(2026, 6, 18, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
return (svc, fix, db, clock);
}
// Workflow V2 1 bước / 1 cấp = 1 approver (CEO/last). Approve cấp đó = terminal
// DaDuyet qua normal advance (ApproveV2Async line 881 nextIdx>=steps.Count).
private static async Task<ApprovalWorkflow> SeedSingleApproverWorkflowAsync(
TestApplicationDbContext db, Guid approverId)
{
var wf = new ApprovalWorkflow
{
Code = "QT-AP-V2",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
Name = "QT test approved-price",
IsActive = true,
IsUserSelectable = true,
};
var step = new ApprovalWorkflowStep
{
ApprovalWorkflowId = wf.Id,
Order = 1,
Name = "Bước 1",
};
step.Levels.Add(new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = 1,
Name = "Cấp 1",
ApproverUserId = approverId,
});
wf.Steps.Add(step);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static PurchaseEvaluation BuildPeAtLastSlot(Guid awId, string code)
=> new()
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Test giá chốt",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
ApprovalWorkflowId = awId,
CurrentWorkflowStepIndex = 0,
CurrentApprovalLevelOrder = 1,
SlaDeadline = new DateTime(2026, 6, 25, 0, 0, 0, DateTimeKind.Utc),
};
private static Task ApproveAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
string[] roles, decimal? amount, string? source) =>
svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
actorUserId: actorUserId,
actorRoles: roles,
decision: ApprovalDecision.Approve,
comment: null,
approvedPriceAmount: amount,
approvedPriceSource: source,
ct: CancellationToken.None);
// =====================================================================
// 1. Duyệt cấp cuối (terminal DaDuyet) HUMAN truyền giá hợp lệ → set đúng
// evaluation.ApprovedPriceAmount/Source. (reachable integration path)
// =====================================================================
[Fact]
public async Task ApproveV2_TerminalApprove_ValidPrice_SetsApprovedPriceAndSource()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ceo = (await fix.CreateUserAsync("ceoAP1@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-001");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
amount: ValidAmount, source: ValidSource);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "1 bước/1 cấp duyệt → terminal");
pe.ApprovedPriceAmount.Should().Be(ValidAmount, "giá chốt người duyệt chọn được bind");
pe.ApprovedPriceSource.Should().Be(ValidSource);
}
}
// =====================================================================
// 2. Duyệt cấp cuối HUMAN KHÔNG truyền giá (null) → ConflictException
// ("Chọn 1 giá chốt..."). Phiếu KHÔNG đổi sang DaDuyet (throw trước set Phase).
// =====================================================================
[Fact]
public async Task ApproveV2_TerminalApprove_NullPrice_Human_ThrowsConflict_NotFinalized()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ceo = (await fix.CreateUserAsync("ceoAP2@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-002");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
amount: null, source: null);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*giá chốt*");
// ApplyApprovedPriceOnFinalize chạy TRƯỚC set Phase=DaDuyet (line 885-886)
// → throw = phiếu giữ ChoDuyet, pointer chưa null.
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "thiếu giá chốt → không finalize");
reloaded.ApprovedPriceAmount.Should().BeNull();
}
}
// =====================================================================
// 3. Duyệt cấp cuối HUMAN truyền nguồn RÁC (∉ {Ncc,ProMin,ProMax,Ccm}) →
// ConflictException. Amount có nhưng source invalid → vẫn chặn.
// =====================================================================
[Fact]
public async Task ApproveV2_TerminalApprove_GarbageSource_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ceo = (await fix.CreateUserAsync("ceoAP3@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-003");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
amount: ValidAmount, source: "TuNghi"); // rác
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Nguồn giá chốt*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
reloaded.ApprovedPriceSource.Should().BeNull();
}
}
// =====================================================================
// 3b. Mỗi nguồn hợp lệ (Ncc/ProMin/ProMax/Ccm) đều set được — chốt allow-list
// đầy đủ (parametrized theo Theory).
// =====================================================================
[Theory]
[InlineData("Ncc")]
[InlineData("ProMin")]
[InlineData("ProMax")]
[InlineData("Ccm")]
public async Task ApproveV2_TerminalApprove_EachValidSource_SetsSource(string source)
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ceo = (await fix.CreateUserAsync($"ceoAP3b-{source}@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
var pe = BuildPeAtLastSlot(wf.Id, $"PE-AP-3B-{source}");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
amount: ValidAmount, source: source);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
pe.ApprovedPriceSource.Should().Be(source);
pe.ApprovedPriceAmount.Should().Be(ValidAmount);
}
}
// =====================================================================
// 4. Auto-approve hệ thống (isSystem=true) tới terminal mà null giá → KHÔNG throw
// (miễn chọn giá). Test UNIT qua reflection private-static ApplyApprovedPriceOnFinalize
// — isSystem KHÔNG reachable qua public ApproveV2Async (xem header OBSERVATION),
// nên test contract của method trực tiếp. Verify exempt-branch hoạt động đúng.
// =====================================================================
[Fact]
public void ApplyApprovedPriceOnFinalize_System_NullPrice_NoThrow_NoSet()
{
var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), TenGoiThau = "x" };
var act = () => InvokeApply(pe, isSystem: true, amount: null, source: null);
act.Should().NotThrow("auto-approve hệ thống MIỄN chọn giá chốt");
pe.ApprovedPriceAmount.Should().BeNull("không có người chọn → không set giá");
pe.ApprovedPriceSource.Should().BeNull();
}
// =====================================================================
// 4b. Đối chứng (mirror case 2 ở mức unit): isSystem=false + null giá → Conflict.
// Cùng method, chỉ khác cờ isSystem → chứng minh exempt-branch là do isSystem.
// =====================================================================
[Fact]
public void ApplyApprovedPriceOnFinalize_Human_NullPrice_ThrowsConflict()
{
var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), TenGoiThau = "x" };
var act = () => InvokeApply(pe, isSystem: false, amount: null, source: null);
act.Should().Throw<ConflictException>().WithMessage("*giá chốt*");
}
// =====================================================================
// 4c. isSystem=true NHƯNG có truyền amount + source rác → vẫn Conflict (exempt
// chỉ áp khi amount==null; có amount thì source PHẢI hợp lệ kể cả system).
// =====================================================================
[Fact]
public void ApplyApprovedPriceOnFinalize_System_AmountWithGarbageSource_ThrowsConflict()
{
var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), TenGoiThau = "x" };
var act = () => InvokeApply(pe, isSystem: true, amount: ValidAmount, source: "Bogus");
act.Should().Throw<ConflictException>().WithMessage("*Nguồn giá chốt*");
}
// Reflection helper — invoke private static ApplyApprovedPriceOnFinalize. Unwrap
// TargetInvocationException để assertion bắt đúng inner ConflictException.
private static void InvokeApply(
PurchaseEvaluation evaluation, bool isSystem, decimal? amount, string? source)
{
var mi = typeof(PurchaseEvaluationWorkflowService).GetMethod(
"ApplyApprovedPriceOnFinalize",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("ApplyApprovedPriceOnFinalize không tìm thấy (đổi tên?).");
try
{
mi.Invoke(null, new object?[] { evaluation, isSystem, amount, source });
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
throw ex.InnerException;
}
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE) using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
@ -10,32 +11,42 @@ using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Services; namespace SolutionErp.Infrastructure.Tests.Services;
// ===== FEATURE B (S69 anh Kiệt FDC) — value-threshold CCM-finalize ===== // ===== FEATURE B (Mig 54 2026-06-18 anh Kiệt FDC) — CCM-finalize ĐỔI AUTO→OPT-IN =====
// PurchaseEvaluationWorkflowService.ApproveV2Async, line 816-854 prod (test theo // PurchaseEvaluationWorkflowService.ApproveV2Async, finalize-branch line 830-867 prod
// CODE — S34 rule, KHÔNG touch production). // (test theo CODE — S34 rule, KHÔNG touch production).
// //
// Logic: khi NV đang duyệt (ChoDuyet, decision=Approve) có role AppRoles.CostControl // ⚠️ SPEC CHANGE (Mig 54 vs S69):
// (CCM) VÀ aw.CeoApprovalThreshold != null VÀ winnerQuoteTotal (SUM ThanhTien của // TRƯỚC (S69): CCM duyệt + aw.CeoApprovalThreshold set + winnerQuoteTotal < ngưỡng
// các báo giá của NCC được chọn) < ngưỡng VÀ chưa-ở-slot-cuối → set Phase=DaDuyet // + chưa-slot-cuối → AUTO finalize DaDuyet (im lặng, KHÔNG cần tham số).
// (bỏ qua các Bước/Cấp còn lại, incl CEO), pointers null, SLA null. // NAY (Mig 54): CHỈ finalize khi `finalizeByCcmDelegation=true` truyền vào
// Else → advance tuyến tính bình thường. // TransitionAsync VÀ đủ điều kiện (fail-closed, check theo thứ tự code line 832-851):
// (1) aw.CeoApprovalThreshold != null → null = ConflictException
// (2) actorRoles chứa AppRoles.CostControl → khác = ForbiddenException
// (3) winnerQuoteTotal < ngưỡng (STRICT `<`) → >= = ConflictException
// Đủ 3 ĐK → ApplyApprovedPriceOnFinalize (BẮT BUỘC giá chốt cho human) → DaDuyet.
// flag=false → bỏ qua finalize-branch hoàn toàn → advance tuyến tính lên CEO.
// //
// ⚠️ BOUNDARY (load-bearing): predicate là `winnerQuoteTotal < ceoThreshold` (STRICT // ⚠️ finalize-branch (delegation) gọi ApplyApprovedPriceOnFinalize → human approver
// less-than, code line 838) → bằng đúng ngưỡng = KHÔNG finalize → advance bình thường. // PHẢI truyền approvedPriceAmount + approvedPriceSource hợp lệ, nếu không = Conflict
// ("Chọn 1 giá chốt..."). Mọi case finalize dưới đây truyền giá hợp lệ.
// //
// Mirror harness PeSubmitGuardAndBypassTests.cs cùng folder (IdentityFixture + SQLite // Mirror harness PeSubmitGuardAndBypassTests.cs cùng folder (IdentityFixture + SQLite
// + SeedWorkflowAsync + SeedWinnerWithQuoteAsync + reuse NoOpNotificationService internal). // + SeedWorkflowAsync + SeedWinnerWithQuoteAsync + reuse NoOpNotificationService internal).
// Khác PeSubmit*: tests này dựng PE TRỰC TIẾP ở ChoDuyet (đã qua submit guard) + pin // Tests này dựng PE TRỰC TIẾP ở ChoDuyet (đã qua submit guard) + pin pointer Step/Level
// pointer Step/Level đứng tại slot CCM → drive 1 lần Approve. // đứng tại slot CCM → drive 1 lần Approve.
public class PeCcmThresholdFinalizeTests public class PeCcmThresholdFinalizeTests
{ {
private const decimal CeoThreshold = 1_000_000_000m;
private const decimal ValidApprovedPrice = 500_000_000m;
private const string ValidApprovedSource = "Ncc";
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
TestApplicationDbContext db, FixedDateTime clock) CreateService() TestApplicationDbContext db, FixedDateTime clock) CreateService()
{ {
var fix = new IdentityFixture(); var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>(); var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>(); var um = fix.Services.GetRequiredService<UserManager<User>>();
var clock = new FixedDateTime(new DateTime(2026, 6, 17, 0, 0, 0, DateTimeKind.Utc)); var clock = new FixedDateTime(new DateTime(2026, 6, 18, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService(); var notify = new NoOpNotificationService();
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um); var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
return (svc, fix, db, clock); return (svc, fix, db, clock);
@ -63,7 +74,7 @@ public class PeCcmThresholdFinalizeTests
SelectedSupplierId = selectedSupplierId, SelectedSupplierId = selectedSupplierId,
CurrentWorkflowStepIndex = stepIdx, CurrentWorkflowStepIndex = stepIdx,
CurrentApprovalLevelOrder = levelOrder, CurrentApprovalLevelOrder = levelOrder,
SlaDeadline = new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc), SlaDeadline = new DateTime(2026, 6, 25, 0, 0, 0, DateTimeKind.Utc),
}; };
} }
@ -140,9 +151,14 @@ public class PeCcmThresholdFinalizeTests
return wf; return wf;
} }
// Approve-in-place (advance pointer). finalizeByCcmDelegation + giá chốt optional —
// mặc định KHÔNG uỷ quyền + không giá (advance bình thường).
private static Task ApproveAsync( private static Task ApproveAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId, PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
params string[] roles) => string[] roles,
bool finalizeByCcmDelegation = false,
decimal? approvedPriceAmount = null,
string? approvedPriceSource = null) =>
svc.TransitionAsync( svc.TransitionAsync(
evaluation: pe, evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place (advance pointer) targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place (advance pointer)
@ -150,14 +166,17 @@ public class PeCcmThresholdFinalizeTests
actorRoles: roles, actorRoles: roles,
decision: ApprovalDecision.Approve, decision: ApprovalDecision.Approve,
comment: null, comment: null,
finalizeByCcmDelegation: finalizeByCcmDelegation,
approvedPriceAmount: approvedPriceAmount,
approvedPriceSource: approvedPriceSource,
ct: CancellationToken.None); ct: CancellationToken.None);
// ===================================================================== // =====================================================================
// 1. ⭐ LOAD-BEARING — CCM duyệt, gói < ngưỡng, CCM mid-workflow (còn CEO sau) // 1. ⭐ LOAD-BEARING — CCM tích uỷ-quyền (flag=true) + gói < ngưỡng + CCM
// → DaDuyet luôn, bỏ qua CEO, pointers null. // mid-workflow (còn CEO sau) + truyền giá chốt → DaDuyet, bỏ CEO, pointers null.
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task ApproveV2_CcmBelowThreshold_MidWorkflow_FinalizesDaDuyet_SkipsCeo_PointersCleared() public async Task ApproveV2_CcmDelegationFlag_BelowThreshold_MidWorkflow_FinalizesDaDuyet_SkipsCeo_PointersCleared()
{ {
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
@ -170,25 +189,30 @@ public class PeCcmThresholdFinalizeTests
{ {
new[] { ccm }, // Bước 1 Cấp 1 = CCM new[] { ccm }, // Bước 1 Cấp 1 = CCM
new[] { ceo }, // Bước 2 Cấp 1 = CEO (sẽ bị bỏ qua) new[] { ceo }, // Bước 2 Cấp 1 = CEO (sẽ bị bỏ qua)
}, ceoThreshold: 1_000_000_000m); }, ceoThreshold: CeoThreshold);
// PE đứng tại Bước 1 (stepIdx 0) Cấp 1 — đến lượt CCM. // PE đứng tại Bước 1 (stepIdx 0) Cấp 1 — đến lượt CCM.
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1); var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1);
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
// Gói 500tr < ngưỡng 1 tỷ. // Gói 500tr < ngưỡng 1 tỷ.
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 500_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice);
pe.SelectedSupplierId = supplierId; pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl); await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
// ⭐ Phiếu DaDuyet ngay — KHÔNG advance sang Bước 2 (CEO bị bỏ qua). // ⭐ Phiếu DaDuyet ngay — KHÔNG advance sang Bước 2 (CEO bị bỏ qua).
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
"CCM duyệt + gói < ngưỡng CEO → finalize, bỏ CEO"); "CCM tích uỷ-quyền + gói < ngưỡng CEO → finalize, bỏ CEO");
pe.CurrentWorkflowStepIndex.Should().BeNull("terminal → step pointer null"); pe.CurrentWorkflowStepIndex.Should().BeNull("terminal → step pointer null");
pe.CurrentApprovalLevelOrder.Should().BeNull("terminal → level pointer null"); pe.CurrentApprovalLevelOrder.Should().BeNull("terminal → level pointer null");
pe.SlaDeadline.Should().BeNull("terminal → SLA null"); pe.SlaDeadline.Should().BeNull("terminal → SLA null");
// ① giá chốt phải được bind từ giá CCM truyền vào.
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice, "giá chốt bind khi finalize");
pe.ApprovedPriceSource.Should().Be(ValidApprovedSource);
// CEO KHÔNG được ghi opinion (bị bỏ qua hoàn toàn — chỉ CCM ký). // CEO KHÔNG được ghi opinion (bị bỏ qua hoàn toàn — chỉ CCM ký).
var opinions = await db.PurchaseEvaluationLevelOpinions var opinions = await db.PurchaseEvaluationLevelOpinions
@ -200,11 +224,52 @@ public class PeCcmThresholdFinalizeTests
} }
// ===================================================================== // =====================================================================
// 2. CCM duyệt, gói >= ngưỡng → advance sang CEO, Phase GIỮ ChoDuyet (NOT DaDuyet). // 1a. (NEW Mig 54) flag=false + gói < ngưỡng → KHÔNG finalize, advance lên CEO.
// Boundary: gói == đúng ngưỡng (strict-less-than → KHÔNG finalize). // Đây là ĐỔI hành vi chính: trước S69 AUTO finalize, NAY phải tích flag.
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task ApproveV2_CcmAtOrAboveThreshold_AdvancesToCeo_PhaseStaysChoDuyet() public async Task ApproveV2_NoDelegationFlag_CcmBelowThreshold_DoesNotFinalize_AdvancesToCeo()
{
var (svc, fix, db, clock) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccm1a@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceo1a@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm }, // Bước 1 = CCM
new[] { ceo }, // Bước 2 = CEO
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-001A");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// Gói 500tr < ngưỡng — NHƯNG KHÔNG tích uỷ-quyền.
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
// flag=false (mặc định) → KHÔNG vào finalize-branch dù gói < ngưỡng.
await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl });
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"không tích uỷ-quyền → KHÔNG finalize dù gói < ngưỡng → advance bình thường lên CEO");
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)");
pe.CurrentApprovalLevelOrder.Should().Be(1, "Cấp 1 của Bước 2");
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7), "SLA reset cho CEO");
// Chưa finalize → giá chốt CHƯA bind (CEO mới chọn ở cấp cuối).
pe.ApprovedPriceAmount.Should().BeNull("chưa tới terminal → chưa có giá chốt");
}
}
// =====================================================================
// 2. flag=false + gói >= ngưỡng → advance sang CEO, Phase GIỮ ChoDuyet.
// Boundary: gói == đúng ngưỡng (strict-less-than → KHÔNG finalize kể cả nếu
// có tích flag — case b cover phần Conflict).
// =====================================================================
[Fact]
public async Task ApproveV2_CcmAtThreshold_NoFlag_AdvancesToCeo_PhaseStaysChoDuyet()
{ {
var (svc, fix, db, clock) = CreateService(); var (svc, fix, db, clock) = CreateService();
using (fix) using (fix)
@ -216,17 +281,17 @@ public class PeCcmThresholdFinalizeTests
{ {
new[] { ccm }, // Bước 1 = CCM new[] { ccm }, // Bước 1 = CCM
new[] { ceo }, // Bước 2 = CEO new[] { ceo }, // Bước 2 = CEO
}, ceoThreshold: 1_000_000_000m); }, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-002"); var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-002");
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
// Gói == ĐÚNG ngưỡng (1 tỷ) → strict `<` false → KHÔNG finalize. // Gói == ĐÚNG ngưỡng (1 tỷ) → strict `<` false → KHÔNG finalize.
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: CeoThreshold);
pe.SelectedSupplierId = supplierId; pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl); await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl });
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"gói == ngưỡng (không < ngưỡng) → advance bình thường, chưa DaDuyet"); "gói == ngưỡng (không < ngưỡng) → advance bình thường, chưa DaDuyet");
@ -237,46 +302,168 @@ public class PeCcmThresholdFinalizeTests
} }
// ===================================================================== // =====================================================================
// 2b. CCM duyệt, gói > ngưỡng (vượt rõ rệt) → advance sang CEO (không finalize). // (b) flag=true + gói == ngưỡng → ConflictException (strict `<` violated:
// Phân biệt với case (1) cùng setup nhưng chỉ khác giá trị gói. // winnerQuoteTotal >= ceoThreshold). Phiếu KHÔNG đổi (throw trước mutate).
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task ApproveV2_CcmAboveThreshold_AdvancesToCeo_NotFinalized() public async Task ApproveV2_DelegationFlag_AtThreshold_ThrowsConflict_NoMutation()
{ {
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
var ccm = (await fix.CreateUserAsync("ccm2b@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id; var ccm = (await fix.CreateUserAsync("ccmB@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceo2b@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id; var ceo = (await fix.CreateUserAsync("ceoB@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[] var wf = await SeedWorkflowAsync(db, new[]
{ {
new[] { ccm }, new[] { ccm },
new[] { ceo }, new[] { ceo },
}, ceoThreshold: 1_000_000_000m); }, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-002B"); var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-00B");
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
// Gói 2 tỷ > ngưỡng 1 tỷ. // Gói == ngưỡng → finalize-branch chạy nhưng `>= ceoThreshold` → Conflict.
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000_000m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: CeoThreshold);
pe.SelectedSupplierId = supplierId; pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl); var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "gói > ngưỡng → CEO vẫn phải duyệt"); await act.Should().ThrowAsync<ConflictException>()
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)"); .WithMessage("*ngưỡng CEO*");
pe.CurrentApprovalLevelOrder.Should().Be(1);
// Side-effect: throw TRƯỚC khi set Phase → vẫn ChoDuyet, pointer giữ nguyên.
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "gói >= ngưỡng → không được finalize");
reloaded.CurrentWorkflowStepIndex.Should().Be(0);
reloaded.ApprovedPriceAmount.Should().BeNull();
} }
} }
// ===================================================================== // =====================================================================
// 3. CeoApprovalThreshold == null → advance tuyến tính KỂ CẢ CCM (no early-finalize). // (b2) flag=true + gói > ngưỡng (vượt rõ rệt) → cũng ConflictException.
// Backward-compat: workflow chưa set ngưỡng → behavior cũ.
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task ApproveV2_ThresholdNull_CcmApprovesBelowAnyValue_AdvancesNormally_NoFinalize() public async Task ApproveV2_DelegationFlag_AboveThreshold_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccmB2@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceoB2@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm },
new[] { ceo },
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-0B2");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000_000m); // > ngưỡng
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
await act.Should().ThrowAsync<ConflictException>();
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "gói > ngưỡng → CEO vẫn phải duyệt");
}
}
// =====================================================================
// (c) flag=true + actor KHÔNG phải CostControl → ForbiddenException.
// Dùng PRO (Procurement) đứng tại slot bước 1 + tích uỷ-quyền + gói < ngưỡng.
// Guard role (line 835) chạy TRƯỚC threshold-compare → Forbidden.
// =====================================================================
[Fact]
public async Task ApproveV2_DelegationFlag_NonCostControlActor_ThrowsForbidden()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
// Bước 1 Cấp 1 = Procurement (KHÔNG phải CCM) — đứng tại đây tích flag.
var pro = (await fix.CreateUserAsync("proC@fb.test", "PRO User", null, new[] { AppRoles.Procurement })).Id;
var ceo = (await fix.CreateUserAsync("ceoC@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { pro },
new[] { ceo },
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-00C");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice); // < ngưỡng
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, pro, new[] { AppRoles.Procurement },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*CCM*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "PRO không được uỷ-quyền finalize");
}
}
// =====================================================================
// (d) flag=true + workflow chưa set CeoApprovalThreshold (null) → ConflictException.
// Threshold-null check (line 832) chạy TRƯỚC role/value → Conflict.
// =====================================================================
[Fact]
public async Task ApproveV2_DelegationFlag_ThresholdNotSet_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccmD@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceoD@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm },
new[] { ceo },
}, ceoThreshold: null); // ⭐ ngưỡng null → finalize bị chặn ngay
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-00D");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Ngưỡng giá trị*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"ngưỡng null → không thể uỷ-quyền finalize");
}
}
// =====================================================================
// 3. CeoApprovalThreshold == null + flag=false → advance tuyến tính KỂ CẢ CCM.
// Backward-compat: workflow chưa set ngưỡng → behavior cũ (no early-finalize).
// =====================================================================
[Fact]
public async Task ApproveV2_ThresholdNull_NoFlag_CcmApprovesBelowAnyValue_AdvancesNormally()
{ {
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
@ -288,36 +475,35 @@ public class PeCcmThresholdFinalizeTests
{ {
new[] { ccm }, new[] { ccm },
new[] { ceo }, new[] { ceo },
}, ceoThreshold: null); // ⭐ ngưỡng null → finalize-branch không chạy }, ceoThreshold: null);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-003"); var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-003");
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
// Gói 1đ (siêu nhỏ — chắc chắn dưới mọi ngưỡng nếu có) nhưng ngưỡng null. // Gói 1đ (siêu nhỏ) nhưng ngưỡng null + KHÔNG flag.
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1m); var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1m);
pe.SelectedSupplierId = supplierId; pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl); await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl });
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"ngưỡng null → KHÔNG finalize dù CCM + gói nhỏ → advance bình thường"); "ngưỡng null + no flag → advance bình thường");
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) như cũ"); pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) như cũ");
pe.CurrentApprovalLevelOrder.Should().Be(1); pe.CurrentApprovalLevelOrder.Should().Be(1);
} }
} }
// ===================================================================== // =====================================================================
// 4. Non-CCM actor (Procurement) duyệt dưới ngưỡng → advance bình thường (no // 4. Non-CCM actor (Procurement) duyệt dưới ngưỡng, KHÔNG flag → advance bình
// early-finalize; CHỈ CostControl mới trigger). Cover nhận-diện-theo-role. // thường (no early-finalize). Cover nhận-diện-theo-role + no-flag.
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task ApproveV2_NonCcmActor_BelowThreshold_AdvancesNormally_NoFinalize() public async Task ApproveV2_NonCcmActor_NoFlag_BelowThreshold_AdvancesNormally()
{ {
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
// Bước 1 Cấp 1 = Procurement (KHÔNG phải CCM), Bước 2 = CEO.
var pro = (await fix.CreateUserAsync("pro4@fb.test", "PRO User", null, new[] { AppRoles.Procurement })).Id; var pro = (await fix.CreateUserAsync("pro4@fb.test", "PRO User", null, new[] { AppRoles.Procurement })).Id;
var ceo = (await fix.CreateUserAsync("ceo4@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id; var ceo = (await fix.CreateUserAsync("ceo4@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
@ -325,32 +511,31 @@ public class PeCcmThresholdFinalizeTests
{ {
new[] { pro }, new[] { pro },
new[] { ceo }, new[] { ceo },
}, ceoThreshold: 1_000_000_000m); }, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-004"); var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-004");
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
// Gói 100tr << ngưỡng 1 tỷ — NHƯNG actor là PRO không phải CCM. var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 100_000_000m); // << ngưỡng
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 100_000_000m);
pe.SelectedSupplierId = supplierId; pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, pro, AppRoles.Procurement); await ApproveAsync(svc, pe, pro, new[] { AppRoles.Procurement });
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"PRO duyệt + gói nhỏ nhưng KHÔNG phải CostControl → no finalize"); "PRO duyệt + gói nhỏ + no flag → advance bình thường");
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) bình thường"); pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) bình thường");
pe.CurrentApprovalLevelOrder.Should().Be(1); pe.CurrentApprovalLevelOrder.Should().Be(1);
} }
} }
// ===================================================================== // =====================================================================
// 5. (optional) CCM đã ở slot cuối (Bước cuối Cấp cuối) + dưới ngưỡng → DaDuyet // 5. CCM tích uỷ-quyền (flag=true) + ở slot CUỐI (Bước cuối Cấp cuối) + dưới
// qua advance bình thường (guard `!(currentIdx==last && level==max)` chặn // ngưỡng + truyền giá → DaDuyet qua finalize-branch (chạy TRƯỚC normal advance).
// finalize-branch → nhánh advance terminal vẫn ra DaDuyet, không double-finalize). // Verify: chỉ 1 Approval Approve row (không double-finalize).
// ===================================================================== // =====================================================================
[Fact] [Fact]
public async Task ApproveV2_CcmAtLastSlot_BelowThreshold_DaDuyetViaNormalAdvance_NoDoubleFinalize() public async Task ApproveV2_CcmDelegationFlag_AtLastSlot_BelowThreshold_FinalizesDaDuyet_NoDoubleApproval()
{ {
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
@ -361,7 +546,7 @@ public class PeCcmThresholdFinalizeTests
var wf = await SeedWorkflowAsync(db, new[] var wf = await SeedWorkflowAsync(db, new[]
{ {
new[] { ccm }, // Bước 1 Cấp 1 = CCM = slot cuối new[] { ccm }, // Bước 1 Cấp 1 = CCM = slot cuối
}, ceoThreshold: 1_000_000_000m); }, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-005"); var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-005");
db.PurchaseEvaluations.Add(pe); db.PurchaseEvaluations.Add(pe);
@ -370,20 +555,59 @@ public class PeCcmThresholdFinalizeTests
pe.SelectedSupplierId = supplierId; pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl); await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: 300_000_000m, approvedPriceSource: ValidApprovedSource);
// Slot cuối → finalize-branch bị guard skip (currentIdx==last && level==max), // finalize-branch (line 830) chạy TRƯỚC normal advance → DaDuyet ngay.
// nhưng nhánh advance "nextIdx >= steps.Count" cũng set DaDuyet. Kết quả giống.
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
"CCM ở slot cuối → DaDuyet qua advance bình thường (không cần finalize-branch)"); "CCM tích uỷ-quyền ở slot cuối + dưới ngưỡng → DaDuyet qua finalize-branch");
pe.CurrentWorkflowStepIndex.Should().BeNull(); pe.CurrentWorkflowStepIndex.Should().BeNull();
pe.CurrentApprovalLevelOrder.Should().BeNull(); pe.CurrentApprovalLevelOrder.Should().BeNull();
pe.ApprovedPriceAmount.Should().Be(300_000_000m);
// KHÔNG double-finalize: chỉ 1 Approval Approve (của CCM), không phát sinh vết thừa. // KHÔNG double: chỉ 1 Approval Approve (của CCM), finalize-branch return ngay.
var approvals = await db.PurchaseEvaluationApprovals var approvals = await db.PurchaseEvaluationApprovals
.Where(a => a.PurchaseEvaluationId == pe.Id .Where(a => a.PurchaseEvaluationId == pe.Id
&& a.Decision == ApprovalDecision.Approve).ToListAsync(); && a.Decision == ApprovalDecision.Approve).ToListAsync();
approvals.Should().HaveCount(1, "1 lần CCM duyệt = 1 Approval row, không double"); approvals.Should().HaveCount(1, "1 lần CCM duyệt = 1 Approval row, không double");
} }
} }
// =====================================================================
// 6. CCM tích uỷ-quyền + đủ điều kiện NHƯNG KHÔNG truyền giá chốt (null) →
// ConflictException ("Chọn 1 giá chốt..."). Finalize cũng cần giá chốt như
// terminal thường (ApplyApprovedPriceOnFinalize cho human approver).
// =====================================================================
[Fact]
public async Task ApproveV2_CcmDelegationFlag_BelowThreshold_NullPrice_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccm6@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceo6@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm },
new[] { ceo },
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-006");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice); // < ngưỡng
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
// flag=true + đủ ĐK threshold/role/value NHƯNG approvedPriceAmount=null.
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: null, approvedPriceSource: null);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*giá chốt*");
}
}
} }