[CLAUDE] PurchaseEvaluation: endsBeforeCeo pointer-aware + duyệt theo khoảng Min–Max (Mig 66)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m26s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m26s
Hai chỉnh từ buổi UAT anh Kiệt FDC (S117): 1) Fix hiển thị lũy kế "duyệt rồi không bắt" — root cause là DISPLAY-LIE, không phải lũy kế bug. endsBeforeCeo cho phiếu CHƯA DaDuyet nay POINTER-AWARE: chỉ báo "kết thúc trước CEO" khi con-trỏ còn ở/trước cấp finalize. Phiếu đã đi QUA cấp finalize (approver bỏ tick "Cấp này KẾT THÚC" → trình tiếp CEO) → false: sơ đồ hết làm mờ Bước CEO + hết nói dối "Kết thúc tại Cấp X" khi phiếu đang chờ CEO. 3 site (detail in-memory + list/inbox EF-subquery). Filter lũy kế GIỮ nguyên (đúng nguyên tắc "duyệt rồi mới bắc"). BE-only — FE là consumer thuần nên tự render đúng. 2) Feature "duyệt theo KHOẢNG Min–Max": khi PRO có cả Min & Max, người duyệt cấp cuối chọn CẢ HAI (radio "PRO — cả Min và Max", source=ProMinMax) → Min ở ApprovedPriceAmount + Max ở ApprovedPriceMaxAmount (Mig 66 AddColumn nullable, no backfill, phiếu cũ nguyên vẹn). Bất-biến Max⟺ProMinMax, Min≤Max (guard ở service + validator). HĐ.GiaTri KHÔNG đọc giá chốt (= SUM báo giá NCC được chọn) → range thuần record-of-decision hiển thị, 0 tác động HĐ. FE 2-app SHA-mirror (Panel + DetailTabs). Test 495 → 501 (+1 pointer-aware repro, +5 ProMinMax). Cả 2 điều tra qua /fable-real engine-đắt (investigator-codebase deep-pass) theo yêu cầu anh Kiệt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1662,6 +1662,7 @@ const APPROVED_PRICE_SOURCE_LABEL: Record<string, string> = {
|
||||
Ncc: 'Giá NCC',
|
||||
ProMin: 'PRO Min',
|
||||
ProMax: 'PRO Max',
|
||||
ProMinMax: 'PRO Min–Max',
|
||||
Ccm: 'CCM',
|
||||
}
|
||||
function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) {
|
||||
@ -1852,7 +1853,9 @@ function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) {
|
||||
<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.approvedPriceMaxAmount != null
|
||||
? `${fmtVnd(ev.approvedPriceAmount!)} – ${fmtVnd(ev.approvedPriceMaxAmount)}`
|
||||
: 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}
|
||||
|
||||
@ -131,14 +131,22 @@ export function PeWorkflowPanel({
|
||||
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
|
||||
&& finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder)
|
||||
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
|
||||
// [S117 anh Kiệt FDC] Khi PRO có CẢ Min & Max → thêm ứng viên "duyệt theo khoảng"
|
||||
// (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max].
|
||||
const hasBothProPrices = evaluation.proSuggestedMinPrice != null && evaluation.proSuggestedMaxPrice != 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 },
|
||||
...(hasBothProPrices
|
||||
? [{ source: 'ProMinMax', label: 'PRO — cả Min và Max (duyệt theo khoảng)', amount: evaluation.proSuggestedMinPrice, amountMax: 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
|
||||
] as { source: string; label: string; amount: number | null | undefined; amountMax?: number | null }[])
|
||||
.filter((c): c is { source: string; label: string; amount: number; amountMax?: number } => c.amount != null)
|
||||
const selectedCandidate = priceCandidates.find(c => c.source === approvedPriceSource)
|
||||
const selectedPriceAmount = selectedCandidate?.amount ?? null
|
||||
const selectedPriceAmountMax = selectedCandidate?.amountMax ?? null
|
||||
|
||||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
|
||||
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
|
||||
@ -203,6 +211,9 @@ export function PeWorkflowPanel({
|
||||
applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true),
|
||||
approvedPriceAmount: sendPrice ? selectedPriceAmount : null,
|
||||
approvedPriceSource: sendPrice ? approvedPriceSource : null,
|
||||
// [S117] Duyệt theo khoảng: chỉ gửi Max khi nguồn = ProMinMax (selectedPriceAmountMax != null).
|
||||
// Nguồn đơn → undefined → axios bỏ key (BE reject nếu source đơn mà kèm Max).
|
||||
approvedPriceMaxAmount: sendPrice ? (selectedPriceAmountMax ?? undefined) : undefined,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -693,7 +704,9 @@ export function PeWorkflowPanel({
|
||||
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>
|
||||
<span className="font-mono font-semibold text-slate-900">
|
||||
{c.amountMax != null ? `${fmtMoney(c.amount)}đ – ${fmtMoney(c.amountMax)}đ` : `${fmtMoney(c.amount)}đ`}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{priceCandidates.length === 0 && (
|
||||
|
||||
@ -486,6 +486,7 @@ export type PeDetailBundle = {
|
||||
ccmSuggestedPriceNote: string | null
|
||||
approvedPriceAmount: number | null
|
||||
approvedPriceSource: string | null
|
||||
approvedPriceMaxAmount: number | null
|
||||
canEditProSuggestedPrice: boolean
|
||||
canEditCcmSuggestedPrice: boolean
|
||||
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
|
||||
|
||||
@ -1662,6 +1662,7 @@ const APPROVED_PRICE_SOURCE_LABEL: Record<string, string> = {
|
||||
Ncc: 'Giá NCC',
|
||||
ProMin: 'PRO Min',
|
||||
ProMax: 'PRO Max',
|
||||
ProMinMax: 'PRO Min–Max',
|
||||
Ccm: 'CCM',
|
||||
}
|
||||
function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) {
|
||||
@ -1852,7 +1853,9 @@ function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) {
|
||||
<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.approvedPriceMaxAmount != null
|
||||
? `${fmtVnd(ev.approvedPriceAmount!)} – ${fmtVnd(ev.approvedPriceMaxAmount)}`
|
||||
: 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}
|
||||
|
||||
@ -131,14 +131,22 @@ export function PeWorkflowPanel({
|
||||
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
|
||||
&& finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder)
|
||||
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null).
|
||||
// [S117 anh Kiệt FDC] Khi PRO có CẢ Min & Max → thêm ứng viên "duyệt theo khoảng"
|
||||
// (source=ProMinMax, amount=Min, amountMax=Max). Chọn nó = chốt dải [Min, Max].
|
||||
const hasBothProPrices = evaluation.proSuggestedMinPrice != null && evaluation.proSuggestedMaxPrice != 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 },
|
||||
...(hasBothProPrices
|
||||
? [{ source: 'ProMinMax', label: 'PRO — cả Min và Max (duyệt theo khoảng)', amount: evaluation.proSuggestedMinPrice, amountMax: 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
|
||||
] as { source: string; label: string; amount: number | null | undefined; amountMax?: number | null }[])
|
||||
.filter((c): c is { source: string; label: string; amount: number; amountMax?: number } => c.amount != null)
|
||||
const selectedCandidate = priceCandidates.find(c => c.source === approvedPriceSource)
|
||||
const selectedPriceAmount = selectedCandidate?.amount ?? null
|
||||
const selectedPriceAmountMax = selectedCandidate?.amountMax ?? null
|
||||
|
||||
// 2-stage dept approvals (Migration 16) — fetch riêng để FE Workflow Panel
|
||||
// hiển thị progress per phase × dept (Stage Review NV / Confirm TPB).
|
||||
@ -203,6 +211,9 @@ export function PeWorkflowPanel({
|
||||
applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true),
|
||||
approvedPriceAmount: sendPrice ? selectedPriceAmount : null,
|
||||
approvedPriceSource: sendPrice ? approvedPriceSource : null,
|
||||
// [S117] Duyệt theo khoảng: chỉ gửi Max khi nguồn = ProMinMax (selectedPriceAmountMax != null).
|
||||
// Nguồn đơn → undefined → axios bỏ key (BE reject nếu source đơn mà kèm Max).
|
||||
approvedPriceMaxAmount: sendPrice ? (selectedPriceAmountMax ?? undefined) : undefined,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@ -693,7 +704,9 @@ export function PeWorkflowPanel({
|
||||
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>
|
||||
<span className="font-mono font-semibold text-slate-900">
|
||||
{c.amountMax != null ? `${fmtMoney(c.amount)}đ – ${fmtMoney(c.amountMax)}đ` : `${fmtMoney(c.amount)}đ`}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{priceCandidates.length === 0 && (
|
||||
|
||||
@ -488,6 +488,7 @@ export type PeDetailBundle = {
|
||||
ccmSuggestedPriceNote: string | null
|
||||
approvedPriceAmount: number | null
|
||||
approvedPriceSource: string | null
|
||||
approvedPriceMaxAmount: number | null
|
||||
canEditProSuggestedPrice: boolean
|
||||
canEditCcmSuggestedPrice: boolean
|
||||
// Mig 23 — Pin schema mới ApprovalWorkflowsV2 (User chọn lúc create).
|
||||
|
||||
@ -138,7 +138,8 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new TransitionPurchaseEvaluationCommand(
|
||||
id, body.TargetPhase, body.Decision, body.Comment,
|
||||
body.ReturnMode, body.ReturnTargetUserId, body.SkipToFinal,
|
||||
body.FinalizeByCcmDelegation, body.ApprovedPriceAmount, body.ApprovedPriceSource, body.ApplyLevelFinalize), ct);
|
||||
body.FinalizeByCcmDelegation, body.ApprovedPriceAmount, body.ApprovedPriceSource,
|
||||
body.ApprovedPriceMaxAmount, body.ApplyLevelFinalize), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -352,6 +353,8 @@ public record TransitionPeBody(
|
||||
bool FinalizeByCcmDelegation = false,
|
||||
decimal? ApprovedPriceAmount = null,
|
||||
string? ApprovedPriceSource = null,
|
||||
// [S117 2026-07-14 — anh Kiệt FDC] Max của khoảng khi source="ProMinMax" (duyệt theo Min–Max).
|
||||
decimal? ApprovedPriceMaxAmount = null,
|
||||
// [S96 2026-07-01 — anh Kiệt FDC] false = BỎ tick "Cấp này KẾT THÚC" → vẫn trình CEO.
|
||||
bool ApplyLevelFinalize = true);
|
||||
|
||||
|
||||
@ -284,6 +284,7 @@ public record PurchaseEvaluationDetailBundleDto(
|
||||
string? CcmSuggestedPriceNote,
|
||||
decimal? ApprovedPriceAmount,
|
||||
string? ApprovedPriceSource,
|
||||
decimal? ApprovedPriceMaxAmount, // [S117] Max của khoảng khi source=ProMinMax (duyệt theo Min–Max)
|
||||
bool CanEditProSuggestedPrice,
|
||||
bool CanEditCcmSuggestedPrice,
|
||||
// Mig 23 — schema mới ApprovalWorkflowsV2 pin lúc create. Hiển thị Code +
|
||||
|
||||
@ -479,6 +479,8 @@ public record TransitionPurchaseEvaluationCommand(
|
||||
bool FinalizeByCcmDelegation = false,
|
||||
decimal? ApprovedPriceAmount = null,
|
||||
string? ApprovedPriceSource = null,
|
||||
// [S117 2026-07-14 — anh Kiệt FDC] Max của khoảng khi source="ProMinMax" (duyệt theo Min–Max).
|
||||
decimal? ApprovedPriceMaxAmount = null,
|
||||
// [S96 2026-07-01 — anh Kiệt FDC] false = người duyệt BỎ tick "Cấp này KẾT THÚC" → vẫn trình CEO.
|
||||
bool ApplyLevelFinalize = true) : IRequest;
|
||||
|
||||
@ -501,9 +503,20 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<Tr
|
||||
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")
|
||||
.Must(s => s is "Ncc" or "ProMin" or "ProMax" or "Ccm" or "ProMinMax")
|
||||
.When(x => x.ApprovedPriceSource is not null)
|
||||
.WithMessage("Nguồn giá chốt phải là Ncc/ProMin/ProMax/Ccm.");
|
||||
.WithMessage("Nguồn giá chốt phải là Ncc/ProMin/ProMax/Ccm/ProMinMax.");
|
||||
// [S117] Max của khoảng ≥ 0; chỉ kèm khi source=ProMinMax (bất-biến Max⟺ProMinMax); Min ≤ Max.
|
||||
// Bắt-buộc-khi-duyệt-cuối vẫn enforce ở service (biết nhánh DaDuyet) — validator chặn rác.
|
||||
RuleFor(x => x.ApprovedPriceMaxAmount).GreaterThanOrEqualTo(0)
|
||||
.When(x => x.ApprovedPriceMaxAmount.HasValue);
|
||||
RuleFor(x => x.ApprovedPriceMaxAmount)
|
||||
.Null()
|
||||
.When(x => x.ApprovedPriceSource != "ProMinMax")
|
||||
.WithMessage("Chỉ nguồn 'cả Min–Max' (ProMinMax) mới được kèm giá Max.");
|
||||
RuleFor(x => x.ApprovedPriceMaxAmount)
|
||||
.Must((cmd, max) => max is null || cmd.ApprovedPriceAmount is null || cmd.ApprovedPriceAmount <= max)
|
||||
.WithMessage("Giá Min không được lớn hơn giá Max khi duyệt theo khoảng.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -533,6 +546,7 @@ public class TransitionPurchaseEvaluationCommandHandler(
|
||||
request.FinalizeByCcmDelegation,
|
||||
request.ApprovedPriceAmount,
|
||||
request.ApprovedPriceSource,
|
||||
request.ApprovedPriceMaxAmount,
|
||||
request.ApplyLevelFinalize,
|
||||
ct);
|
||||
}
|
||||
@ -642,12 +656,18 @@ public class ListPurchaseEvaluationsQueryHandler(
|
||||
// (mirror winner-names — EF-safe; record-ctor projection KHÔNG cho biến trung
|
||||
// gian nên không gộp tuple). lv.Step!.Name = simple navigation chắc translate.
|
||||
// ApprovalWorkflowId==null → subquery rỗng → false/null (V1 tự đúng).
|
||||
// [S97] runtime: phiếu DaDuyet đọc EndedByLevelFinalize (finalize sớm THẬT);
|
||||
// chưa xong → config-subquery. Phiếu-đã-lên-CEO KHÔNG hiện badge "Kết thúc" sai.
|
||||
// [S97] runtime: phiếu DaDuyet đọc EndedByLevelFinalize (finalize sớm THẬT).
|
||||
// [S117] phiếu chưa xong POINTER-AWARE: config-subquery lọc thêm cấp finalize
|
||||
// ở-hoặc-SAU con-trỏ (rank cấp = Step.Order-1, Order tuần-tự s+1 bởi seed/Designer).
|
||||
// Phiếu đã QUA cấp finalize (approver opt-out → lên CEO) → rỗng → false, KHÔNG badge
|
||||
// "Kết thúc" sai khi đang chờ CEO. Con-trỏ null (Nháp) → ?? 0/1 = mọi cấp reachable.
|
||||
x.e.Phase == PurchaseEvaluationPhase.DaDuyet
|
||||
? x.e.EndedByLevelFinalize
|
||||
: (from lv in db.ApprovalWorkflowLevels
|
||||
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
|
||||
&& (lv.Step!.Order - 1 > (x.e.CurrentWorkflowStepIndex ?? 0)
|
||||
|| (lv.Step!.Order - 1 == (x.e.CurrentWorkflowStepIndex ?? 0)
|
||||
&& lv.Order >= (x.e.CurrentApprovalLevelOrder ?? 1)))
|
||||
select 1).Any(),
|
||||
(from lv in db.ApprovalWorkflowLevels
|
||||
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
|
||||
@ -762,12 +782,17 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
|
||||
// [S89 Mig 58] "Duyệt là KẾT THÚC (không trình CEO)" — 3 subquery riêng mirror
|
||||
// winner-names (EF-safe). lv.Step!.Name simple navigation. ApprovalWorkflowId
|
||||
// ==null → subquery rỗng → false/null (V1 tự đúng).
|
||||
// [S97] runtime: phiếu DaDuyet đọc EndedByLevelFinalize (finalize sớm THẬT);
|
||||
// chưa xong → config-subquery. Phiếu-đã-lên-CEO KHÔNG hiện badge "Kết thúc" sai.
|
||||
// [S97] runtime: phiếu DaDuyet đọc EndedByLevelFinalize (finalize sớm THẬT).
|
||||
// [S117] phiếu chưa xong POINTER-AWARE: config-subquery lọc thêm cấp finalize
|
||||
// ở-hoặc-SAU con-trỏ (rank = Step.Order-1). Phiếu đã QUA cấp finalize → lên CEO →
|
||||
// rỗng → false, KHÔNG badge "Kết thúc" sai (mirror List :647).
|
||||
x.e.Phase == PurchaseEvaluationPhase.DaDuyet
|
||||
? x.e.EndedByLevelFinalize
|
||||
: (from lv in db.ApprovalWorkflowLevels
|
||||
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
|
||||
&& (lv.Step!.Order - 1 > (x.e.CurrentWorkflowStepIndex ?? 0)
|
||||
|| (lv.Step!.Order - 1 == (x.e.CurrentWorkflowStepIndex ?? 0)
|
||||
&& lv.Order >= (x.e.CurrentApprovalLevelOrder ?? 1)))
|
||||
select 1).Any(),
|
||||
(from lv in db.ApprovalWorkflowLevels
|
||||
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
|
||||
@ -1146,12 +1171,20 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
{
|
||||
var finalizeLevel = finalizeStep.Levels
|
||||
.Where(l => l.AllowApproverFinalize).OrderBy(l => l.Order).FirstOrDefault();
|
||||
// [S97] endsBeforeCeo theo RUNTIME cho phiếu DaDuyet (đọc EndedByLevelFinalize):
|
||||
// phiếu đi HẾT lên CEO (approver opt-out finalize) → false, KHÔNG hiện "không qua
|
||||
// CEO". Phiếu chưa xong (ChoDuyet…) giữ heads-up config (còn cấp finalize phía trước).
|
||||
// [S97] endsBeforeCeo theo RUNTIME cho phiếu DaDuyet (đọc EndedByLevelFinalize).
|
||||
// [S117 anh Kiệt — bug "duyệt rồi không bắt lũy kế"] Phiếu CHƯA DaDuyet nay
|
||||
// POINTER-AWARE: heads-up "kết thúc trước CEO" CHỈ khi CÒN cấp finalize ở-hoặc-SAU
|
||||
// con-trỏ hiện tại (còn có thể finalize). Phiếu đã đi QUA mọi cấp finalize (approver
|
||||
// opt-out S97 → trình tiếp CEO) → false: KHÔNG làm mờ Bước sau + KHÔNG nói dối
|
||||
// "Kết thúc tại Cấp X" khi phiếu đang nằm inbox CEO (A/018: user tưởng đã duyệt
|
||||
// xong nên trách "lũy kế không bắt"). Con-trỏ null (Nháp/TraLai) → giữ config như cũ.
|
||||
var anyReachableFinalize = currentIdx is int finCi && currentLevel is int finCl
|
||||
? steps.Select((s, si) => (s, si)).Any(t => t.si >= finCi
|
||||
&& t.s.Levels.Any(l => l.AllowApproverFinalize && (t.si > finCi || l.Order >= finCl)))
|
||||
: true;
|
||||
endsBeforeCeo = phase == PurchaseEvaluationPhase.DaDuyet
|
||||
? e.EndedByLevelFinalize
|
||||
: true;
|
||||
: anyReachableFinalize;
|
||||
finalizeStepName = finalizeStep.DepartmentId is Guid fsd
|
||||
&& stepDeptNames.TryGetValue(fsd, out var fsdn) ? fsdn : finalizeStep.Name;
|
||||
finalizeLevelName = finalizeLevel?.Name;
|
||||
@ -1208,7 +1241,7 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
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.ProSuggestedPriceNote, e.CcmSuggestedPriceNote, // [Mig 57] ghi chú giải thích giá
|
||||
e.ApprovedPriceAmount, e.ApprovedPriceSource, // [Mig 54] giá chốt người duyệt chọn
|
||||
e.ApprovedPriceAmount, e.ApprovedPriceSource, e.ApprovedPriceMaxAmount, // [Mig 54 + S117] giá chốt (+ Max khoảng ProMinMax)
|
||||
canEditProSuggested, canEditCcmSuggested, // [Mig 54] capability role-gate
|
||||
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
|
||||
currentApproval, approvalFlow,
|
||||
|
||||
@ -32,6 +32,9 @@ public interface IPurchaseEvaluationWorkflowService
|
||||
bool finalizeByCcmDelegation = false,
|
||||
decimal? approvedPriceAmount = null,
|
||||
string? approvedPriceSource = null,
|
||||
// [S117 2026-07-14 — anh Kiệt FDC] Max của khoảng khi source="ProMinMax" (duyệt theo Min–Max).
|
||||
// null = giá đơn (giữ nguyên Mig 54); != null ⟺ source=="ProMinMax", Amount=Min.
|
||||
decimal? approvedPriceMaxAmount = null,
|
||||
// [S96 2026-07-01 — anh Kiệt FDC] Người duyệt BỎ tick "Cấp này KẾT THÚC quy trình"
|
||||
// (AllowApproverFinalize) → false = KHÔNG finalize, vẫn trình tiếp CEO/cấp sau.
|
||||
// Default true = giữ hành vi cũ (cấp cấu hình finalize → auto kết thúc). Đảo S89 auto→opt-out.
|
||||
|
||||
@ -92,11 +92,18 @@ public class PurchaseEvaluation : AuditableEntity
|
||||
|
||||
// [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 =
|
||||
// ApprovedPriceSource ∈ {Ncc, ProMin, ProMax, Ccm, ProMinMax} = 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; }
|
||||
|
||||
// [S117 2026-07-14 — anh Kiệt FDC] "Duyệt theo KHOẢNG": khi PRO có cả Min & Max, người duyệt
|
||||
// chọn CẢ HAI (source="ProMinMax") → ApprovedPriceAmount = Min, ApprovedPriceMaxAmount = Max.
|
||||
// Bất-biến: ApprovedPriceMaxAmount != null ⟺ ApprovedPriceSource == "ProMinMax". Source đơn → null
|
||||
// (giữ nguyên Mig 54). Range thuần record-of-decision hiển thị — HĐ-gen KHÔNG đọc (GiaTri = SUM
|
||||
// báo giá NCC được chọn). Mig 66 AddColumn nullable, no backfill (phiếu cũ nguyên vẹn).
|
||||
public decimal? ApprovedPriceMaxAmount { get; set; }
|
||||
|
||||
// [S97 2026-07-01 — anh Kiệt FDC] RUNTIME fact: phiếu này ĐÃ kết thúc sớm bằng level-finalize
|
||||
// ("Duyệt thay CEO", short-circuit TRƯỚC cấp cuối/CEO)? Set true DUY NHẤT ở nhánh finalize của
|
||||
// ApproveV2Async. Default false = phiếu đi HẾT quy trình (qua CEO). Phân biệt CONFIG "workflow
|
||||
|
||||
@ -33,6 +33,8 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
|
||||
b.Property(x => x.ProSuggestedMaxPrice).HasPrecision(18, 2);
|
||||
b.Property(x => x.CcmSuggestedPrice).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedPriceAmount).HasPrecision(18, 2);
|
||||
// [S117 Mig 66] Max của khoảng duyệt (source ProMinMax) — mirror precision (18,2).
|
||||
b.Property(x => x.ApprovedPriceMaxAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ApprovedPriceSource).HasMaxLength(20);
|
||||
// [Mig 57 2026-06-19] Ghi chú giải thích giá đề xuất PRO/CCM — nvarchar(1000)
|
||||
// nullable, KHÔNG index (free-text). Mirror HoSoLink + PeWorkItemBudget.CcmNote.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPeApprovedPriceRange : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ApprovedPriceMaxAmount",
|
||||
table: "PurchaseEvaluations",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ApprovedPriceMaxAmount",
|
||||
table: "PurchaseEvaluations");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4686,6 +4686,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("ApprovedPriceMaxAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ApprovedPriceSource")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
@ -47,6 +47,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
bool finalizeByCcmDelegation = false,
|
||||
decimal? approvedPriceAmount = null,
|
||||
string? approvedPriceSource = null,
|
||||
decimal? approvedPriceMaxAmount = null,
|
||||
bool applyLevelFinalize = true,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@ -288,7 +289,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// V2 path nhận flag, V1 legacy throw nếu non-admin gọi skipToFinal=true.
|
||||
if (evaluation.ApprovalWorkflowId is Guid awId)
|
||||
{
|
||||
await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, applyLevelFinalize, ct);
|
||||
await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount, applyLevelFinalize, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -659,6 +660,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
bool finalizeByCcmDelegation,
|
||||
decimal? approvedPriceAmount,
|
||||
string? approvedPriceSource,
|
||||
decimal? approvedPriceMaxAmount,
|
||||
bool applyLevelFinalize,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@ -855,7 +857,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// [S97 anh Kiệt FDC] Đánh dấu RUNTIME: phiếu kết thúc sớm tại cấp finalize (không lên CEO)
|
||||
// → EndsBeforeCeo display đọc cờ này thay vì suy từ config (phiếu opt-out lên CEO hiện đúng).
|
||||
evaluation.EndedByLevelFinalize = true;
|
||||
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource);
|
||||
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.CurrentApprovalLevelOrder = null;
|
||||
@ -893,7 +895,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
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);
|
||||
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.CurrentApprovalLevelOrder = null;
|
||||
@ -925,7 +927,7 @@ public class PurchaseEvaluationWorkflowService(
|
||||
{
|
||||
// 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);
|
||||
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
|
||||
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
|
||||
evaluation.CurrentWorkflowStepIndex = null;
|
||||
evaluation.CurrentApprovalLevelOrder = null;
|
||||
@ -947,22 +949,38 @@ public class PurchaseEvaluationWorkflowService(
|
||||
// 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" };
|
||||
// [S117 2026-07-14 — anh Kiệt FDC] +"ProMinMax" = duyệt theo KHOẢNG: Amount=Min, maxAmount=Max.
|
||||
internal const string ProMinMaxSource = "ProMinMax";
|
||||
private static readonly string[] ValidApprovedPriceSources = { "Ncc", "ProMin", "ProMax", "Ccm", ProMinMaxSource };
|
||||
private static void ApplyApprovedPriceOnFinalize(
|
||||
PurchaseEvaluation evaluation, bool isSystem, decimal? amount, string? source)
|
||||
PurchaseEvaluation evaluation, bool isSystem, decimal? amount, string? source, decimal? maxAmount = null)
|
||||
{
|
||||
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.");
|
||||
"Chọn 1 giá chốt (NCC / PRO Min / PRO Max / CCM / cả Min–Max) 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).");
|
||||
"Nguồn giá chốt không hợp lệ (phải là NCC / PRO Min / PRO Max / CCM / cả Min–Max).");
|
||||
// [S117] "Duyệt theo khoảng" (ProMinMax): bắt buộc có cả Max, Amount=Min, Min ≤ Max.
|
||||
if (source == ProMinMaxSource)
|
||||
{
|
||||
if (maxAmount is null)
|
||||
throw new ConflictException("Duyệt theo khoảng (cả Min–Max) phải có cả giá Min và giá Max.");
|
||||
if (amount > maxAmount)
|
||||
throw new ConflictException("Giá Min không được lớn hơn giá Max khi duyệt theo khoảng.");
|
||||
}
|
||||
else if (maxAmount is not null)
|
||||
{
|
||||
// Source đơn mà kèm Max = wire-bug (FE gửi sai) → chặn, giữ bất-biến Max⟺ProMinMax.
|
||||
throw new ConflictException("Chỉ nguồn 'cả Min–Max' mới được kèm giá Max.");
|
||||
}
|
||||
evaluation.ApprovedPriceAmount = amount;
|
||||
evaluation.ApprovedPriceSource = source;
|
||||
evaluation.ApprovedPriceMaxAmount = source == ProMinMaxSource ? maxAmount : null;
|
||||
}
|
||||
|
||||
// ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps =====
|
||||
|
||||
@ -144,4 +144,73 @@ public class PeFinalizeProjectionTests
|
||||
res.Single(x => x.Id == pePlain).EndsBeforeCeo.Should().BeFalse();
|
||||
res.Single(x => x.Id == peV1).EndsBeforeCeo.Should().BeFalse(); // V1 null-workflow tự đúng
|
||||
}
|
||||
|
||||
// [S117 anh Kiệt/Kenny — bug "duyệt rồi không bắt lũy kế"] Phiếu CHƯA DaDuyet (ChoDuyet) nay
|
||||
// đọc EndsBeforeCeo POINTER-AWARE: chỉ true khi CÒN cấp finalize ở-hoặc-SAU con-trỏ hiện tại
|
||||
// (phiếu còn có thể kết thúc sớm). Phiếu đã đi QUA cấp finalize (approver opt-out S97 → trình
|
||||
// tiếp CEO) → false. Repro A/018: workflow có cấp finalize (Bước 2) nhưng người duyệt KHÔNG tick
|
||||
// "Cấp này KẾT THÚC" → con-trỏ trôi lên Bước 3 (CEO) → TRƯỚC fix config .Any()=true → sơ đồ nói
|
||||
// dối "Kết thúc tại CCM" khi phiếu đang nằm inbox CEO. Test qua ListPurchaseEvaluationsQuery
|
||||
// (bao gồm ChoDuyet) = cũng verify EF subquery :647 (Step.Order-1 vs CurrentWorkflowStepIndex)
|
||||
// translate trên SQLite (rủi ro reviewer). rank cấp finalize = Step.Order-1 (Order tuần-tự s+1).
|
||||
private static Guid SeedChoDuyetPe(
|
||||
TestApplicationDbContext db, Guid projectId, Guid wfId, string code,
|
||||
int stepIdx, int levelOrder)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
db.PurchaseEvaluations.Add(new PurchaseEvaluation
|
||||
{
|
||||
Id = id,
|
||||
MaPhieu = code,
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
TenGoiThau = $"Gói {code}",
|
||||
ProjectId = projectId,
|
||||
ApprovalWorkflowId = wfId,
|
||||
CurrentWorkflowStepIndex = stepIdx, // 0-based rank vào steps.OrderBy(Order)
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
EndedByLevelFinalize = false, // chưa finalize (đang đi trong quy trình)
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListPe_EndsBeforeCeo_ChoDuyet_PointerAware_PastFinalizeIsFalse()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approver = await fix.CreateUserAsync("s117-appr@test.local", "Approver", null, new[] { AppRoles.CostControl });
|
||||
|
||||
var project = new Project { Id = Guid.NewGuid(), Code = "S117P", Name = "Dự án S117" };
|
||||
db.Projects.Add(project);
|
||||
|
||||
// 3 Bước: Bước 1 (no-flag) → Bước 2 (Cấp 1 FINALIZE) → Bước 3 (no-flag = CEO).
|
||||
// finalize = step-index 1 (Bước 2), level Order 1.
|
||||
var wf = BuildWorkflow("QT-S117", approver.Id,
|
||||
("Bước 1", new[] { ("Cấp 1", false) }),
|
||||
("Bước 2", new[] { ("Cấp 1", true) }),
|
||||
("Bước 3", new[] { ("Cấp 1", false) }));
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
|
||||
// con-trỏ AT Bước 1 (index 0) — TRƯỚC finalize → còn có thể kết thúc sớm → TRUE (heads-up giữ).
|
||||
var peBefore = SeedChoDuyetPe(db, project.Id, wf.Id, "PE-BEFORE", stepIdx: 0, levelOrder: 1);
|
||||
// con-trỏ AT cấp finalize (Bước 2 index 1 · Cấp 1) — còn có thể finalize → TRUE.
|
||||
var peAtFin = SeedChoDuyetPe(db, project.Id, wf.Id, "PE-AT", stepIdx: 1, levelOrder: 1);
|
||||
// 🔴 REPRO A/018: con-trỏ QUA finalize (Bước 3 index 2) — approver opt-out → lên CEO → FALSE.
|
||||
var pePast = SeedChoDuyetPe(db, project.Id, wf.Id, "PE-PAST", stepIdx: 2, levelOrder: 1);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// Admin bypass IDOR scope (ListPurchaseEvaluations :576). UserId bất-kỳ (không persist FK).
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
var handler = new ListPurchaseEvaluationsQueryHandler(db, admin);
|
||||
// EF subquery :647 (Step.Order-1 vs CurrentWorkflowStepIndex) PHẢI translate — không ném = pass smoke.
|
||||
var res = await handler.Handle(new ListPurchaseEvaluationsQuery(), CancellationToken.None);
|
||||
|
||||
res.Items.Single(x => x.Id == peBefore).EndsBeforeCeo.Should().BeTrue(
|
||||
"con-trỏ TRƯỚC cấp finalize → phiếu còn có thể kết thúc sớm → giữ heads-up");
|
||||
res.Items.Single(x => x.Id == peAtFin).EndsBeforeCeo.Should().BeTrue(
|
||||
"con-trỏ ĐANG ở cấp finalize → còn có thể tick KẾT THÚC → true");
|
||||
res.Items.Single(x => x.Id == pePast).EndsBeforeCeo.Should().BeFalse(
|
||||
"🔴 A/018: con-trỏ đã QUA cấp finalize (đang chờ CEO) → KHÔNG được nói dối 'Kết thúc tại CCM'");
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,12 +18,24 @@ namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
// 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):
|
||||
// Contract ApplyApprovedPriceOnFinalize(evaluation, isSystem, amount, source, maxAmount=null):
|
||||
// - 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
|
||||
// - source null/rác (∉ {Ncc,ProMin,ProMax,Ccm,ProMinMax}) → ConflictException
|
||||
// - hợp lệ → set evaluation.ApprovedPriceAmount/Source
|
||||
//
|
||||
// ===== NEW (S117 2026-07-14 anh Kiệt FDC) — "Duyệt theo KHOẢNG" (source="ProMinMax") =====
|
||||
// Khi PRO có cả Min & Max, người duyệt cấp cuối chọn CẢ HAI làm giá chốt → Amount=Min, MaxAmount=Max.
|
||||
// Prod: ApplyApprovedPriceOnFinalize +param `decimal? maxAmount` (line 955-984); ValidApprovedPriceSources
|
||||
// +"ProMinMax"; TransitionAsync/ApproveV2Async +param approvedPriceMaxAmount (SAU source, TRƯỚC applyLevelFinalize).
|
||||
// - source="ProMinMax" + maxAmount null → Conflict "*phải có cả giá Min và giá Max*"
|
||||
// - source="ProMinMax" + amount > maxAmount → Conflict "*không được lớn hơn giá Max*" (boundary `>`: min==max OK)
|
||||
// - source ĐƠN (Ncc/ProMin/ProMax/Ccm) + maxAmount≠null → Conflict "*kèm giá Max*" (wire-bug guard)
|
||||
// - happy → Amount=amount(Min), Source=source, MaxAmount = (source=="ProMinMax" ? maxAmount : null)
|
||||
// Bất-biến: ApprovedPriceMaxAmount != null ⟺ ApprovedPriceSource == "ProMinMax".
|
||||
// Financial-critical → test-before merge. NEW test đi qua reachable terminal path (mirror case 1), KHÔNG
|
||||
// gọi thẳng private helper. InvokeApply reflection helper cập-nhật lên 5 args cho existing unit test 4/4b/4c.
|
||||
//
|
||||
// ⚠️ 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ó
|
||||
@ -101,7 +113,8 @@ public class PeApprovedPriceFinalizeTests
|
||||
|
||||
private static Task ApproveAsync(
|
||||
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
|
||||
string[] roles, decimal? amount, string? source) =>
|
||||
string[] roles, decimal? amount, string? source,
|
||||
decimal? maxAmount = null) =>
|
||||
svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
@ -111,6 +124,7 @@ public class PeApprovedPriceFinalizeTests
|
||||
comment: null,
|
||||
approvedPriceAmount: amount,
|
||||
approvedPriceSource: source,
|
||||
approvedPriceMaxAmount: maxAmount, // [S117] duyệt theo KHOẢNG — SAU source, mirror prod signature
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// =====================================================================
|
||||
@ -226,6 +240,147 @@ public class PeApprovedPriceFinalizeTests
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
|
||||
pe.ApprovedPriceSource.Should().Be(source);
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidAmount);
|
||||
// [S117 regression] nguồn ĐƠN → MaxAmount PHẢI null (bất-biến Max ⟺ ProMinMax).
|
||||
// Chứng signature mới (thêm maxAmount) KHÔNG phá path cũ.
|
||||
pe.ApprovedPriceMaxAmount.Should().BeNull("nguồn đơn không kèm giá Max");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// [S117] "Duyệt theo KHOẢNG" (source="ProMinMax") — reachable terminal path (mirror case 1).
|
||||
// Amount = giá Min, ApprovedPriceMaxAmount = giá Max. Financial-critical → test-before merge.
|
||||
// =====================================================================
|
||||
private const decimal ProMinMaxSource_MinPrice = 100_000_000m; // giá Min PRO
|
||||
private const decimal ProMinMaxSource_MaxPrice = 130_000_000m; // giá Max PRO
|
||||
private const string ProMinMax = "ProMinMax";
|
||||
|
||||
// (a) HAPPY: source="ProMinMax", amount=Min(100), maxAmount=Max(130) → terminal set CẢ HAI.
|
||||
[Fact]
|
||||
public async Task ApproveV2_TerminalApprove_ProMinMax_ValidRange_SetsMinAndMax()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ceo = (await fix.CreateUserAsync("ceoAP-MM1@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
|
||||
|
||||
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-MM1");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
|
||||
amount: ProMinMaxSource_MinPrice, source: ProMinMax, maxAmount: ProMinMaxSource_MaxPrice);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "1 bước/1 cấp duyệt → terminal");
|
||||
pe.ApprovedPriceAmount.Should().Be(ProMinMaxSource_MinPrice, "duyệt theo khoảng: Amount = giá Min");
|
||||
pe.ApprovedPriceMaxAmount.Should().Be(ProMinMaxSource_MaxPrice, "duyệt theo khoảng: MaxAmount = giá Max");
|
||||
pe.ApprovedPriceSource.Should().Be(ProMinMax);
|
||||
}
|
||||
}
|
||||
|
||||
// (a-boundary) Min == Max HỢP LỆ — guard prod là `amount > maxAmount` (strict). Đổi thành
|
||||
// `>=` sẽ làm case này RED → lock ranh giới `>` vs `>=`.
|
||||
[Fact]
|
||||
public async Task ApproveV2_TerminalApprove_ProMinMax_MinEqualsMax_Ok()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ceo = (await fix.CreateUserAsync("ceoAP-MM2@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
|
||||
|
||||
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-MM2");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
|
||||
amount: ProMinMaxSource_MaxPrice, source: ProMinMax, maxAmount: ProMinMaxSource_MaxPrice);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
|
||||
pe.ApprovedPriceAmount.Should().Be(ProMinMaxSource_MaxPrice);
|
||||
pe.ApprovedPriceMaxAmount.Should().Be(ProMinMaxSource_MaxPrice, "min==max: khoảng suy biến 1 điểm vẫn set Max");
|
||||
pe.ApprovedPriceSource.Should().Be(ProMinMax);
|
||||
}
|
||||
}
|
||||
|
||||
// (b) THIẾU Max (maxAmount=null) → ConflictException, phiếu KHÔNG finalize (giữ ChoDuyet, Max null).
|
||||
[Fact]
|
||||
public async Task ApproveV2_TerminalApprove_ProMinMax_MissingMax_ThrowsConflict_NotFinalized()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ceo = (await fix.CreateUserAsync("ceoAP-MM3@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
|
||||
|
||||
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-MM3");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
|
||||
amount: ProMinMaxSource_MinPrice, source: ProMinMax, maxAmount: null);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*phải có cả giá Min và giá Max*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "thiếu Max → không finalize");
|
||||
reloaded.ApprovedPriceMaxAmount.Should().BeNull();
|
||||
reloaded.ApprovedPriceAmount.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// (c) Min > Max (amount=130, maxAmount=100) → ConflictException, KHÔNG finalize.
|
||||
[Fact]
|
||||
public async Task ApproveV2_TerminalApprove_ProMinMax_MinGreaterThanMax_ThrowsConflict_NotFinalized()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ceo = (await fix.CreateUserAsync("ceoAP-MM4@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
|
||||
|
||||
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-MM4");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
|
||||
amount: ProMinMaxSource_MaxPrice, source: ProMinMax, maxAmount: ProMinMaxSource_MinPrice); // 130 > 100
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*không được lớn hơn giá Max*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
reloaded.ApprovedPriceMaxAmount.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// (d) Nguồn ĐƠN (Ncc) kèm maxAmount → wire-bug guard: ConflictException, KHÔNG finalize. Giữ
|
||||
// bất-biến Max ⟺ ProMinMax (FE gửi Max cho nguồn đơn = sai → chặn).
|
||||
[Fact]
|
||||
public async Task ApproveV2_TerminalApprove_SingleSourceWithMax_ThrowsConflict_NotFinalized()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ceo = (await fix.CreateUserAsync("ceoAP-MM5@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
|
||||
|
||||
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-MM5");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
|
||||
amount: ProMinMaxSource_MinPrice, source: "Ncc", maxAmount: ProMinMaxSource_MaxPrice); // nguồn đơn + Max
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*kèm giá Max*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "nguồn đơn kèm Max = wire-bug → chặn");
|
||||
reloaded.ApprovedPriceMaxAmount.Should().BeNull();
|
||||
reloaded.ApprovedPriceAmount.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@ -278,7 +433,8 @@ public class PeApprovedPriceFinalizeTests
|
||||
// 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)
|
||||
PurchaseEvaluation evaluation, bool isSystem, decimal? amount, string? source,
|
||||
decimal? maxAmount = null)
|
||||
{
|
||||
var mi = typeof(PurchaseEvaluationWorkflowService).GetMethod(
|
||||
"ApplyApprovedPriceOnFinalize",
|
||||
@ -286,7 +442,9 @@ public class PeApprovedPriceFinalizeTests
|
||||
?? throw new InvalidOperationException("ApplyApprovedPriceOnFinalize không tìm thấy (đổi tên?).");
|
||||
try
|
||||
{
|
||||
mi.Invoke(null, new object?[] { evaluation, isSystem, amount, source });
|
||||
// [S117] prod method nay 5 params (thêm maxAmount) — Invoke cần đúng số arg (optional
|
||||
// KHÔNG tự bind qua reflection) nếu không TargetParameterCountException.
|
||||
mi.Invoke(null, new object?[] { evaluation, isSystem, amount, source, maxAmount });
|
||||
}
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is not null)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user