[CLAUDE] PurchaseEvaluation: Mig 55 ô "Ghi chú từ CCM" ngân sách gói thầu — CCM nhập số + ghi lý do giống PRO
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m54s

- Entity PeWorkItemBudget +CcmNote (mirror ProNote, nvarchar 1000) + Mig 55 additive-nullable
- UpdatePeBudgetCcmCommand +CcmNote absolute-set, role-gate CostControl/Admin fail-closed
- DTO PeBudgetSummaryDto +CcmNote + controller BudgetCcmBody + GET mapping
- FE 2 app SHA-mirror: dòng "Ghi chú từ CCM" gate canEditCcm (sau V0/hiệu chỉnh), absolute-set đủ 3 field
- Test +5 (set CCM/Admin, null-clear, non-priv Forbidden, all-3-persist) -> 339 pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-18 19:05:10 +07:00
parent e7e99d10f2
commit 8655ebf1ba
14 changed files with 6487 additions and 15 deletions

View File

@ -385,7 +385,7 @@ public class PeWorkItemBudgetTests
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
// Adjustment ÂM 5tr — "hiệu chỉnh tăng giảm" cho phép ÂM (validator KHÔNG ràng dấu).
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m),
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m, null),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
@ -404,7 +404,7 @@ public class PeWorkItemBudgetTests
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 10m, 0m), CancellationToken.None))
new UpdatePeBudgetCcmCommand(pe.Id, 10m, 0m, null), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh");
}
@ -418,13 +418,130 @@ public class PeWorkItemBudgetTests
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m), CancellationToken.None);
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m, null), CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.InitialAmount.Should().Be(1m);
rec.AdjustmentAmount.Should().Be(2m);
}
// =====================================================================
// 4b. CcmNote [Mig 55] — mirror ProNote: absolute-set null=clear,
// role-gate CostControl|Admin fail-closed TRƯỚC side-effect.
// =====================================================================
[Fact]
public async Task UpdateCcm_CostControlRole_SetsCcmNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN1");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
await handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 80_000_000m, -5_000_000m, "Theo NS ban hành Q2 — nguồn dự toán CCM"),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.CcmNote.Should().Be("Theo NS ban hành Q2 — nguồn dự toán CCM", "CostControl được ghi chú CCM");
}
[Fact]
public async Task UpdateCcm_AdminRole_SetsCcmNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN2");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 1m, 2m, "admin ghi chú"), CancellationToken.None);
(await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.CcmNote.Should().Be("admin ghi chú", "Admin được ghi chú CCM");
}
[Fact]
public async Task UpdateCcm_NullCcmNote_ClearsField_AbsoluteSet()
{
// Absolute-set (mirror ProNote): gửi CcmNote=null → CLEAR về null, KHÔNG partial-keep.
// Bắt đầu từ giá trị có sẵn để chứng minh bị xoá (không phải vốn-dĩ-null).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN3");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
InitialAmount = 500m, AdjustmentAmount = 10m, CcmNote = "ghi chú cũ cần xoá",
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
await handler.Handle(new UpdatePeBudgetCcmCommand(pe.Id, 500m, 10m, null), CancellationToken.None);
var rec = await db.PeWorkItemBudgets.AsNoTracking()
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.CcmNote.Should().BeNull("absolute-set: CcmNote=null CLEAR field, KHÔNG giữ giá trị cũ");
}
[Fact]
public async Task UpdateCcm_NonPrivilegedRole_WithCcmNote_ThrowsForbidden_AndDoesNotMutateRecord()
{
// Procurement (KHÔNG CostControl/Admin) set CcmNote → ForbiddenException +
// record giữ nguyên (fail-closed: role-gate TRƯỚC EnsureTrackedAsync + side-effect).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN4");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
// Pre-seed record để chứng minh KHÔNG bị mutate khi Forbidden.
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
InitialAmount = 700m, AdjustmentAmount = -3m, CcmNote = "giữ nguyên",
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 9_999m, 1m, "không được ghi"), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh + ghi chú CCM");
var rec = await db.PeWorkItemBudgets.AsNoTracking()
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.CcmNote.Should().Be("giữ nguyên", "Forbidden TRƯỚC side-effect → CcmNote giữ nguyên");
rec.InitialAmount.Should().Be(700m, "không field nào bị mutate khi Forbidden");
rec.AdjustmentAmount.Should().Be(-3m);
}
[Fact]
public async Task UpdateCcm_InitialAdjustmentAndCcmNote_AllPersistTogether()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCMN5");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.CostControl));
await handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 120_000_000m, -8_000_000m, "ban hành + hiệu chỉnh + lý do"),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.InitialAmount.Should().Be(120_000_000m);
rec.AdjustmentAmount.Should().Be(-8_000_000m);
rec.CcmNote.Should().Be("ban hành + hiệu chỉnh + lý do", "cả 3 field persist trong 1 lệnh");
}
// =====================================================================
// 5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler)
// =====================================================================