[CLAUDE] PurchaseEvaluation: Mig 54 giá đề xuất PRO/CCM + CEO chọn giá chốt + CCM duyệt-done ô-tích
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s

Theo note anh Kiệt FDC (go-live so-sánh-giá thứ Hai):
- (1) Giá chào thầu thêm giá đề xuất NGOÀI giá NCC: PRO nhập dải Min/Max +
  CCM nhập 1 giá (2 lệnh role-gate Procurement/CostControl, fail-closed).
  Khi duyệt cấp cuối, người duyệt CHỌN 1 giá chốt (Ncc/ProMin/ProMax/Ccm)
  -> luu ApprovedPriceAmount/Source (bind tai moi nhanh DaDuyet, bat buoc
  chon; auto-approve he thong mien).
- (3) CCM duyet-done mien CEO: DOI tu AUTO-threshold (S69) sang O-TICH-TAY
  (finalizeByCcmDelegation) -- CCM chu dong tich, fail-closed theo nguong
  + role + gia goi. An toan hon (khong vo tinh bo CEO).
- Mig 54 additive-nullable (5 cot PE) - FE 2 app SHA-mirror - test 306->334
  (+28: opt-in 6->11, +10 gia chot, +13 setter authz).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-18 15:51:39 +07:00
parent 77ad219361
commit 1d86abcdc5
20 changed files with 7931 additions and 101 deletions

View File

@ -0,0 +1,269 @@
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;
namespace SolutionErp.Infrastructure.Tests.Application;
// ===== NEW (Mig 54 2026-06-18 anh Kiệt FDC) — ③ 2 setter giá-đề-xuất role-gate =====
// PeSuggestedPriceFeatures.cs: UpdatePeSuggestedPricePro/CcmCommand(+Handler+Validator).
// Test theo CODE đã land (S34 rule — KHÔNG touch production). Mirror harness
// PeWorkItemGuardTests (validator plain .Validate() API + handler 2-dep nhẹ
// db+ICurrentUser instantiate trực tiếp).
//
// Authz (mirror UpdatePeBudgetPro/Ccm S61):
// - PRO command: role Procurement HOẶC Admin set ProSuggestedMin/Max; role khác →
// ForbiddenException (fail-closed TRƯỚC mọi side-effect — guard sau NotFound check).
// - CCM command: role CostControl HOẶC Admin set CcmSuggestedPrice; role khác → Forbidden.
// - Validator PRO: Min/Max >= 0; cả 2 có → Min <= Max (invalid nếu Min > Max).
// - Validator CCM: CcmPrice >= 0.
//
// ⚠️ Handler check PE-existence (NotFound) TRƯỚC authz gate (PeSuggestedPriceFeatures
// line 49-50 rồi 53). Nên unknown-PE → NotFound bất kể role; Forbidden cần PE tồn tại.
public class PeSuggestedPriceSetterAuthzTests
{
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 async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db, string code = "PE-SP-001")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Gói thầu test giá đề xuất",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// ====================================================================
// ===== PRO setter (ProSuggestedMinPrice / ProSuggestedMaxPrice) =====
// ====================================================================
// 1. Procurement set được Min/Max.
[Fact]
public async Task ProSetter_Procurement_SetsMinMax()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db);
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 100_000_000m, MaxPrice: 200_000_000m),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedMinPrice.Should().Be(100_000_000m);
reloaded.ProSuggestedMaxPrice.Should().Be(200_000_000m);
}
// 2. Admin set được Min/Max (allow-list thứ 2).
[Fact]
public async Task ProSetter_Admin_SetsMinMax()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-002");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 50_000_000m, MaxPrice: null),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedMinPrice.Should().Be(50_000_000m);
reloaded.ProSuggestedMaxPrice.Should().BeNull("chỉ 1 trong 2 = giá chốt đó, Max để trống OK");
}
// 3. Role khác (CostControl) → ForbiddenException + KHÔNG set giá (fail-closed).
[Fact]
public async Task ProSetter_CostControl_ThrowsForbidden_NoSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-003");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: 2m),
CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*PRO*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.ProSuggestedMinPrice.Should().BeNull("CCM bị chặn → không set giá PRO");
reloaded.ProSuggestedMaxPrice.Should().BeNull();
}
// 3b. Role thường khác (Drafter) → cũng Forbidden (chắc chắn không chỉ CCM bị chặn).
[Fact]
public async Task ProSetter_Drafter_ThrowsForbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-003B");
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Drafter));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceProCommand(pe.Id, MinPrice: 1m, MaxPrice: null),
CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>();
}
// 4. Validator PRO: Min > Max → invalid (rule WithMessage "Giá Min phải ≤ Giá Max").
[Fact]
public void ProValidator_MinGreaterThanMax_Invalid()
{
var validator = new UpdatePeSuggestedPriceProCommandValidator();
var result = validator.Validate(
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), MinPrice: 500m, MaxPrice: 100m));
result.IsValid.Should().BeFalse("Min > Max vi phạm rule");
result.Errors.Should().Contain(e => e.ErrorMessage.Contains("Min phải ≤"));
}
// 4b. Validator PRO: Min <= Max → valid. Chỉ 1 trong 2 (Max null) → cũng valid
// (rule Min<=Max chỉ When cả 2 HasValue).
[Fact]
public void ProValidator_MinLeMax_AndSingleValue_Valid()
{
var validator = new UpdatePeSuggestedPriceProCommandValidator();
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 100m, 500m))
.IsValid.Should().BeTrue("Min <= Max hợp lệ");
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 100m, null))
.IsValid.Should().BeTrue("chỉ Min (Max null) hợp lệ — không ràng Min<=Max");
validator.Validate(new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), null, null))
.IsValid.Should().BeTrue("cả 2 null (clear) hợp lệ");
}
// 4c. Validator PRO: giá trị âm → invalid (GreaterThanOrEqualTo(0)).
[Fact]
public void ProValidator_NegativeValue_Invalid()
{
var validator = new UpdatePeSuggestedPriceProCommandValidator();
var result = validator.Validate(
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), MinPrice: -1m, MaxPrice: null));
result.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
}
// 5. PRO handler unknown PE → NotFound (existence check TRƯỚC authz).
[Fact]
public async Task ProSetter_UnknownPe_ThrowsNotFound()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var handler = new UpdatePeSuggestedPriceProCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceProCommand(Guid.NewGuid(), 1m, 2m), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
// ====================================================================
// ===== CCM setter (CcmSuggestedPrice) =====
// ====================================================================
// 6. CostControl set được CcmSuggestedPrice.
[Fact]
public async Task CcmSetter_CostControl_SetsPrice()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-006");
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 333_000_000m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPrice.Should().Be(333_000_000m);
}
// 7. Admin set được CcmSuggestedPrice.
[Fact]
public async Task CcmSetter_Admin_SetsPrice()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-007");
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 1m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPrice.Should().Be(1m);
}
// 8. Role khác (Procurement) → ForbiddenException + KHÔNG set giá.
[Fact]
public async Task CcmSetter_Procurement_ThrowsForbidden_NoSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-SP-008");
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(pe.Id, CcmPrice: 9m), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*CCM*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmSuggestedPrice.Should().BeNull("PRO bị chặn → không set giá CCM");
}
// 9. Validator CCM: giá âm → invalid.
[Fact]
public void CcmValidator_NegativeValue_Invalid()
{
var validator = new UpdatePeSuggestedPriceCcmCommandValidator();
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: -5m))
.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: 0m))
.IsValid.Should().BeTrue("0 hợp lệ");
validator.Validate(new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), CcmPrice: null))
.IsValid.Should().BeTrue("null (clear) hợp lệ");
}
// 10. CCM handler unknown PE → NotFound.
[Fact]
public async Task CcmSetter_UnknownPe_ThrowsNotFound()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var handler = new UpdatePeSuggestedPriceCcmCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
var act = async () => await handler.Handle(
new UpdatePeSuggestedPriceCcmCommand(Guid.NewGuid(), 1m), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}

