[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

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:
pqhuy1987
2026-07-14 11:14:09 +07:00
parent 6f1f8aa898
commit 316a82f96d
18 changed files with 6762 additions and 36 deletions

View File

@ -1662,6 +1662,7 @@ const APPROVED_PRICE_SOURCE_LABEL: Record<string, string> = {
Ncc: 'Giá NCC', Ncc: 'Giá NCC',
ProMin: 'PRO Min', ProMin: 'PRO Min',
ProMax: 'PRO Max', ProMax: 'PRO Max',
ProMinMax: 'PRO MinMax',
Ccm: 'CCM', Ccm: 'CCM',
} }
function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) { 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]"> <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="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"> <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 && ( {ev.approvedPriceSource && (
<span className="ml-2 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700"> <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} {APPROVED_PRICE_SOURCE_LABEL[ev.approvedPriceSource] ?? ev.approvedPriceSource}

View File

@ -131,14 +131,22 @@ export function PeWorkflowPanel({
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order || (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
&& finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder) && finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder)
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). // Ứ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 = ([ const priceCandidates = ([
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal }, { source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
{ source: 'ProMin', label: 'PRO — Giá Min', amount: evaluation.proSuggestedMinPrice }, { source: 'ProMin', label: 'PRO — Giá Min', amount: evaluation.proSuggestedMinPrice },
{ source: 'ProMax', label: 'PRO — Giá Max', amount: evaluation.proSuggestedMaxPrice }, { 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 }, { source: 'Ccm', label: 'CCM đề xuất', amount: evaluation.ccmSuggestedPrice },
] as { source: string; label: string; amount: number | null | undefined }[]) ] as { source: string; label: string; amount: number | null | undefined; amountMax?: number | null }[])
.filter((c): c is { source: string; label: string; amount: number } => c.amount != null) .filter((c): c is { source: string; label: string; amount: number; amountMax?: number } => c.amount != null)
const selectedPriceAmount = priceCandidates.find(c => c.source === approvedPriceSource)?.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 // 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).
@ -203,6 +211,9 @@ export function PeWorkflowPanel({
applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true), applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true),
approvedPriceAmount: sendPrice ? selectedPriceAmount : null, approvedPriceAmount: sendPrice ? selectedPriceAmount : null,
approvedPriceSource: sendPrice ? approvedPriceSource : 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: () => { onSuccess: () => {
@ -693,7 +704,9 @@ export function PeWorkflowPanel({
onChange={() => setApprovedPriceSource(c.source)} onChange={() => setApprovedPriceSource(c.source)}
/> />
<span className="flex-1 text-slate-700">{c.label}</span> <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> </label>
))} ))}
{priceCandidates.length === 0 && ( {priceCandidates.length === 0 && (

View File

@ -486,6 +486,7 @@ export type PeDetailBundle = {
ccmSuggestedPriceNote: string | null ccmSuggestedPriceNote: string | null
approvedPriceAmount: number | null approvedPriceAmount: number | null
approvedPriceSource: string | null approvedPriceSource: string | null
approvedPriceMaxAmount: number | null
canEditProSuggestedPrice: boolean canEditProSuggestedPrice: boolean
canEditCcmSuggestedPrice: 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).

View File

@ -1662,6 +1662,7 @@ const APPROVED_PRICE_SOURCE_LABEL: Record<string, string> = {
Ncc: 'Giá NCC', Ncc: 'Giá NCC',
ProMin: 'PRO Min', ProMin: 'PRO Min',
ProMax: 'PRO Max', ProMax: 'PRO Max',
ProMinMax: 'PRO MinMax',
Ccm: 'CCM', Ccm: 'CCM',
} }
function SuggestedPriceRows({ ev }: { ev: PeDetailBundle }) { 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]"> <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="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"> <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 && ( {ev.approvedPriceSource && (
<span className="ml-2 rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700"> <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} {APPROVED_PRICE_SOURCE_LABEL[ev.approvedPriceSource] ?? ev.approvedPriceSource}

View File

@ -131,14 +131,22 @@ export function PeWorkflowPanel({
|| (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order || (finalizeFlowStep != null && stepOrder === finalizeFlowStep.order
&& finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder) && finalizeFlowLevelOrder != null && levelOrder > finalizeFlowLevelOrder)
// Ứng viên giá chốt — chỉ giá nào đã có giá trị (≠ null). // Ứ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 = ([ const priceCandidates = ([
{ source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal }, { source: 'Ncc', label: 'Giá NCC (giá chào thầu)', amount: evaluation.winnerQuoteTotal },
{ source: 'ProMin', label: 'PRO — Giá Min', amount: evaluation.proSuggestedMinPrice }, { source: 'ProMin', label: 'PRO — Giá Min', amount: evaluation.proSuggestedMinPrice },
{ source: 'ProMax', label: 'PRO — Giá Max', amount: evaluation.proSuggestedMaxPrice }, { 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 }, { source: 'Ccm', label: 'CCM đề xuất', amount: evaluation.ccmSuggestedPrice },
] as { source: string; label: string; amount: number | null | undefined }[]) ] as { source: string; label: string; amount: number | null | undefined; amountMax?: number | null }[])
.filter((c): c is { source: string; label: string; amount: number } => c.amount != null) .filter((c): c is { source: string; label: string; amount: number; amountMax?: number } => c.amount != null)
const selectedPriceAmount = priceCandidates.find(c => c.source === approvedPriceSource)?.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 // 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).
@ -203,6 +211,9 @@ export function PeWorkflowPanel({
applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true), applyLevelFinalize: !isReject && (approverFinalizeEligible ? applyLevelFinalize : true),
approvedPriceAmount: sendPrice ? selectedPriceAmount : null, approvedPriceAmount: sendPrice ? selectedPriceAmount : null,
approvedPriceSource: sendPrice ? approvedPriceSource : 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: () => { onSuccess: () => {
@ -693,7 +704,9 @@ export function PeWorkflowPanel({
onChange={() => setApprovedPriceSource(c.source)} onChange={() => setApprovedPriceSource(c.source)}
/> />
<span className="flex-1 text-slate-700">{c.label}</span> <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> </label>
))} ))}
{priceCandidates.length === 0 && ( {priceCandidates.length === 0 && (

View File

@ -488,6 +488,7 @@ export type PeDetailBundle = {
ccmSuggestedPriceNote: string | null ccmSuggestedPriceNote: string | null
approvedPriceAmount: number | null approvedPriceAmount: number | null
approvedPriceSource: string | null approvedPriceSource: string | null
approvedPriceMaxAmount: number | null
canEditProSuggestedPrice: boolean canEditProSuggestedPrice: boolean
canEditCcmSuggestedPrice: 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).

View File

@ -138,7 +138,8 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
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, 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(); return NoContent();
} }
@ -352,6 +353,8 @@ public record TransitionPeBody(
bool FinalizeByCcmDelegation = false, bool FinalizeByCcmDelegation = false,
decimal? ApprovedPriceAmount = null, decimal? ApprovedPriceAmount = null,
string? ApprovedPriceSource = null, string? ApprovedPriceSource = null,
// [S117 2026-07-14 — anh Kiệt FDC] Max của khoảng khi source="ProMinMax" (duyệt theo MinMax).
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. // [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); bool ApplyLevelFinalize = true);

View File

@ -284,6 +284,7 @@ public record PurchaseEvaluationDetailBundleDto(
string? CcmSuggestedPriceNote, string? CcmSuggestedPriceNote,
decimal? ApprovedPriceAmount, decimal? ApprovedPriceAmount,
string? ApprovedPriceSource, string? ApprovedPriceSource,
decimal? ApprovedPriceMaxAmount, // [S117] Max của khoảng khi source=ProMinMax (duyệt theo MinMax)
bool CanEditProSuggestedPrice, bool CanEditProSuggestedPrice,
bool CanEditCcmSuggestedPrice, 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 +

View File

@ -479,6 +479,8 @@ public record TransitionPurchaseEvaluationCommand(
bool FinalizeByCcmDelegation = false, bool FinalizeByCcmDelegation = false,
decimal? ApprovedPriceAmount = null, decimal? ApprovedPriceAmount = null,
string? ApprovedPriceSource = null, string? ApprovedPriceSource = null,
// [S117 2026-07-14 — anh Kiệt FDC] Max của khoảng khi source="ProMinMax" (duyệt theo MinMax).
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. // [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; bool ApplyLevelFinalize = true) : IRequest;
@ -501,9 +503,20 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<Tr
RuleFor(x => x.ApprovedPriceAmount).GreaterThanOrEqualTo(0) RuleFor(x => x.ApprovedPriceAmount).GreaterThanOrEqualTo(0)
.When(x => x.ApprovedPriceAmount.HasValue); .When(x => x.ApprovedPriceAmount.HasValue);
RuleFor(x => x.ApprovedPriceSource) 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) .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ả MinMax' (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.FinalizeByCcmDelegation,
request.ApprovedPriceAmount, request.ApprovedPriceAmount,
request.ApprovedPriceSource, request.ApprovedPriceSource,
request.ApprovedPriceMaxAmount,
request.ApplyLevelFinalize, request.ApplyLevelFinalize,
ct); ct);
} }
@ -642,12 +656,18 @@ public class ListPurchaseEvaluationsQueryHandler(
// (mirror winner-names — EF-safe; record-ctor projection KHÔNG cho biến trung // (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. // 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). // ApprovalWorkflowId==null → subquery rỗng → false/null (V1 tự đúng).
// [S97] runtime: phiếu DaDuyet đọc EndedByLevelFinalize (finalize sớm THẬT); // [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. // [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.Phase == PurchaseEvaluationPhase.DaDuyet
? x.e.EndedByLevelFinalize ? x.e.EndedByLevelFinalize
: (from lv in db.ApprovalWorkflowLevels : (from lv in db.ApprovalWorkflowLevels
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId 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(), select 1).Any(),
(from lv in db.ApprovalWorkflowLevels (from lv in db.ApprovalWorkflowLevels
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId 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 // [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 // winner-names (EF-safe). lv.Step!.Name simple navigation. ApprovalWorkflowId
// ==null → subquery rỗng → false/null (V1 tự đúng). // ==null → subquery rỗng → false/null (V1 tự đúng).
// [S97] runtime: phiếu DaDuyet đọc EndedByLevelFinalize (finalize sớm THẬT); // [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. // [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.Phase == PurchaseEvaluationPhase.DaDuyet
? x.e.EndedByLevelFinalize ? x.e.EndedByLevelFinalize
: (from lv in db.ApprovalWorkflowLevels : (from lv in db.ApprovalWorkflowLevels
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId 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(), select 1).Any(),
(from lv in db.ApprovalWorkflowLevels (from lv in db.ApprovalWorkflowLevels
where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId where lv.AllowApproverFinalize && lv.Step!.ApprovalWorkflowId == x.e.ApprovalWorkflowId
@ -1146,12 +1171,20 @@ public class GetPurchaseEvaluationQueryHandler(
{ {
var finalizeLevel = finalizeStep.Levels var finalizeLevel = finalizeStep.Levels
.Where(l => l.AllowApproverFinalize).OrderBy(l => l.Order).FirstOrDefault(); .Where(l => l.AllowApproverFinalize).OrderBy(l => l.Order).FirstOrDefault();
// [S97] endsBeforeCeo theo RUNTIME cho phiếu DaDuyet (đọc EndedByLevelFinalize): // [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 // [S117 anh Kiệt — bug "duyệt rồi không bắt lũy kế"] Phiếu CHƯA DaDuyet nay
// CEO". Phiếu chưa xong (ChoDuyet…) giữ heads-up config (còn cấp finalize phía trước). // 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 endsBeforeCeo = phase == PurchaseEvaluationPhase.DaDuyet
? e.EndedByLevelFinalize ? e.EndedByLevelFinalize
: true; : anyReachableFinalize;
finalizeStepName = finalizeStep.DepartmentId is Guid fsd finalizeStepName = finalizeStep.DepartmentId is Guid fsd
&& stepDeptNames.TryGetValue(fsd, out var fsdn) ? fsdn : finalizeStep.Name; && stepDeptNames.TryGetValue(fsd, out var fsdn) ? fsdn : finalizeStep.Name;
finalizeLevelName = finalizeLevel?.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.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.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.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 canEditProSuggested, canEditCcmSuggested, // [Mig 54] capability role-gate
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions, e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
currentApproval, approvalFlow, currentApproval, approvalFlow,

View File

@ -32,6 +32,9 @@ public interface IPurchaseEvaluationWorkflowService
bool finalizeByCcmDelegation = false, bool finalizeByCcmDelegation = false,
decimal? approvedPriceAmount = null, decimal? approvedPriceAmount = null,
string? approvedPriceSource = null, string? approvedPriceSource = null,
// [S117 2026-07-14 — anh Kiệt FDC] Max của khoảng khi source="ProMinMax" (duyệt theo MinMax).
// 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" // [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. // (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. // Default true = giữ hành vi cũ (cấp cấu hình finalize → auto kết thúc). Đảo S89 auto→opt-out.

View File

@ -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"). // [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). // 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. // 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 decimal? ApprovedPriceAmount { get; set; }
public string? ApprovedPriceSource { 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 // [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 // ("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 // ApproveV2Async. Default false = phiếu đi HẾT quy trình (qua CEO). Phân biệt CONFIG "workflow

View File

@ -33,6 +33,8 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.Property(x => x.ProSuggestedMaxPrice).HasPrecision(18, 2); b.Property(x => x.ProSuggestedMaxPrice).HasPrecision(18, 2);
b.Property(x => x.CcmSuggestedPrice).HasPrecision(18, 2); b.Property(x => x.CcmSuggestedPrice).HasPrecision(18, 2);
b.Property(x => x.ApprovedPriceAmount).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); 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) // [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. // nullable, KHÔNG index (free-text). Mirror HoSoLink + PeWorkItemBudget.CcmNote.

View File

@ -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");
}
}
}

View File

@ -4686,6 +4686,10 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasPrecision(18, 2) .HasPrecision(18, 2)
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<decimal?>("ApprovedPriceMaxAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ApprovedPriceSource") b.Property<string>("ApprovedPriceSource")
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("nvarchar(20)"); .HasColumnType("nvarchar(20)");

View File

@ -47,6 +47,7 @@ public class PurchaseEvaluationWorkflowService(
bool finalizeByCcmDelegation = false, bool finalizeByCcmDelegation = false,
decimal? approvedPriceAmount = null, decimal? approvedPriceAmount = null,
string? approvedPriceSource = null, string? approvedPriceSource = null,
decimal? approvedPriceMaxAmount = null,
bool applyLevelFinalize = true, bool applyLevelFinalize = true,
CancellationToken ct = default) 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. // 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, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, applyLevelFinalize, ct); await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount, applyLevelFinalize, ct);
} }
else else
{ {
@ -659,6 +660,7 @@ public class PurchaseEvaluationWorkflowService(
bool finalizeByCcmDelegation, bool finalizeByCcmDelegation,
decimal? approvedPriceAmount, decimal? approvedPriceAmount,
string? approvedPriceSource, string? approvedPriceSource,
decimal? approvedPriceMaxAmount,
bool applyLevelFinalize, bool applyLevelFinalize,
CancellationToken ct) 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) // [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). // → EndsBeforeCeo display đọc cờ này thay vì suy từ config (phiếu opt-out lên CEO hiện đúng).
evaluation.EndedByLevelFinalize = true; evaluation.EndedByLevelFinalize = true;
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource); ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource, approvedPriceMaxAmount);
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet; evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null; evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null; evaluation.CurrentApprovalLevelOrder = null;
@ -893,7 +895,7 @@ public class PurchaseEvaluationWorkflowService(
throw new ConflictException( 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."); $"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.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null; evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = 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 // 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. // 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.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null; evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = 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 // 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 // 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. // 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( 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 (amount is null)
{ {
if (!isSystem) if (!isSystem)
throw new ConflictException( 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ả MinMax) trước khi duyệt cấp cuối.");
return; // auto-approve hệ thống (SLA) — không bắt chọn giá return; // auto-approve hệ thống (SLA) — không bắt chọn giá
} }
if (source is null || !ValidApprovedPriceSources.Contains(source)) if (source is null || !ValidApprovedPriceSources.Contains(source))
throw new ConflictException( 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ả MinMax).");
// [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ả MinMax) 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ả MinMax' mới được kèm giá Max.");
}
evaluation.ApprovedPriceAmount = amount; evaluation.ApprovedPriceAmount = amount;
evaluation.ApprovedPriceSource = source; evaluation.ApprovedPriceSource = source;
evaluation.ApprovedPriceMaxAmount = source == ProMinMaxSource ? maxAmount : null;
} }
// ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps ===== // ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps =====

View File

@ -144,4 +144,73 @@ public class PeFinalizeProjectionTests
res.Single(x => x.Id == pePlain).EndsBeforeCeo.Should().BeFalse(); res.Single(x => x.Id == pePlain).EndsBeforeCeo.Should().BeFalse();
res.Single(x => x.Id == peV1).EndsBeforeCeo.Should().BeFalse(); // V1 null-workflow tự đúng 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'");
}
} }

