[CLAUDE] PurchaseEvaluation: Section B (THỰC HIỆN) tách 3 cột per-cột kỳ-này (Mig 59, anh Kiệt FDC / CCM)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m8s

Section B "Tổng hợp ngân sách trình ký" tách 3 cột Dự án|PRO|CCM giống Section A —
mỗi cột nhập "NS kỳ này" RIÊNG + tính thực-hiện độc-lập (lũy-kế / còn-lại / % /
so-sánh) theo ngân-sách-full của cột đó:
- PRO: budgetPeriodAmount (có sẵn) đối chiếu proFull
- CCM: ccmBudgetPeriodAmount (MỚI, Mig 59) đối chiếu ccmFull — CCM nhập (canEditCcm)
- Dự án: — (chưa wire, như A)
Số thực-tế dùng chung mọi cột (NCC đề xuất, lũy kế lịch sử). Submit-guard giữ trên
budgetPeriodAmount (không đụng).

Mig 59 AddCcmBudgetPeriodToPurchaseEvaluation (1 cột nullable, no backfill). NEW
endpoint PATCH /budget/ccm-period role-gate Admin|CostControl (fail-closed). FE
Section B rewrite <BudgetRow> list -> <table> 3 cột (colCalc per-cột), 2 app SHA-mirror.
Test 395->402 (+7 authz setter). Build sạch BE + 2 FE. Workflow wf_aafb18fe-d9d
(BE->FE->test + 3-lane review: math PASS, authz PASS, mirror self-gated identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-24 16:06:59 +07:00
parent f1b10e9c33
commit 9b1ee0c3a5
14 changed files with 7121 additions and 397 deletions

View File

@ -0,0 +1,192 @@
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 59 2026-06-24 anh Kiệt FDC / CCM) — setter "NS kỳ này" cột CCM =====
// Section B (THỰC HIỆN) tách 3 cột Dự án|PRO|CCM. PRO dùng /budget-adjust sẵn có
// (BudgetPeriodAmount); CCM nhập "NS kỳ này" RIÊNG ở cột mình → PeSuggestedPriceFeatures.cs
// SetPeCcmBudgetPeriodCommand(+Validator+Handler). Test theo CODE đã land (S34 rule —
// KHÔNG touch production). Mirror harness PeSuggestedPriceSetterAuthzTests (handler 2-dep
// nhẹ db+ICurrentUser instantiate trực tiếp + FakeCurrentUser(params roles) + SeedPeAsync).
//
// Authz (mirror UpdatePeSuggestedPriceCcm / UpdatePeBudgetCcm S61):
// - role CostControl (CCM) HOẶC Admin set CcmBudgetPeriodAmount; role khác →
// ForbiddenException ("*CCM*") fail-closed TRƯỚC mọi side-effect (guard sau NotFound).
// - Absolute-set (null = clear). Budget=0 hợp lệ (GreaterThanOrEqualTo(0).When(HasValue)).
// - KHÔNG submit-guard (guard chỉ trên BudgetPeriodAmount của PRO — gotcha #73 không đụng).
//
// ⚠️ Handler check PE-existence (NotFound) TRƯỚC authz gate (PeSuggestedPriceFeatures
// line 172-173 rồi 176-181). Nên unknown-PE → NotFound bất kể role; Forbidden cần PE tồn tại.
// Changelog.UserId = Guid? KHÔNG FK User (chỉ FK PE Cascade) → FakeCurrentUser random Guid an toàn.
public class PeCcmBudgetPeriodSetterAuthzTests
{
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-CCMB-001")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = code,
TenGoiThau = "Gói thầu test NS kỳ này (CCM)",
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// 1a. Admin set được CcmBudgetPeriodAmount (allow-list thứ nhất).
[Fact]
public async Task Admin_SetsCcmBudgetPeriod()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001A");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 250_000_000m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().Be(250_000_000m, "Admin được nhập NS kỳ này (CCM)");
}
// 1b. CostControl (CCM) set được CcmBudgetPeriodAmount (allow-list thứ hai).
[Fact]
public async Task CostControl_SetsCcmBudgetPeriod()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001B");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 333_000_000m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().Be(333_000_000m, "CCM (CostControl) được nhập NS kỳ này cột mình");
}
// 1c. Role khác (Drafter — phiếu của người khai, KHÔNG có vai CCM) → ForbiddenException
// + KHÔNG set giá (fail-closed: role-gate TRƯỚC side-effect → no partial-write).
[Fact]
public async Task OtherRole_Drafter_ThrowsForbidden_NoSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001C");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.Drafter));
var act = async () => await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 1m), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*CCM*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().BeNull("Drafter (không phải CCM/Admin) bị chặn → không set giá");
}
// 1c-bis. Role khác (Procurement = PRO, có cột riêng /budget-adjust) → cũng Forbidden
// (chắc chắn không chỉ Drafter bị chặn — PRO KHÔNG được nhập cột CCM).
[Fact]
public async Task OtherRole_Procurement_ThrowsForbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001CP");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 9m), CancellationToken.None);
await act.Should().ThrowAsync<ForbiddenException>()
.WithMessage("*CCM*");
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().BeNull("PRO bị chặn → không set cột CCM (cột riêng độc lập)");
}
// 1d. value = 0 hợp lệ (no throw) — budget=0 cho ccmBudgetPeriodAmount luôn được
// (validator GreaterThanOrEqualTo(0).When(HasValue)). Handler set 0 vào DB.
[Fact]
public async Task ZeroValue_IsValid_AndPersisted()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001D");
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, 0m), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().Be(0m, "budget=0 hợp lệ cho cột CCM (no submit-guard ở đây)");
// Validator cũng xác nhận 0 valid (và âm invalid).
var validator = new SetPeCcmBudgetPeriodCommandValidator();
validator.Validate(new SetPeCcmBudgetPeriodCommand(pe.Id, 0m))
.IsValid.Should().BeTrue("0 hợp lệ");
validator.Validate(new SetPeCcmBudgetPeriodCommand(pe.Id, -1m))
.IsValid.Should().BeFalse("giá âm vi phạm GreaterThanOrEqualTo(0)");
}
// 1e. Absolute-set null = clear — bắt đầu từ giá CÓ SẴN rồi gửi null → CLEAR
// (chứng minh absolute-set, KHÔNG skip-if-null giữ giá trị cũ — gotcha #73).
[Fact]
public async Task NullValue_ClearsExistingAmount_AbsoluteSet()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = await SeedPeAsync(db, "PE-CCMB-001E");
// Pre-seed giá có sẵn để chứng minh bị xoá (không phải vốn-dĩ-null).
pe.CcmBudgetPeriodAmount = 500_000_000m;
await db.SaveChangesAsync(CancellationToken.None);
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(
new SetPeCcmBudgetPeriodCommand(pe.Id, null), CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloaded.CcmBudgetPeriodAmount.Should().BeNull("absolute-set: null CLEAR field, KHÔNG giữ giá cũ");
// Validator: null hợp lệ (clear).
new SetPeCcmBudgetPeriodCommandValidator()
.Validate(new SetPeCcmBudgetPeriodCommand(pe.Id, null))
.IsValid.Should().BeTrue("null (clear) hợp lệ");
}
// 2. Unknown PE → NotFound (existence check TRƯỚC authz gate — line 172-173 < 176).
[Fact]
public async Task UnknownPe_ThrowsNotFound()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var handler = new SetPeCcmBudgetPeriodCommandHandler(db, new FakeCurrentUser(AppRoles.CostControl));
var act = async () => await handler.Handle(
new SetPeCcmBudgetPeriodCommand(Guid.NewGuid(), 1m), CancellationToken.None);
await act.Should().ThrowAsync<NotFoundException>();
}
}