[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

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