[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
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:
@ -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>();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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*");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user