[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
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:
@ -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)
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user