View File

@ -18,12 +18,24 @@ namespace SolutionErp.Infrastructure.Tests.Services;
// và (b) CCM-delegation finalize (line 853 — cover ở PeCcmThresholdFinalizeTests). // và (b) CCM-delegation finalize (line 853 — cover ở PeCcmThresholdFinalizeTests).
// Test theo CODE đã land (S34 rule — KHÔNG touch production). // 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 → ConflictException ("Chọn 1 giá chốt...")
// - amount null + isSystem → return im lặng (auto-approve hệ thống MIỄN chọn giá) // - 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 // - 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 // ⚠️ OBSERVATION (REPORT em main, KHÔNG fix): isSystem KHÔNG reachable qua public
// ApproveV2Async — branch APPROVE STEP (TransitionAsync line 243) gate // ApproveV2Async — branch APPROVE STEP (TransitionAsync line 243) gate
// `decision == Approve`, trong khi isSystem cần `decision == AutoApprove`. PE KHÔNG có // `decision == Approve`, trong khi isSystem cần `decision == AutoApprove`. PE KHÔNG có
@ -101,7 +113,8 @@ public class PeApprovedPriceFinalizeTests
private static Task ApproveAsync( private static Task ApproveAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId, PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
string[] roles, decimal? amount, string? source) => string[] roles, decimal? amount, string? source,
decimal? maxAmount = null) =>
svc.TransitionAsync( svc.TransitionAsync(
evaluation: pe, evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet, targetPhase: PurchaseEvaluationPhase.ChoDuyet,
@ -111,6 +124,7 @@ public class PeApprovedPriceFinalizeTests
comment: null, comment: null,
approvedPriceAmount: amount, approvedPriceAmount: amount,
approvedPriceSource: source, approvedPriceSource: source,
approvedPriceMaxAmount: maxAmount, // [S117] duyệt theo KHOẢNG — SAU source, mirror prod signature
ct: CancellationToken.None); ct: CancellationToken.None);
// ===================================================================== // =====================================================================
@ -226,6 +240,147 @@ public class PeApprovedPriceFinalizeTests
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet); pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
pe.ApprovedPriceSource.Should().Be(source); pe.ApprovedPriceSource.Should().Be(source);
pe.ApprovedPriceAmount.Should().Be(ValidAmount); 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 // Reflection helper — invoke private static ApplyApprovedPriceOnFinalize. Unwrap
// TargetInvocationException để assertion bắt đúng inner ConflictException. // TargetInvocationException để assertion bắt đúng inner ConflictException.
private static void InvokeApply( 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( var mi = typeof(PurchaseEvaluationWorkflowService).GetMethod(
"ApplyApprovedPriceOnFinalize", "ApplyApprovedPriceOnFinalize",
@ -286,7 +442,9 @@ public class PeApprovedPriceFinalizeTests
?? throw new InvalidOperationException("ApplyApprovedPriceOnFinalize không tìm thấy (đổi tên?)."); ?? throw new InvalidOperationException("ApplyApprovedPriceOnFinalize không tìm thấy (đổi tên?).");
try 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) catch (TargetInvocationException ex) when (ex.InnerException is not null)
{ {