[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:
@ -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