[CLAUDE] PurchaseEvaluation: drafter tự gỡ cờ GẤP + ngân sách kỳ này tự nhảy từ ban-hành (anh Kiệt FDC UAT R2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m46s

D2 — Người KHAI phiếu tự GỠ cờ GẤP của mình (nới luật S77 "GỠ=chỉ TP"). BE handler
restructure SET/UNSET: GẮN giữ role-only (PRO/CCM/Admin); GỠ +drafter (DrafterUserId==actor)
→ drafter/Admin clear CẢ 2 cờ, TP-vai clear cờ vai mình. FE canToggle +isDrafter.
D3 — "Ngân sách kỳ này" (row3) tự nhảy từ "Ngân sách Ban hành lần đầu"
(PeWorkItemBudget.ProInitialAmount) khi chưa nhập tay. Submit-guard BE + FE missingForApproval
dùng effective = budgetPeriodAmount ?? proInitialAmount (mirror 2 tầng, chống bypass gotcha #44).

BE 2 file (Urgent handler + Workflow submit-guard). FE PeDetailTabs ×2 app SHA-identical.
Test +5 PeUrgentToggleAuthzTests (drafter-self-unset authz matrix) → 371 PASS. BE slnx + 2 FE build PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-23 14:00:33 +07:00
parent 24f0083d78
commit a5e936fc0a
5 changed files with 219 additions and 42 deletions

View File

@ -32,10 +32,18 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// • plain CostControl (no DeptManager) → ForbiddenException + NO mutation
// • role khác → ForbiddenException ("*GỠ cờ gấp*")
//
// Production gate (PurchaseEvaluationUrgentFeatures.cs:49-55):
// isPro = IsUrgent ? hasPro : (hasPro && isDeptManager);
// isCcm = IsUrgent ? hasCcm : (hasCcm && isDeptManager);
// if (!isAdmin && !isPro && !isCcm) throw Forbidden(<msg theo IsUrgent>);
// [S85 anh Kiệt — NGƯỜI KHAI tự gỡ cờ của mình] UNSET branch mở thêm 1 lối:
// chính người khai phiếu (actor.UserId == entity.DrafterUserId) được GỠ cờ — và gỡ
// CẢ 2 cờ (un-urgent phiếu), GIỐNG Admin. SET branch KHÔNG đổi (drafter vẫn không tự
// GẮN — chỉ role chức năng/Admin gắn). Production (PurchaseEvaluationUrgentFeatures.cs):
// UNSET allow = isAdmin || (hasPro && isDeptManager) || (hasCcm && isDeptManager) || isDrafter
// isDrafter ⇒ clear CẢ 2 (cùng nhánh với isAdmin, line 70).
//
// Production gate (SET — PurchaseEvaluationUrgentFeatures.cs:57):
// if (!isAdmin && !hasPro && !hasCcm) throw Forbidden("*đánh dấu phiếu gấp*");
// Production gate (UNSET — line 66-69):
// isProTp = hasPro && isDeptManager; isCcmTp = hasCcm && isDeptManager;
// if (!isAdmin && !isProTp && !isCcmTp && !isDrafter) throw Forbidden("*GỠ cờ gấp*");
//
// Notify CEO (Director) là best-effort try/catch khi false→true → KHÔNG assert
// notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt
@ -46,10 +54,12 @@ namespace SolutionErp.Infrastructure.Tests.Application;
public class PeUrgentToggleAuthzTests
{
// ICurrentUser stub — Roles set per test (giống FakeCurrentUser PeWorkItemGuardTests
// nhưng configurable roles thay vì hardcode Drafter).
// nhưng configurable roles thay vì hardcode Drafter). UserId settable (init) để test
// [S85] nhánh drafter (actor.UserId == entity.DrafterUserId) — default random khi
// không quan tâm danh tính (mọi test cũ không phụ thuộc UserId cụ thể).
private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
{
public Guid? UserId { get; } = Guid.NewGuid();
public Guid? UserId { get; init; } = Guid.NewGuid();
public string? Email { get; } = "actor@test.local";
public string? FullName { get; } = "Actor Test";
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
@ -61,11 +71,15 @@ public class PeUrgentToggleAuthzTests
=> new(db, currentUser, um, new NoOpNotificationService());
// PE chưa gấp (cả 2 cờ false) — state mặc định để verify role nào set cờ nào.
// [S85] drafterUserId tùy chọn: gán = actor.UserId để test nhánh "người khai tự gỡ".
// Default random (khác mọi FakeCurrentUser.UserId) → các test role-only KHÔNG vô tình
// dính nhánh drafter.
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
bool urgentByPro = false,
bool urgentByCcm = false,
string code = "PE-URG-001")
string code = "PE-URG-001",
Guid? drafterUserId = null)
{
var pe = new PurchaseEvaluation
{
@ -75,7 +89,7 @@ public class PeUrgentToggleAuthzTests
MaPhieu = code,
TenGoiThau = "Gói thầu test urgent",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
DrafterUserId = drafterUserId ?? Guid.NewGuid(),
IsUrgentByPro = urgentByPro,
IsUrgentByCcm = urgentByCcm,
};
@ -315,4 +329,141 @@ public class PeUrgentToggleAuthzTests
await act.Should().ThrowAsync<NotFoundException>();
}
// =====================================================================
// [S85 anh Kiệt — NGƯỜI KHAI tự gỡ cờ của mình] Nhánh drafter mới ở UNSET.
// =====================================================================
// ---------------------------------------------------------------------
// 10. UNSET — Drafter (người KHAI phiếu) role THƯỜNG (KHÔNG PRO/CCM/TP/Admin) →
// OK gỡ, clear CẢ 2 cờ. Trục chính S85: actor.UserId == DrafterUserId mở lối gỡ,
// và gỡ giống Admin (un-urgent toàn phiếu) dù không có role chức năng nào.
// Pre-seed cờ ĐỎ (ByPro) đang bật để chứng minh CLEAR thật, không phải vốn-dĩ false.
// ---------------------------------------------------------------------
[Fact]
public async Task Drafter_Unset_ClearsBothFlags_NoForbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var drafterId = Guid.NewGuid();
// Phiếu do drafterId khai, cờ ĐỎ đang bật (vd PRO bật trước đó).
var pe = await SeedPeAsync(db, urgentByPro: true, code: "PE-URG-010", drafterUserId: drafterId);
// Actor = chính drafter, role THƯỜNG (Drafter — KHÔNG Procurement/CostControl/DeptManager/Admin),
// UserId trùng DrafterUserId của phiếu.
var actor = new FakeCurrentUser(AppRoles.Drafter) { UserId = drafterId };
var handler = BuildHandler(db, um, actor);
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeFalse("người khai gỡ cờ → clear ĐỎ");
reloaded.IsUrgentByCcm.Should().BeFalse("người khai gỡ → clear CẢ 2 (giống Admin)");
}
// ---------------------------------------------------------------------
// 11. UNSET — non-drafter + non-TP + non-admin (Finance, role trung tính) →
// ForbiddenException, cờ KHÔNG đổi. Chốt: chỉ MỞ lối gỡ cho đúng người khai —
// người KHÁC role thường vẫn bị chặn (UserId ngẫu nhiên ≠ DrafterUserId).
// ---------------------------------------------------------------------
[Fact]
public async Task NonDrafterPlainRole_Unset_ThrowsForbidden_NoMutation()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
// Phiếu của 1 drafter KHÁC; cả 2 cờ đang bật.
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true,
code: "PE-URG-011", drafterUserId: Guid.NewGuid());
// Actor = Finance (role thường), UserId random → KHÔNG phải drafter, KHÔNG TP.
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Finance));
var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*GỠ cờ gấp*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeTrue("guard throw TRƯỚC mutate → cờ ĐỎ giữ nguyên");
reloaded.IsUrgentByCcm.Should().BeTrue("cờ XANH giữ nguyên — người ngoài không gỡ được");
}
// ---------------------------------------------------------------------
// 12. SET — Drafter (người khai) role THƯỜNG cố GẮN cờ → ForbiddenException.
// Drafter CHỈ được GỠ, KHÔNG được GẮN (SET branch vẫn role-only, không xét drafter).
// Message nhánh SET ("*đánh dấu phiếu gấp*"). Cờ giữ false (không set).
// ---------------------------------------------------------------------
[Fact]
public async Task Drafter_Set_ThrowsForbidden_DrafterCannotMark()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var drafterId = Guid.NewGuid();
var pe = await SeedPeAsync(db, code: "PE-URG-012", drafterUserId: drafterId);
// Actor = chính người khai nhưng role thường + IsUrgent=true (GẮN).
var actor = new FakeCurrentUser(AppRoles.Drafter) { UserId = drafterId };
var handler = BuildHandler(db, um, actor);
var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*đánh dấu phiếu gấp*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeFalse("drafter chỉ GỠ, không GẮN → không set cờ");
reloaded.IsUrgentByCcm.Should().BeFalse();
}
// ---------------------------------------------------------------------
// 13. UNSET — Drafter gỡ khi CHỈ cờ XANH (ByCcm) đang bật → vẫn clear CẢ 2.
// Chứng minh drafter-clear KHÔNG phụ thuộc cờ nào đang bật (un-urgent toàn phiếu).
// ---------------------------------------------------------------------
[Fact]
public async Task Drafter_Unset_OnlyCcmFlagSet_ClearsBoth()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var drafterId = Guid.NewGuid();
var pe = await SeedPeAsync(db, urgentByCcm: true, code: "PE-URG-013", drafterUserId: drafterId);
var actor = new FakeCurrentUser(AppRoles.Drafter) { UserId = drafterId };
var handler = BuildHandler(db, um, actor);
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByCcm.Should().BeFalse("người khai gỡ cờ XANH");
reloaded.IsUrgentByPro.Should().BeFalse("vốn-dĩ false, vẫn false sau clear");
}
// ---------------------------------------------------------------------
// 14. UNSET — Drafter ĐỒNG THỜI có role Procurement nhưng KHÔNG DeptManager → gỡ
// vẫn clear CẢ 2 qua nhánh drafter (isDrafter ưu tiên/độc lập với isProTp).
// Đối chứng test 6 (TP-PRO chỉ clear cờ vai): cùng có role PRO nhưng vì là người
// khai → gỡ TOÀN phiếu, KHÔNG bị giới hạn role-keyed.
// ---------------------------------------------------------------------
[Fact]
public async Task DrafterWithProcurementButNoDeptManager_Unset_ClearsBoth()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var drafterId = Guid.NewGuid();
// Cả 2 cờ bật; nếu đi nhánh role-keyed (isProTp) sẽ CHỈ clear ByPro, ByCcm còn true.
// Vì là drafter → phải clear CẢ 2.
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true,
code: "PE-URG-014", drafterUserId: drafterId);
// role Procurement nhưng KHÔNG DeptManager (nên isProTp=false) + là người khai.
var actor = new FakeCurrentUser(AppRoles.Procurement) { UserId = drafterId };
var handler = BuildHandler(db, um, actor);
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeFalse("drafter gỡ → clear ĐỎ");
reloaded.IsUrgentByCcm.Should().BeFalse("drafter gỡ CẢ 2 — KHÔNG bị giới hạn role-keyed như TP");
}
}