[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

@ -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 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.
bool ApplyLevelFinalize = true);

View File

@ -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 MinMax)
bool CanEditProSuggestedPrice,
bool CanEditCcmSuggestedPrice,
// 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,
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 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.
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ả 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.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,

View File

@ -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 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"
// (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.

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").
// 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

View File

@ -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.

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)
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("ApprovedPriceMaxAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ApprovedPriceSource")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");

View File

@ -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ả MinMax) 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ả 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.ApprovedPriceSource = source;
evaluation.ApprovedPriceMaxAmount = source == ProMinMaxSource ? maxAmount : null;
}
// ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps =====