[CLAUDE] PurchaseEvaluation: cờ gấp PRO/CCM + CCM duyệt-final theo ngưỡng giá trị (Mig 53) + 14 test
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m41s

Yêu cầu anh Kiệt FDC (sau họp sếp). Mig 53 AddPeUrgentAndCeoApprovalThreshold — 3 AddColumn, no new table (Mig 52→53). Rollout an toàn: cột nullable, ngưỡng null = giữ luồng duyệt cũ 100% cho tới khi admin set.

B — CCM duyệt-final theo NGƯỠNG GIÁ TRỊ ("gói CEO phân quyền theo giá trị"):
- ApprovalWorkflow += CeoApprovalThreshold (decimal?, admin nhập trong Workflow Designer).
- ApproveV2Async: actor role CostControl (CCM) + winnerQuoteTotal (tổng giá NCC được chọn) < ngưỡng → DaDuyet luôn (bỏ CEO); ≥ ngưỡng → đẩy lên CEO như cũ. Ngưỡng null = luồng tuyến tính cũ. Q4 chốt nhận diện theo ROLE người duyệt.
- reviewer PASS 0 blocker: cascade-safe (Off/role không lan), tested load-bearing (CCM dưới ngưỡng → DaDuyet skip CEO).

A — cờ gấp per-vai (visibility-only, Q3 KHÔNG đổi luồng):
- PE += IsUrgentByPro (PRO đỏ) / IsUrgentByCcm (CCM xanh).
- Endpoint PUT /purchase-evaluations/{id}/urgent role-gated (Procurement→ByPro, CostControl→ByCcm, Admin→cả 2, khác→Forbidden) + notify CEO (Director) khi MỚI bật (best-effort).

FE ×2 app: Workflow Designer ô "Ngưỡng giá trị gói CEO" (fe-admin) + PE detail nút bật/tắt cờ gấp đỏ/xanh theo role + badge GẤP + hint "giá trị gói vs ngưỡng → CCM duyệt-final/cần CEO" + PE list badge gấp.
DTO: PE detail += isUrgentByPro/Ccm + winnerQuoteTotal + ceoApprovalThreshold; list += isUrgentByPro/Ccm; workflow V2 += ceoApprovalThreshold.

+14 test (292→306): PeCcmThresholdFinalizeTests 5 (B routing) + PeUrgentToggleAuthzTests 9 (A authz). Build slnx 0/0 · npm build ×2 0 err · dotnet test 306 PASS.

C (sau duyệt xong chuyển phiếu đến dự án) — chờ anh Kiệt làm chi tiết form, CHƯA làm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-17 13:27:50 +07:00
parent 1f8947e763
commit ebd7e1c42f
25 changed files with 7358 additions and 10 deletions

View File

