[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>();
}
}