View File

@ -0,0 +1,296 @@
using System.Reflection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
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;
// ===== NEW (Mig 54 2026-06-18 anh Kiệt FDC) — ① GIÁ CHỐT ApplyApprovedPriceOnFinalize =====
// PurchaseEvaluationWorkflowService.ApplyApprovedPriceOnFinalize (line 908-923 prod) +
// 2 call-site nhánh DaDuyet trong ApproveV2Async: (a) terminal normal advance (line 885)
// 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):
// - 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
// - hợp lệ → set evaluation.ApprovedPriceAmount/Source
//
// ⚠️ 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ó
// SLA-auto-job gọi TransitionAsync (chỉ Contract SlaExpiryJob). Nên nhánh isSystem-
// exempt trong PE là defensive/dead qua đường V2-approve. Vẫn test ĐƯỢC contract của nó
// ở mức UNIT qua reflection (private static) — verify hành vi tài liệu hoá đúng. Phần
// reachable (human approver) test qua integration TransitionAsync bên dưới.
//
// Mirror harness PeCcmThresholdFinalizeTests cùng folder.
public class PeApprovedPriceFinalizeTests
{
private const string ValidSource = "Ncc";
private const decimal ValidAmount = 750_000_000m;
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, 18, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
return (svc, fix, db, clock);
}
// Workflow V2 1 bước / 1 cấp = 1 approver (CEO/last). Approve cấp đó = terminal
// DaDuyet qua normal advance (ApproveV2Async line 881 nextIdx>=steps.Count).
private static async Task<ApprovalWorkflow> SeedSingleApproverWorkflowAsync(
TestApplicationDbContext db, Guid approverId)
{
var wf = new ApprovalWorkflow
{
Code = "QT-AP-V2",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
Name = "QT test approved-price",
IsActive = true,
IsUserSelectable = true,
};
var step = new ApprovalWorkflowStep
{
ApprovalWorkflowId = wf.Id,
Order = 1,
Name = "Bước 1",
};
step.Levels.Add(new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = 1,
Name = "Cấp 1",
ApproverUserId = approverId,
});
wf.Steps.Add(step);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static PurchaseEvaluation BuildPeAtLastSlot(Guid awId, string code)
=> new()
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Test giá chốt",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
ApprovalWorkflowId = awId,
CurrentWorkflowStepIndex = 0,
CurrentApprovalLevelOrder = 1,
SlaDeadline = new DateTime(2026, 6, 25, 0, 0, 0, DateTimeKind.Utc),
};
private static Task ApproveAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
string[] roles, decimal? amount, string? source) =>
svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
actorUserId: actorUserId,
actorRoles: roles,
decision: ApprovalDecision.Approve,
comment: null,
approvedPriceAmount: amount,
approvedPriceSource: source,
ct: CancellationToken.None);
// =====================================================================
// 1. Duyệt cấp cuối (terminal DaDuyet) HUMAN truyền giá hợp lệ → set đúng
// evaluation.ApprovedPriceAmount/Source. (reachable integration path)
// =====================================================================
[Fact]
public async Task ApproveV2_TerminalApprove_ValidPrice_SetsApprovedPriceAndSource()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ceo = (await fix.CreateUserAsync("ceoAP1@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-001");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
amount: ValidAmount, source: ValidSource);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "1 bước/1 cấp duyệt → terminal");
pe.ApprovedPriceAmount.Should().Be(ValidAmount, "giá chốt người duyệt chọn được bind");
pe.ApprovedPriceSource.Should().Be(ValidSource);
}
}
// =====================================================================
// 2. Duyệt cấp cuối HUMAN KHÔNG truyền giá (null) → ConflictException
// ("Chọn 1 giá chốt..."). Phiếu KHÔNG đổi sang DaDuyet (throw trước set Phase).
// =====================================================================
[Fact]
public async Task ApproveV2_TerminalApprove_NullPrice_Human_ThrowsConflict_NotFinalized()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ceo = (await fix.CreateUserAsync("ceoAP2@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-002");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
amount: null, source: null);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*giá chốt*");
// ApplyApprovedPriceOnFinalize chạy TRƯỚC set Phase=DaDuyet (line 885-886)
// → throw = phiếu giữ ChoDuyet, pointer chưa null.
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "thiếu giá chốt → không finalize");
reloaded.ApprovedPriceAmount.Should().BeNull();
}
}
// =====================================================================
// 3. Duyệt cấp cuối HUMAN truyền nguồn RÁC (∉ {Ncc,ProMin,ProMax,Ccm}) →
// ConflictException. Amount có nhưng source invalid → vẫn chặn.
// =====================================================================
[Fact]
public async Task ApproveV2_TerminalApprove_GarbageSource_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ceo = (await fix.CreateUserAsync("ceoAP3@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
var pe = BuildPeAtLastSlot(wf.Id, "PE-AP-003");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
amount: ValidAmount, source: "TuNghi"); // rác
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Nguồn giá chốt*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
reloaded.ApprovedPriceSource.Should().BeNull();
}
}
// =====================================================================
// 3b. Mỗi nguồn hợp lệ (Ncc/ProMin/ProMax/Ccm) đều set được — chốt allow-list
// đầy đủ (parametrized theo Theory).
// =====================================================================
[Theory]
[InlineData("Ncc")]
[InlineData("ProMin")]
[InlineData("ProMax")]
[InlineData("Ccm")]
public async Task ApproveV2_TerminalApprove_EachValidSource_SetsSource(string source)
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ceo = (await fix.CreateUserAsync($"ceoAP3b-{source}@ap.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedSingleApproverWorkflowAsync(db, ceo);
var pe = BuildPeAtLastSlot(wf.Id, $"PE-AP-3B-{source}");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ceo, new[] { AppRoles.Director },
amount: ValidAmount, source: source);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
pe.ApprovedPriceSource.Should().Be(source);
pe.ApprovedPriceAmount.Should().Be(ValidAmount);
}
}
// =====================================================================
// 4. Auto-approve hệ thống (isSystem=true) tới terminal mà null giá → KHÔNG throw
// (miễn chọn giá). Test UNIT qua reflection private-static ApplyApprovedPriceOnFinalize
// — isSystem KHÔNG reachable qua public ApproveV2Async (xem header OBSERVATION),
// nên test contract của method trực tiếp. Verify exempt-branch hoạt động đúng.
// =====================================================================
[Fact]
public void ApplyApprovedPriceOnFinalize_System_NullPrice_NoThrow_NoSet()
{
var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), TenGoiThau = "x" };
var act = () => InvokeApply(pe, isSystem: true, amount: null, source: null);
act.Should().NotThrow("auto-approve hệ thống MIỄN chọn giá chốt");
pe.ApprovedPriceAmount.Should().BeNull("không có người chọn → không set giá");
pe.ApprovedPriceSource.Should().BeNull();
}
// =====================================================================
// 4b. Đối chứng (mirror case 2 ở mức unit): isSystem=false + null giá → Conflict.
// Cùng method, chỉ khác cờ isSystem → chứng minh exempt-branch là do isSystem.
// =====================================================================
[Fact]
public void ApplyApprovedPriceOnFinalize_Human_NullPrice_ThrowsConflict()
{
var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), TenGoiThau = "x" };
var act = () => InvokeApply(pe, isSystem: false, amount: null, source: null);
act.Should().Throw<ConflictException>().WithMessage("*giá chốt*");
}
// =====================================================================
// 4c. isSystem=true NHƯNG có truyền amount + source rác → vẫn Conflict (exempt
// chỉ áp khi amount==null; có amount thì source PHẢI hợp lệ kể cả system).
// =====================================================================
[Fact]
public void ApplyApprovedPriceOnFinalize_System_AmountWithGarbageSource_ThrowsConflict()
{
var pe = new PurchaseEvaluation { Id = Guid.NewGuid(), TenGoiThau = "x" };
var act = () => InvokeApply(pe, isSystem: true, amount: ValidAmount, source: "Bogus");
act.Should().Throw<ConflictException>().WithMessage("*Nguồn giá chốt*");
}
// 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)
{
var mi = typeof(PurchaseEvaluationWorkflowService).GetMethod(
"ApplyApprovedPriceOnFinalize",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("ApplyApprovedPriceOnFinalize không tìm thấy (đổi tên?).");
try
{
mi.Invoke(null, new object?[] { evaluation, isSystem, amount, source });
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
throw ex.InnerException;
}
}
}

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
using SolutionErp.Domain.Identity;
@ -10,32 +11,42 @@ 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).
// ===== FEATURE B (Mig 54 2026-06-18 anh Kiệt FDC) — CCM-finalize ĐỔI AUTO→OPT-IN =====
// PurchaseEvaluationWorkflowService.ApproveV2Async, finalize-branch line 830-867 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.
// ⚠️ SPEC CHANGE (Mig 54 vs S69):
// TRƯỚC (S69): CCM duyệt + aw.CeoApprovalThreshold set + winnerQuoteTotal < ngưỡng
// + chưa-slot-cuối → AUTO finalize DaDuyet (im lặng, KHÔNG cần tham số).
// NAY (Mig 54): CHỈ finalize khi `finalizeByCcmDelegation=true` truyền vào
// TransitionAsync VÀ đủ điều kiện (fail-closed, check theo thứ tự code line 832-851):
// (1) aw.CeoApprovalThreshold != null → null = ConflictException
// (2) actorRoles chứa AppRoles.CostControl → khác = ForbiddenException
// (3) winnerQuoteTotal < ngưỡng (STRICT `<`) → >= = ConflictException
// Đủ 3 ĐK → ApplyApprovedPriceOnFinalize (BẮT BUỘC giá chốt cho human) → DaDuyet.
// flag=false → bỏ qua finalize-branch hoàn toàn → advance tuyến tính lên CEO.
//
// ⚠️ 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.
// ⚠️ finalize-branch (delegation) gọi ApplyApprovedPriceOnFinalize → human approver
// PHẢI truyền approvedPriceAmount + approvedPriceSource hợp lệ, nếu không = Conflict
// ("Chọn 1 giá chốt..."). Mọi case finalize dưới đây truyền giá hợp lệ.
//
// 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.
// 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 const decimal CeoThreshold = 1_000_000_000m;
private const decimal ValidApprovedPrice = 500_000_000m;
private const string ValidApprovedSource = "Ncc";
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 clock = new FixedDateTime(new DateTime(2026, 6, 18, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
return (svc, fix, db, clock);
@ -63,7 +74,7 @@ public class PeCcmThresholdFinalizeTests
SelectedSupplierId = selectedSupplierId,
CurrentWorkflowStepIndex = stepIdx,
CurrentApprovalLevelOrder = levelOrder,
SlaDeadline = new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc),
SlaDeadline = new DateTime(2026, 6, 25, 0, 0, 0, DateTimeKind.Utc),
};
}
@ -140,9 +151,14 @@ public class PeCcmThresholdFinalizeTests
return wf;
}
// Approve-in-place (advance pointer). finalizeByCcmDelegation + giá chốt optional —
// mặc định KHÔNG uỷ quyền + không giá (advance bình thường).
private static Task ApproveAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
params string[] roles) =>
string[] roles,
bool finalizeByCcmDelegation = false,
decimal? approvedPriceAmount = null,
string? approvedPriceSource = null) =>
svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place (advance pointer)
@ -150,14 +166,17 @@ public class PeCcmThresholdFinalizeTests
actorRoles: roles,
decision: ApprovalDecision.Approve,
comment: null,
finalizeByCcmDelegation: finalizeByCcmDelegation,
approvedPriceAmount: approvedPriceAmount,
approvedPriceSource: approvedPriceSource,
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.
// 1. ⭐ LOAD-BEARING — CCM tích uỷ-quyền (flag=true) + gói < ngưỡng + CCM
// mid-workflow (còn CEO sau) + truyền giá chốt → DaDuyet, bỏ CEO, pointers null.
// =====================================================================
[Fact]
public async Task ApproveV2_CcmBelowThreshold_MidWorkflow_FinalizesDaDuyet_SkipsCeo_PointersCleared()
public async Task ApproveV2_CcmDelegationFlag_BelowThreshold_MidWorkflow_FinalizesDaDuyet_SkipsCeo_PointersCleared()
{
var (svc, fix, db, _) = CreateService();
using (fix)
@ -170,25 +189,30 @@ public class PeCcmThresholdFinalizeTests
{
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);
}, ceoThreshold: CeoThreshold);
// 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);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
// ⭐ 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");
"CCM tích uỷ-quyền + 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");
// ① giá chốt phải được bind từ giá CCM truyền vào.
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice, "giá chốt bind khi finalize");
pe.ApprovedPriceSource.Should().Be(ValidApprovedSource);
// CEO KHÔNG được ghi opinion (bị bỏ qua hoàn toàn — chỉ CCM ký).
var opinions = await db.PurchaseEvaluationLevelOpinions
@ -200,11 +224,52 @@ public class PeCcmThresholdFinalizeTests
}
// =====================================================================
// 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).
// 1a. (NEW Mig 54) flag=false + gói < ngưỡng → KHÔNG finalize, advance lên CEO.
// Đây là ĐỔI hành vi chính: trước S69 AUTO finalize, NAY phải tích flag.
// =====================================================================
[Fact]
public async Task ApproveV2_CcmAtOrAboveThreshold_AdvancesToCeo_PhaseStaysChoDuyet()
public async Task ApproveV2_NoDelegationFlag_CcmBelowThreshold_DoesNotFinalize_AdvancesToCeo()
{
var (svc, fix, db, clock) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccm1a@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceo1a@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: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-001A");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// Gói 500tr < ngưỡng — NHƯNG KHÔNG tích uỷ-quyền.
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
// flag=false (mặc định) → KHÔNG vào finalize-branch dù gói < ngưỡng.
await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl });
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"không tích uỷ-quyền → KHÔNG finalize dù gói < ngưỡng → advance bình thường lên CEO");
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 CEO");
// Chưa finalize → giá chốt CHƯA bind (CEO mới chọn ở cấp cuối).
pe.ApprovedPriceAmount.Should().BeNull("chưa tới terminal → chưa có giá chốt");
}
}
// =====================================================================
// 2. flag=false + gói >= ngưỡng → advance sang CEO, Phase GIỮ ChoDuyet.
// Boundary: gói == đúng ngưỡng (strict-less-than → KHÔNG finalize kể cả nếu
// có tích flag — case b cover phần Conflict).
// =====================================================================
[Fact]
public async Task ApproveV2_CcmAtThreshold_NoFlag_AdvancesToCeo_PhaseStaysChoDuyet()
{
var (svc, fix, db, clock) = CreateService();
using (fix)
@ -216,17 +281,17 @@ public class PeCcmThresholdFinalizeTests
{
new[] { ccm }, // Bước 1 = CCM
new[] { ceo }, // Bước 2 = CEO
}, ceoThreshold: 1_000_000_000m);
}, ceoThreshold: CeoThreshold);
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);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: CeoThreshold);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl });
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"gói == ngưỡng (không < ngưỡng) → advance bình thường, chưa DaDuyet");
@ -237,46 +302,168 @@ public class PeCcmThresholdFinalizeTests
}
// =====================================================================
// 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.
// (b) flag=true + gói == ngưỡng → ConflictException (strict `<` violated:
// winnerQuoteTotal >= ceoThreshold). Phiếu KHÔNG đổi (throw trước mutate).
// =====================================================================
[Fact]
public async Task ApproveV2_CcmAboveThreshold_AdvancesToCeo_NotFinalized()
public async Task ApproveV2_DelegationFlag_AtThreshold_ThrowsConflict_NoMutation()
{
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 ccm = (await fix.CreateUserAsync("ccmB@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceoB@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm },
new[] { ceo },
}, ceoThreshold: 1_000_000_000m);
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-002B");
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-00B");
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);
// Gói == ngưỡng → finalize-branch chạy nhưng `>= ceoThreshold` → Conflict.
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: CeoThreshold);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
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);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*ngưỡng CEO*");
// Side-effect: throw TRƯỚC khi set Phase → vẫn ChoDuyet, pointer giữ nguyên.
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "gói >= ngưỡng → không được finalize");
reloaded.CurrentWorkflowStepIndex.Should().Be(0);
reloaded.ApprovedPriceAmount.Should().BeNull();
}
}
// =====================================================================
// 3. CeoApprovalThreshold == null → advance tuyến tính KỂ CẢ CCM (no early-finalize).
// Backward-compat: workflow chưa set ngưỡng → behavior cũ.
// (b2) flag=true + gói > ngưỡng (vượt rõ rệt) → cũng ConflictException.
// =====================================================================
[Fact]
public async Task ApproveV2_ThresholdNull_CcmApprovesBelowAnyValue_AdvancesNormally_NoFinalize()
public async Task ApproveV2_DelegationFlag_AboveThreshold_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccmB2@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceoB2@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm },
new[] { ceo },
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-0B2");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000_000m); // > ngưỡng
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
await act.Should().ThrowAsync<ConflictException>();
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "gói > ngưỡng → CEO vẫn phải duyệt");
}
}
// =====================================================================
// (c) flag=true + actor KHÔNG phải CostControl → ForbiddenException.
// Dùng PRO (Procurement) đứng tại slot bước 1 + tích uỷ-quyền + gói < ngưỡng.
// Guard role (line 835) chạy TRƯỚC threshold-compare → Forbidden.
// =====================================================================
[Fact]
public async Task ApproveV2_DelegationFlag_NonCostControlActor_ThrowsForbidden()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
// Bước 1 Cấp 1 = Procurement (KHÔNG phải CCM) — đứng tại đây tích flag.
var pro = (await fix.CreateUserAsync("proC@fb.test", "PRO User", null, new[] { AppRoles.Procurement })).Id;
var ceo = (await fix.CreateUserAsync("ceoC@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { pro },
new[] { ceo },
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-00C");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice); // < ngưỡng
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, pro, new[] { AppRoles.Procurement },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*CCM*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "PRO không được uỷ-quyền finalize");
}
}
// =====================================================================
// (d) flag=true + workflow chưa set CeoApprovalThreshold (null) → ConflictException.
// Threshold-null check (line 832) chạy TRƯỚC role/value → Conflict.
// =====================================================================
[Fact]
public async Task ApproveV2_DelegationFlag_ThresholdNotSet_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccmD@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceoD@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 bị chặn ngay
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-00D");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*Ngưỡng giá trị*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"ngưỡng null → không thể uỷ-quyền finalize");
}
}
// =====================================================================
// 3. CeoApprovalThreshold == null + flag=false → advance tuyến tính KỂ CẢ CCM.
// Backward-compat: workflow chưa set ngưỡng → behavior cũ (no early-finalize).
// =====================================================================
[Fact]
public async Task ApproveV2_ThresholdNull_NoFlag_CcmApprovesBelowAnyValue_AdvancesNormally()
{
var (svc, fix, db, _) = CreateService();
using (fix)
@ -288,36 +475,35 @@ public class PeCcmThresholdFinalizeTests
{
new[] { ccm },
new[] { ceo },
}, ceoThreshold: null); // ⭐ ngưỡng null → finalize-branch không chạy
}, ceoThreshold: null);
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.
// Gói 1đ (siêu nhỏ) nhưng ngưỡng null + KHÔNG flag.
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1m);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl });
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"ngưỡng null → KHÔNG finalize dù CCM + gói nhỏ → advance bình thường");
"ngưỡng null + no flag → 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.
// 4. Non-CCM actor (Procurement) duyệt dưới ngưỡng, KHÔNG flag → advance bình
// thường (no early-finalize). Cover nhận-diện-theo-role + no-flag.
// =====================================================================
[Fact]
public async Task ApproveV2_NonCcmActor_BelowThreshold_AdvancesNormally_NoFinalize()
public async Task ApproveV2_NonCcmActor_NoFlag_BelowThreshold_AdvancesNormally()
{
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;
@ -325,32 +511,31 @@ public class PeCcmThresholdFinalizeTests
{
new[] { pro },
new[] { ceo },
}, ceoThreshold: 1_000_000_000m);
}, ceoThreshold: CeoThreshold);
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);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 100_000_000m); // << ngưỡng
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, pro, AppRoles.Procurement);
await ApproveAsync(svc, pe, pro, new[] { AppRoles.Procurement });
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
"PRO duyệt + gói nhỏ nhưng KHÔNG phải CostControl → no finalize");
"PRO duyệt + gói nhỏ + no flag → advance bình thường");
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).
// 5. CCM tích uỷ-quyền (flag=true) + ở slot CUỐI (Bước cuối Cấp cuối) + dưới
// ngưỡng + truyền giá → DaDuyet qua finalize-branch (chạy TRƯỚC normal advance).
// Verify: chỉ 1 Approval Approve row (không double-finalize).
// =====================================================================
[Fact]
public async Task ApproveV2_CcmAtLastSlot_BelowThreshold_DaDuyetViaNormalAdvance_NoDoubleFinalize()
public async Task ApproveV2_CcmDelegationFlag_AtLastSlot_BelowThreshold_FinalizesDaDuyet_NoDoubleApproval()
{
var (svc, fix, db, _) = CreateService();
using (fix)
@ -361,7 +546,7 @@ public class PeCcmThresholdFinalizeTests
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm }, // Bước 1 Cấp 1 = CCM = slot cuối
}, ceoThreshold: 1_000_000_000m);
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-005");
db.PurchaseEvaluations.Add(pe);
@ -370,20 +555,59 @@ public class PeCcmThresholdFinalizeTests
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
await ApproveAsync(svc, pe, ccm, AppRoles.CostControl);
await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: 300_000_000m, approvedPriceSource: ValidApprovedSource);
// 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.
// finalize-branch (line 830) chạy TRƯỚC normal advance → DaDuyet ngay.
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
"CCM ở slot cuối → DaDuyet qua advance bình thường (không cần finalize-branch)");
"CCM tích uỷ-quyền ở slot cuối + dưới ngưỡng → DaDuyet qua finalize-branch");
pe.CurrentWorkflowStepIndex.Should().BeNull();
pe.CurrentApprovalLevelOrder.Should().BeNull();
pe.ApprovedPriceAmount.Should().Be(300_000_000m);
// KHÔNG double-finalize: chỉ 1 Approval Approve (của CCM), không phát sinh vết thừa.
// KHÔNG double: chỉ 1 Approval Approve (của CCM), finalize-branch return ngay.
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");
}
}
// =====================================================================
// 6. CCM tích uỷ-quyền + đủ điều kiện NHƯNG KHÔNG truyền giá chốt (null) →
// ConflictException ("Chọn 1 giá chốt..."). Finalize cũng cần giá chốt như
// terminal thường (ApplyApprovedPriceOnFinalize cho human approver).
// =====================================================================
[Fact]
public async Task ApproveV2_CcmDelegationFlag_BelowThreshold_NullPrice_ThrowsConflict()
{
var (svc, fix, db, _) = CreateService();
using (fix)
{
var ccm = (await fix.CreateUserAsync("ccm6@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
var ceo = (await fix.CreateUserAsync("ceo6@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { ccm },
new[] { ceo },
}, ceoThreshold: CeoThreshold);
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-006");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: ValidApprovedPrice); // < ngưỡng
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
// flag=true + đủ ĐK threshold/role/value NHƯNG approvedPriceAmount=null.
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
finalizeByCcmDelegation: true,
approvedPriceAmount: null, approvedPriceSource: null);
await act.Should().ThrowAsync<ConflictException>()
.WithMessage("*giá chốt*");
}
}
}