[CLAUDE] PurchaseEvaluation: Mig 54 giá đề xuất PRO/CCM + CEO chọn giá chốt + CCM duyệt-done ô-tích
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s
Theo note anh Kiệt FDC (go-live so-sánh-giá thứ Hai): - (1) Giá chào thầu thêm giá đề xuất NGOÀI giá NCC: PRO nhập dải Min/Max + CCM nhập 1 giá (2 lệnh role-gate Procurement/CostControl, fail-closed). Khi duyệt cấp cuối, người duyệt CHỌN 1 giá chốt (Ncc/ProMin/ProMax/Ccm) -> luu ApprovedPriceAmount/Source (bind tai moi nhanh DaDuyet, bat buoc chon; auto-approve he thong mien). - (3) CCM duyet-done mien CEO: DOI tu AUTO-threshold (S69) sang O-TICH-TAY (finalizeByCcmDelegation) -- CCM chu dong tich, fail-closed theo nguong + role + gia goi. An toan hon (khong vo tinh bo CEO). - Mig 54 additive-nullable (5 cot PE) - FE 2 app SHA-mirror - test 306->334 (+28: opt-in 6->11, +10 gia chot, +13 setter authz). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1380,6 +1380,128 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại khối "c. Giá chào thầu".
|
||||||
|
// PRO nhập dải Min/Max (PUT /suggested-price/pro {minPrice,maxPrice}); CCM nhập 1
|
||||||
|
// giá (PUT /suggested-price/ccm {ccmPrice}). Role-gate qua canEditPro/CcmSuggestedPrice
|
||||||
|
// (BE-computed capability — mirror budget PRO/CCM, KHÔNG ràng phase). Read-only khi
|
||||||
|
// !canEdit → hiện text giá. Khi DaDuyet + approvedPriceAmount → dòng "Giá chốt duyệt".
|
||||||
|
const APPROVED_PRICE_SOURCE_LABEL: Record<string, string> = {
|
||||||
|
Ncc: 'Giá NCC',
|
||||||
|
ProMin: 'PRO Min',
|
||||||
|
ProMax: 'PRO Max',
|
||||||
|
Ccm: 'CCM',
|
||||||
|
}
|
||||||
|
function SuggestedPriceRows({ ev, readOnly }: { ev: PeDetailBundle; readOnly: boolean }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-detail', ev.id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['pe-list'] })
|
||||||
|
}
|
||||||
|
// PRO Min/Max — ABSOLUTE SET cả cặp (mirror budget CCM dual-field: field không đổi
|
||||||
|
// echo giá trị hiện tại để không bị clear).
|
||||||
|
const proPriceMut = useMutation({
|
||||||
|
mutationFn: async (body: { minPrice: number | null; maxPrice: number | null }) =>
|
||||||
|
api.put(`/purchase-evaluations/${ev.id}/suggested-price/pro`, body),
|
||||||
|
onSuccess: () => { toast.success('Đã lưu giá đề xuất (PRO)'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
const ccmPriceMut = useMutation({
|
||||||
|
mutationFn: async (body: { ccmPrice: number | null }) =>
|
||||||
|
api.put(`/purchase-evaluations/${ev.id}/suggested-price/ccm`, body),
|
||||||
|
onSuccess: () => { toast.success('Đã lưu giá đề xuất (CCM)'); invalidate() },
|
||||||
|
onError: e => toast.error(getErrorMessage(e)),
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEditPro = !readOnly && ev.canEditProSuggestedPrice
|
||||||
|
const canEditCcm = !readOnly && ev.canEditCcmSuggestedPrice
|
||||||
|
const hasAnyValue = ev.proSuggestedMinPrice != null
|
||||||
|
|| ev.proSuggestedMaxPrice != null
|
||||||
|
|| ev.ccmSuggestedPrice != null
|
||||||
|
const approved = ev.phase === PurchaseEvaluationPhase.DaDuyet && ev.approvedPriceAmount != null
|
||||||
|
|
||||||
|
// Ẩn hoàn toàn khi không edit được + chưa có giá nào + chưa chốt → tránh khối rỗng.
|
||||||
|
if (!canEditPro && !canEditCcm && !hasAnyValue && !approved) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 bg-slate-50/60 px-3 py-2.5">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Giá đề xuất (ngoài giá chào thầu)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PRO — Giá Min / Giá Max */}
|
||||||
|
<div className="flex items-start gap-3 text-[12px]">
|
||||||
|
<span className="w-44 shrink-0 pt-1 text-slate-500">Giá đề xuất (PRO)</span>
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-12 shrink-0 text-[11px] text-slate-500">Min</span>
|
||||||
|
{canEditPro ? (
|
||||||
|
<VndInlineEdit
|
||||||
|
initial={ev.proSuggestedMinPrice}
|
||||||
|
saving={proPriceMut.isPending}
|
||||||
|
label="Giá đề xuất PRO — Min"
|
||||||
|
onSave={v => proPriceMut.mutate({ minPrice: v, maxPrice: ev.proSuggestedMaxPrice })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold text-slate-800">
|
||||||
|
{ev.proSuggestedMinPrice != null ? fmtVnd(ev.proSuggestedMinPrice) : <span className="font-normal text-slate-400">—</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-12 shrink-0 text-[11px] text-slate-500">Max</span>
|
||||||
|
{canEditPro ? (
|
||||||
|
<VndInlineEdit
|
||||||
|
initial={ev.proSuggestedMaxPrice}
|
||||||
|
saving={proPriceMut.isPending}
|
||||||
|
label="Giá đề xuất PRO — Max"
|
||||||
|
onSave={v => proPriceMut.mutate({ minPrice: ev.proSuggestedMinPrice, maxPrice: v })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold text-slate-800">
|
||||||
|
{ev.proSuggestedMaxPrice != null ? fmtVnd(ev.proSuggestedMaxPrice) : <span className="font-normal text-slate-400">—</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CCM — 1 giá */}
|
||||||
|
<div className="flex items-center gap-3 text-[12px]">
|
||||||
|
<span className="w-44 shrink-0 text-slate-500">Giá đề xuất (CCM)</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{canEditCcm ? (
|
||||||
|
<VndInlineEdit
|
||||||
|
initial={ev.ccmSuggestedPrice}
|
||||||
|
saving={ccmPriceMut.isPending}
|
||||||
|
label="Giá đề xuất CCM"
|
||||||
|
onSave={v => ccmPriceMut.mutate({ ccmPrice: v })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-semibold text-slate-800">
|
||||||
|
{ev.ccmSuggestedPrice != null ? fmtVnd(ev.ccmSuggestedPrice) : <span className="font-normal text-slate-400">—</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Giá CHỐT duyệt — chỉ khi DaDuyet + approvedPriceAmount != null. */}
|
||||||
|
{approved && (
|
||||||
|
<div className="flex items-center gap-3 rounded border border-emerald-200 bg-emerald-50 px-2 py-1.5 text-[12px]">
|
||||||
|
<span className="w-44 shrink-0 font-medium text-emerald-800">Giá chốt duyệt</span>
|
||||||
|
<span className="min-w-0 flex-1 font-semibold text-emerald-900">
|
||||||
|
{fmtVnd(ev.approvedPriceAmount!)}
|
||||||
|
{ev.approvedPriceSource && (
|
||||||
|
<span className="ml-2 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||||
|
{APPROVED_PRICE_SOURCE_LABEL[ev.approvedPriceSource] ?? ev.approvedPriceSource}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Section 2 — Chọn NCC/TP (spec: a/b/c/d) =====
|
// ===== 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>
|
||||||
|
|||||||
@ -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 ký duyệt thật để phiếu thành "Đã duyệt".
|
vẫn phải ký 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)}đ < ngưỡng CEO {fmtMoney(evaluation.ceoApprovalThreshold ?? 0)}đ — tích để hoàn tất ngay, không cần trình CEO.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
|
||||||
|
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
|
||||||
|
{shouldPickPrice && (
|
||||||
|
<div className="mb-3 space-y-1.5">
|
||||||
|
<Label className="text-[12px]">
|
||||||
|
Chọn giá chốt (duyệt theo giá đề xuất) <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{priceCandidates.map(c => (
|
||||||
|
<label key={c.source} className="flex items-center gap-2 rounded border border-brand-200 bg-white px-2 py-1.5 text-[12px] hover:bg-brand-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={approvedPriceSource === c.source}
|
||||||
|
onChange={() => setApprovedPriceSource(c.source)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-slate-700">{c.label}</span>
|
||||||
|
<span className="font-mono font-semibold text-slate-900">{fmtMoney(c.amount)}đ</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{priceCandidates.length === 0 && (
|
||||||
|
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
|
||||||
|
Chưa có giá nào để chọn — nhập giá đề xuất (PRO/CCM) hoặc chọn NCC thắng thầu trước khi duyệt cuối.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Label>Ghi chú (tùy chọn)</Label>
|
<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')
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ký duyệt thật để phiếu thành "Đã duyệt".
|
vẫn phải ký 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)}đ < ngưỡng CEO {fmtMoney(evaluation.ceoApprovalThreshold ?? 0)}đ — tích để hoàn tất ngay, không cần trình CEO.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* [Mig 54] ① Bộ chọn giá chốt — hiện khi duyệt CUỐI hoặc CCM tích done.
|
||||||
|
"Duyệt theo giá đề xuất": chọn 1 trong NCC / PRO Min / PRO Max / CCM. */}
|
||||||
|
{shouldPickPrice && (
|
||||||
|
<div className="mb-3 space-y-1.5">
|
||||||
|
<Label className="text-[12px]">
|
||||||
|
Chọn giá chốt (duyệt theo giá đề xuất) <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{priceCandidates.map(c => (
|
||||||
|
<label key={c.source} className="flex items-center gap-2 rounded border border-brand-200 bg-white px-2 py-1.5 text-[12px] hover:bg-brand-50/40">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={approvedPriceSource === c.source}
|
||||||
|
onChange={() => setApprovedPriceSource(c.source)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-slate-700">{c.label}</span>
|
||||||
|
<span className="font-mono font-semibold text-slate-900">{fmtMoney(c.amount)}đ</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{priceCandidates.length === 0 && (
|
||||||
|
<div className="rounded border border-amber-200 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-700">
|
||||||
|
Chưa có giá nào để chọn — nhập giá đề xuất (PRO/CCM) hoặc chọn NCC thắng thầu trước khi duyệt cuối.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Label>Ghi chú (tùy chọn)</Label>
|
<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')
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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,8 +846,11 @@ 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(
|
||||||
|
$"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.");
|
||||||
|
|
||||||
|
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;
|
||||||
@ -847,11 +861,10 @@ public class PurchaseEvaluationWorkflowService(
|
|||||||
PurchaseEvaluationPhase.DaDuyet,
|
PurchaseEvaluationPhase.DaDuyet,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
ApprovalDecision.Approve,
|
ApprovalDecision.Approve,
|
||||||
$"[CCM duyệt cuối — gói {winnerQuoteTotal:N0}đ < ngưỡng CEO {ceoThreshold:N0}đ, không cần CEO duyệt] {comment ?? ""}".Trim(),
|
$"[CCM duyệt done miễn CEO — gói {winnerQuoteTotal:N0}đ < ngưỡng CEO {ceoThreshold:N0}đ] {comment ?? ""}".Trim(),
|
||||||
ct);
|
ct);
|
||||||
return;
|
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
|
||||||
if (currentLevelOrder < maxLevelOrder)
|
if (currentLevelOrder < maxLevelOrder)
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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*");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user