@ -0,0 +1,235 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService (reuse internal helper)
namespace SolutionErp.Infrastructure.Tests.Application;
// ===== FEATURE A (S69 anh Kiệt FDC) — urgent-toggle authz =====
// SetPurchaseEvaluationUrgentCommandHandler (PurchaseEvaluationUrgentFeatures.cs).
// Test theo CODE đã land (S34 rule — KHÔNG touch production).
//
// Logic role → cờ:
// - Procurement (PRO) → set IsUrgentByPro (IsUrgentByCcm KHÔNG đụng)
// - CostControl (CCM) → set IsUrgentByCcm (IsUrgentByPro KHÔNG đụng)
// - Admin → set CẢ 2 cờ
// - role khác (Drafter/Finance/...) → ForbiddenException (không lưu gì)
//
// Notify CEO (Director) là best-effort try/catch khi false→true → KHÔNG assert
// notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt
// call → try block không throw. UserManager lấy từ IdentityFixture (GetUsersInRoleAsync
// trả rỗng khi không seed Director — best-effort no-op, đúng visibility-only Q3).
//
// Handler 4 dep: (IApplicationDbContext, ICurrentUser, UserManager<User>, INotificationService).
// FakeCurrentUser configurable Roles per scenario.
public class PeUrgentToggleAuthzTests
{
// ICurrentUser stub — Roles set per test (giống FakeCurrentUser PeWorkItemGuardTests
// nhưng configurable roles thay vì hardcode Drafter).
private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
{
public Guid? UserId { get; } = Guid.NewGuid();
public string? Email { get; } = "actor@test.local";
public string? FullName { get; } = "Actor Test";
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
public bool IsAuthenticated => UserId is not null;
}
private static SetPurchaseEvaluationUrgentCommandHandler BuildHandler(
TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
=> 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.
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
bool urgentByPro = false,
bool urgentByCcm = false,
string code = "PE-URG-001")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Gói thầu test urgent",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
IsUrgentByPro = urgentByPro,
IsUrgentByCcm = urgentByCcm,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// =====================================================================
// 1. Procurement → set CHỈ IsUrgentByPro=true, IsUrgentByCcm KHÔNG đụng.
// =====================================================================
[Fact]
public async Task Procurement_SetsOnlyIsUrgentByPro_CcmUntouched()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db);
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeTrue("PRO set cờ ĐỎ (ByPro)");
reloaded.IsUrgentByCcm.Should().BeFalse("PRO KHÔNG đụng cờ XANH (ByCcm)");
}
// =====================================================================
// 2. CostControl → set CHỈ IsUrgentByCcm=true, IsUrgentByPro KHÔNG đụng.
// =====================================================================
[Fact]
public async Task CostControl_SetsOnlyIsUrgentByCcm_ProUntouched()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-002");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByCcm.Should().BeTrue("CCM set cờ XANH (ByCcm)");
reloaded.IsUrgentByPro.Should().BeFalse("CCM KHÔNG đụng cờ ĐỎ (ByPro)");
}
// =====================================================================
// 3. Admin → set CẢ 2 cờ true.
// =====================================================================
[Fact]
public async Task Admin_SetsBothFlags()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-003");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeTrue("Admin set cả ĐỎ");
reloaded.IsUrgentByCcm.Should().BeTrue("Admin set cả XANH");
}
// =====================================================================
// 4. Role không có quyền (Drafter) → ForbiddenException, KHÔNG lưu gì.
// =====================================================================
[Fact]
public async Task Drafter_ThrowsForbidden_NoFlagSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-004");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Drafter));
var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*PRO*CCM*Admin*");
// Side-effect: guard throw TRƯỚC mutate → cả 2 cờ giữ false.
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeFalse("Drafter bị chặn → không set cờ");
reloaded.IsUrgentByCcm.Should().BeFalse();
}
// =====================================================================
// 4b. Role Finance (cũng ngoài allow-list) → ForbiddenException. Cover thêm 1
// role thường để chắc chắn KHÔNG chỉ Drafter bị chặn.
// =====================================================================
[Fact]
public async Task Finance_ThrowsForbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-004B");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Finance));
var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>();
}
// =====================================================================
// 5. IsUrgent=false (tắt cờ) — PRO tắt chỉ ByPro, ByCcm giữ nguyên (vd CCM đã
// bật trước đó). Verify partial-clear theo role + KHÔNG đụng cờ role khác.
// =====================================================================
[Fact]
public async Task Procurement_TurnOff_ClearsOnlyProFlag_CcmFlagPreserved()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
// State: cả 2 cờ đang bật (PRO đã bật ĐỎ, CCM đã bật XANH).
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, code: "PE-URG-005");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
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("PRO tắt cờ ĐỎ của mình");
reloaded.IsUrgentByCcm.Should().BeTrue("cờ XANH của CCM giữ nguyên — PRO không đụng");
}
// =====================================================================
// 6. Multi-role actor có CẢ Procurement (không Admin) — vẫn chỉ là PRO-path
// (Admin > PRO > CCM trong if-else). Đây actor PRO → set ByPro only.
// (Edge: nếu sau này role priority đổi → test này đỏ → review chủ đích.)
// =====================================================================
[Fact]
public async Task ActorWithBothProAndCcmRoles_NoAdmin_SetsBothFlagsViaElseIfChain_LocksBehavior()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var pe = await SeedPeAsync(db, code: "PE-URG-006");
// Actor mang CẢ Procurement + CostControl (KHÔNG Admin).
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement, AppRoles.CostControl));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
// Code path (line 51-63): isAdmin=false → else-if isPro=true → CHỈ set ByPro
// (else-if chain ngắn mạch, KHÔNG vào nhánh isCcm). LOCK behavior hiện tại:
// PRO ưu tiên hơn CCM khi user kiêm 2 role nhưng không Admin → chỉ ByPro.
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.IsUrgentByPro.Should().BeTrue("PRO branch chạy (else-if đầu khớp)");
reloaded.IsUrgentByCcm.Should().BeFalse(
"else-if ngắn mạch — actor kiêm PRO+CCM (no Admin) chỉ set ByPro, KHÔNG vào nhánh CCM");
}
// =====================================================================
// 7. PE không tồn tại → NotFoundException (guard đầu handler trước authz).
// =====================================================================
[Fact]
public async Task UnknownPe_ThrowsNotFound()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(Guid.NewGuid(), IsUrgent: true), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}