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