[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
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:
@ -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>();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,389 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
|
||||
// ===== FEATURE B (S69 anh Kiệt FDC) — value-threshold CCM-finalize =====
|
||||
// PurchaseEvaluationWorkflowService.ApproveV2Async, line 816-854 prod (test theo
|
||||
// CODE — S34 rule, KHÔNG touch production).
|
||||
//
|
||||
// Logic: khi NV đang duyệt (ChoDuyet, decision=Approve) có role AppRoles.CostControl
|
||||
// (CCM) VÀ aw.CeoApprovalThreshold != null VÀ winnerQuoteTotal (SUM ThanhTien của
|
||||
// các báo giá của NCC được chọn) < ngưỡng VÀ chưa-ở-slot-cuối → set Phase=DaDuyet
|
||||
// (bỏ qua các Bước/Cấp còn lại, incl CEO), pointers null, SLA null.
|
||||
// Else → advance tuyến tính bình thường.
|
||||
//
|
||||
// ⚠️ BOUNDARY (load-bearing): predicate là `winnerQuoteTotal < ceoThreshold` (STRICT
|
||||
// less-than, code line 838) → bằng đúng ngưỡng = KHÔNG finalize → advance bình thường.
|
||||
//
|
||||
// Mirror harness PeSubmitGuardAndBypassTests.cs cùng folder (IdentityFixture + SQLite
|
||||
// + SeedWorkflowAsync + SeedWinnerWithQuoteAsync + reuse NoOpNotificationService internal).
|
||||
// Khác PeSubmit*: tests này dựng PE TRỰC TIẾP ở ChoDuyet (đã qua submit guard) + pin
|
||||
// pointer Step/Level đứng tại slot CCM → drive 1 lần Approve.
|
||||
public class PeCcmThresholdFinalizeTests
|
||||
{
|
||||
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
|
||||
TestApplicationDbContext db, FixedDateTime clock) CreateService()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var clock = new FixedDateTime(new DateTime(2026, 6, 17, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
|
||||
return (svc, fix, db, clock);
|
||||
}
|
||||
|
||||
// PE đứng ở ChoDuyet tại pointer (stepIdx, levelOrder). SelectedSupplierId pin
|
||||
// winner. ApprovalWorkflowId pin V2 → ApproveV2Async branch.
|
||||
private static PurchaseEvaluation BuildPeAtApprovalSlot(
|
||||
Guid approvalWorkflowId,
|
||||
Guid? selectedSupplierId,
|
||||
int stepIdx,
|
||||
int levelOrder,
|
||||
string code = "PE-FB-001")
|
||||
{
|
||||
return new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Test Feature B — CCM threshold finalize",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = approvalWorkflowId,
|
||||
SelectedSupplierId = selectedSupplierId,
|
||||
CurrentWorkflowStepIndex = stepIdx,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
SlaDeadline = new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
}
|
||||
|
||||
// Seed 1 NCC tham gia (winner) + 1 detail + 1 quote ThanhTien=amount → winner
|
||||
// quote sum = amount. Return supplierId (master ref) để pin SelectedSupplierId.
|
||||
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
||||
{
|
||||
var supplierId = Guid.NewGuid();
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Bê tông",
|
||||
NoiDung = "Concrete M100",
|
||||
Order = 0,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationDetails.Add(detail);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = quoteThanhTien,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
}
|
||||
|
||||
// Seed workflow V2 multi-step: stepApprovers[s] = mảng NV cho từng cấp (Order
|
||||
// 1-based) của bước s (Order s+1). 1 NV / cấp. ceoThreshold set CeoApprovalThreshold
|
||||
// (null = bỏ qua finalize). Return ApprovalWorkflow đã persist.
|
||||
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
|
||||
TestApplicationDbContext db, Guid[][] stepApprovers, decimal? ceoThreshold)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = "QT-FB-V2",
|
||||
Version = 1,
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
Name = "QT test Feature B threshold",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
CeoApprovalThreshold = ceoThreshold,
|
||||
};
|
||||
for (int s = 0; s < stepApprovers.Length; s++)
|
||||
{
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = s + 1,
|
||||
Name = $"Bước {s + 1}",
|
||||
};
|
||||
for (int lvl = 0; lvl < stepApprovers[s].Length; lvl++)
|
||||
{
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = lvl + 1,
|
||||
Name = $"Cấp {lvl + 1}",
|
||||
ApproverUserId = stepApprovers[s][lvl],
|
||||
});
|
||||
}
|
||||
wf.Steps.Add(step);
|
||||
}
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
private static Task ApproveAsync(
|
||||
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
|
||||
params string[] roles) =>
|
||||
svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place (advance pointer)
|
||||
actorUserId: actorUserId,
|
||||
actorRoles: roles,
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// =====================================================================
|
||||
// 1. ⭐ LOAD-BEARING — CCM duyệt, gói < ngưỡng, CCM mid-workflow (còn CEO sau)
|
||||
// → DaDuyet luôn, bỏ qua CEO, pointers null.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmBelowThreshold_MidWorkflow_FinalizesDaDuyet_SkipsCeo_PointersCleared()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// Workflow 2 bước: Bước 1 Cấp 1 = CCM (đang đứng đây), Bước 2 Cấp 1 = CEO.
|
||||
var ccm = (await fix.CreateUserAsync("ccm@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm }, // Bước 1 Cấp 1 = CCM
|
||||
new[] { ceo }, // Bước 2 Cấp 1 = CEO (sẽ bị bỏ qua)
|
||||
}, ceoThreshold: 1_000_000_000m);
|
||||
|
||||
// PE đứng tại Bước 1 (stepIdx 0) Cấp 1 — đến lượt CCM.
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Gói 500tr < ngưỡng 1 tỷ.
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 500_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||
|
||||
// ⭐ Phiếu DaDuyet ngay — KHÔNG advance sang Bước 2 (CEO bị bỏ qua).
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||
"CCM duyệt + gói < ngưỡng CEO → finalize, bỏ CEO");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull("terminal → step pointer null");
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull("terminal → level pointer null");
|
||||
pe.SlaDeadline.Should().BeNull("terminal → SLA null");
|
||||
|
||||
// CEO KHÔNG được ghi opinion (bị bỏ qua hoàn toàn — chỉ CCM ký).
|
||||
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
var ccmLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 1).Id;
|
||||
opinions.Should().ContainSingle(o => o.ApprovalWorkflowLevelId == ccmLevelId,
|
||||
"chỉ slot CCM ký, không ghi hộ CEO");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 2. CCM duyệt, gói >= ngưỡng → advance sang CEO, Phase GIỮ ChoDuyet (NOT DaDuyet).
|
||||
// Boundary: gói == đúng ngưỡng (strict-less-than → KHÔNG finalize).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmAtOrAboveThreshold_AdvancesToCeo_PhaseStaysChoDuyet()
|
||||
{
|
||||
var (svc, fix, db, clock) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccm2@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo2@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm }, // Bước 1 = CCM
|
||||
new[] { ceo }, // Bước 2 = CEO
|
||||
}, ceoThreshold: 1_000_000_000m);
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-002");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Gói == ĐÚNG ngưỡng (1 tỷ) → strict `<` false → KHÔNG finalize.
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"gói == ngưỡng (không < ngưỡng) → advance bình thường, chưa DaDuyet");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1, "Cấp 1 của Bước 2");
|
||||
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7), "SLA reset cho approver kế (CEO)");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 2b. CCM duyệt, gói > ngưỡng (vượt rõ rệt) → advance sang CEO (không finalize).
|
||||
// Phân biệt với case (1) cùng setup nhưng chỉ khác giá trị gói.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmAboveThreshold_AdvancesToCeo_NotFinalized()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccm2b@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo2b@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm },
|
||||
new[] { ceo },
|
||||
}, ceoThreshold: 1_000_000_000m);
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-002B");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Gói 2 tỷ > ngưỡng 1 tỷ.
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "gói > ngưỡng → CEO vẫn phải duyệt");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 3. CeoApprovalThreshold == null → advance tuyến tính KỂ CẢ CCM (no early-finalize).
|
||||
// Backward-compat: workflow chưa set ngưỡng → behavior cũ.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_ThresholdNull_CcmApprovesBelowAnyValue_AdvancesNormally_NoFinalize()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccm3@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo3@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm },
|
||||
new[] { ceo },
|
||||
}, ceoThreshold: null); // ⭐ ngưỡng null → finalize-branch không chạy
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-003");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Gói 1đ (siêu nhỏ — chắc chắn dưới mọi ngưỡng nếu có) nhưng ngưỡng null.
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"ngưỡng null → KHÔNG finalize dù CCM + gói nhỏ → advance bình thường");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) như cũ");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 4. Non-CCM actor (Procurement) duyệt dưới ngưỡng → advance bình thường (no
|
||||
// early-finalize; CHỈ CostControl mới trigger). Cover nhận-diện-theo-role.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_NonCcmActor_BelowThreshold_AdvancesNormally_NoFinalize()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// Bước 1 Cấp 1 = Procurement (KHÔNG phải CCM), Bước 2 = CEO.
|
||||
var pro = (await fix.CreateUserAsync("pro4@fb.test", "PRO User", null, new[] { AppRoles.Procurement })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo4@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { pro },
|
||||
new[] { ceo },
|
||||
}, ceoThreshold: 1_000_000_000m);
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-004");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Gói 100tr << ngưỡng 1 tỷ — NHƯNG actor là PRO không phải CCM.
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 100_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, pro, AppRoles.Procurement);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"PRO duyệt + gói nhỏ nhưng KHÔNG phải CostControl → no finalize");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO) bình thường");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 5. (optional) CCM đã ở slot cuối (Bước cuối Cấp cuối) + dưới ngưỡng → DaDuyet
|
||||
// qua advance bình thường (guard `!(currentIdx==last && level==max)` chặn
|
||||
// finalize-branch → nhánh advance terminal vẫn ra DaDuyet, không double-finalize).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmAtLastSlot_BelowThreshold_DaDuyetViaNormalAdvance_NoDoubleFinalize()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// Workflow 1 bước, 1 cấp = CCM (đây là slot CUỐI luôn).
|
||||
var ccm = (await fix.CreateUserAsync("ccm5@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm }, // Bước 1 Cấp 1 = CCM = slot cuối
|
||||
}, ceoThreshold: 1_000_000_000m);
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-005");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 300_000_000m); // < ngưỡng
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
|
||||
|
||||
// Slot cuối → finalize-branch bị guard skip (currentIdx==last && level==max),
|
||||
// nhưng nhánh advance "nextIdx >= steps.Count" cũng set DaDuyet. Kết quả giống.
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||
"CCM ở slot cuối → DaDuyet qua advance bình thường (không cần finalize-branch)");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull();
|
||||
|
||||
// KHÔNG double-finalize: chỉ 1 Approval Approve (của CCM), không phát sinh vết thừa.
|
||||
var approvals = await db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id
|
||||
&& a.Decision == ApprovalDecision.Approve).ToListAsync();
|
||||
approvals.Should().HaveCount(1, "1 lần CCM duyệt = 1 Approval row, không double");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user