[CLAUDE] PurchaseEvaluation: Mig 56 ngan sach MA TRAN 3 cot (Du an|PRO|CCM) + badge quyen NS theo role
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m57s

S76 (anh Kiet FDC + chi Tra Sol) — form ngan sach + hien thi quyen nhap NS trong flow:
- Part 1: form ngan sach -> MA TRAN 3 cot, moi phong nhap+dieu chinh cot minh
  (PRO canEditPro / CCM canEditCcm / Du an FE hien-thi-only). Mig 56
  +ProInitialAmount/ProAdjustmentAmount (additive-nullable + data-migrate
  ProEstimate->ProInitial). full moi cot = ban hanh + hieu chinh.
- Part 2: Workflow Designer (fe-admin) +badge "NS PRO/CCM" canh approver
  (suy tu role Admin|Procurement / Admin|CostControl, hien-thi-only no-authz).
- Part 3: flow quy trinh fe-user/fe-admin (Duyet NCC) +badge tuong tu.
- Fix race mat-du-lieu Part 1 (useIsFetching khoa Luu khi refetch — dong
  cua-so stale-echo, reviewer Part2/3 bat).
- Test 339->344 (+5). 2 workflow review (Part 1 PASS + Part 2/3 PASS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-19 11:02:47 +07:00
parent 70c13d4ac8
commit e33481efb6
19 changed files with 6974 additions and 325 deletions

View File

@ -213,17 +213,17 @@ public class PeWorkItemBudgetTests
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 100m, "lần 1"), CancellationToken.None);
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 100m, null, "lần 1"), CancellationToken.None);
var firstId = (await db.PeWorkItemBudgets
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id)).Id;
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 200m, "lần 2"), CancellationToken.None);
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 200m, null, "lần 2"), CancellationToken.None);
var recs = await db.PeWorkItemBudgets
.Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
recs.Should().ContainSingle("EnsureTrackedAsync idempotent — 2 lần cùng cặp KHÔNG nhân đôi record");
recs[0].Id.Should().Be(firstId, "cùng record Id qua 2 lần gọi");
recs[0].ProEstimateAmount.Should().Be(200m, "lần 2 absolute-set đè giá trị mới");
recs[0].ProInitialAmount.Should().Be(200m, "lần 2 absolute-set đè giá trị mới (handler set ProInitialAmount)");
}
[Fact]
@ -242,7 +242,7 @@ public class PeWorkItemBudgetTests
Id = Guid.NewGuid(),
ProjectId = project.Id,
WorkItemId = wi.Id,
ProEstimateAmount = 999m,
ProInitialAmount = 999m,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
@ -251,7 +251,7 @@ public class PeWorkItemBudgetTests
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
var act = async () => await handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 50m, "mới"), CancellationToken.None);
new UpdatePeBudgetProCommand(pe.Id, 50m, null, "mới"), CancellationToken.None);
await act.Should().NotThrowAsync(
"filtered index cho phép tạo record active mới khi slot cũ đã soft-delete");
@ -261,7 +261,7 @@ public class PeWorkItemBudgetTests
.CountAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.Should().Be(2, "record soft-deleted gốc giữ lại cho audit + active mới");
(await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.ProEstimateAmount.Should().Be(50m, "record active mới mang giá trị set, KHÔNG kế thừa 999 cũ");
.ProInitialAmount.Should().Be(50m, "record active mới mang giá trị set, KHÔNG kế thừa 999 cũ");
}
// =====================================================================
@ -269,7 +269,7 @@ public class PeWorkItemBudgetTests
// =====================================================================
[Fact]
public async Task UpdatePro_ProcurementRole_SetsEstimateAndNote()
public async Task UpdatePro_ProcurementRole_SetsInitialAndNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
@ -278,11 +278,11 @@ public class PeWorkItemBudgetTests
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 1_500_000m, "Dự trù theo đơn giá Q2"),
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 1_500_000m, null, "Dự trù theo đơn giá Q2"),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.ProEstimateAmount.Should().Be(1_500_000m);
rec.ProInitialAmount.Should().Be(1_500_000m, "handler set ProInitialAmount (arg2 = ProInitial)");
rec.ProNote.Should().Be("Dự trù theo đơn giá Q2");
}
@ -300,19 +300,20 @@ public class PeWorkItemBudgetTests
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProEstimateAmount = 700m, ProNote = "giữ nguyên",
ProInitialAmount = 700m, ProAdjustmentAmount = -10m, ProNote = "giữ nguyên",
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.CostControl));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 9_999m, "không được set"), CancellationToken.None))
new UpdatePeBudgetProCommand(pe.Id, 9_999m, 1m, "không được set"), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ Procurement | Admin được nhập dự trù PRO");
var rec = await db.PeWorkItemBudgets.AsNoTracking()
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.ProEstimateAmount.Should().Be(700m, "Forbidden TRƯỚC side-effect → record giữ nguyên");
rec.ProInitialAmount.Should().Be(700m, "Forbidden TRƯỚC side-effect → record giữ nguyên");
rec.ProAdjustmentAmount.Should().Be(-10m, "không field PRO nào bị mutate khi Forbidden");
rec.ProNote.Should().Be("giữ nguyên");
}
@ -326,10 +327,10 @@ public class PeWorkItemBudgetTests
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 42m, "admin set"), CancellationToken.None);
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 42m, null, "admin set"), CancellationToken.None);
(await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.ProEstimateAmount.Should().Be(42m, "Admin được nhập PRO");
.ProInitialAmount.Should().Be(42m, "Admin được nhập PRO");
}
[Fact]
@ -343,7 +344,7 @@ public class PeWorkItemBudgetTests
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 10m, null), CancellationToken.None))
new UpdatePeBudgetProCommand(pe.Id, 10m, null, null), CancellationToken.None))
.Should().ThrowAsync<ConflictException>()
.WithMessage("*chưa gắn Hạng mục công việc*");
}
@ -363,11 +364,56 @@ public class PeWorkItemBudgetTests
.Should().BeFalse("tiền điều kiện: chưa có record cho cặp");
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 333m, "auto"), CancellationToken.None);
await handler.Handle(new UpdatePeBudgetProCommand(pe.Id, 333m, null, "auto"), CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.Id.Should().NotBe(Guid.Empty, "auto-create record có Id thật");
rec.ProEstimateAmount.Should().Be(333m);
rec.ProInitialAmount.Should().Be(333m);
}
[Fact]
public async Task UpdatePro_SetsBothInitialAndNegativeAdjustment_PersistTogether()
{
// [S76] PRO column split — set CẢ ProInitial + ProAdjust (gồm ÂM) trong 1 lệnh.
// ProAdjustmentAmount cho phép ÂM ("V0/hiệu chỉnh tăng giảm", validator KHÔNG ràng dấu —
// mirror CCM AdjustmentAmount). Cả 2 field + note persist đủ.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO5");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
await handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 100_000_000m, -20_000_000m, "ban hành + V0 giảm 20tr"),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.ProInitialAmount.Should().Be(100_000_000m, "ban hành lần đầu PRO");
rec.ProAdjustmentAmount.Should().Be(-20_000_000m, "V0/hiệu chỉnh PRO ÂM được chấp nhận");
rec.ProNote.Should().Be("ban hành + V0 giảm 20tr", "cả 3 field PRO persist trong 1 lệnh");
}
[Fact]
public void UpdatePro_Validator_NegativeInitial_FailsValidation()
{
// ProInitialAmount >= 0 when HasValue → ÂM không hợp lệ (khác ProAdjustment).
var validator = new UpdatePeBudgetProCommandValidator();
var result = validator.Validate(new UpdatePeBudgetProCommand(Guid.NewGuid(), -1m, null, null));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e =>
e.PropertyName == nameof(UpdatePeBudgetProCommand.ProInitialAmount));
}
[Fact]
public void UpdatePro_Validator_NegativeAdjustment_PassesValidation()
{
// ProAdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM (mirror CCM).
var validator = new UpdatePeBudgetProCommandValidator();
var result = validator.Validate(new UpdatePeBudgetProCommand(Guid.NewGuid(), 50m, -999m, null));
result.IsValid.Should().BeTrue();
}
// =====================================================================
@ -634,9 +680,10 @@ public class PeWorkItemBudgetTests
}
[Fact]
public async Task BudgetSummary_FullAmount_FallsBackToProEstimate_WhenCcmEmpty()
public async Task BudgetSummary_FullAmount_FallsBackToProFull_WhenCcmEmpty()
{
// CCM (Initial+Adjustment) cả null → fallback ProEstimate=500, FullIsEstimate=true.
// [S76] CCM (Initial+Adjustment) cả null → fallback proFull = ProInitial(500)+ProAdjust(0)
// = 500, FullIsEstimate=true. KHÔNG còn dùng ProEstimateAmount (legacy).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
@ -646,7 +693,7 @@ public class PeWorkItemBudgetTests
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProEstimateAmount = 500m, // CCM Initial + Adjustment đều null
ProInitialAmount = 500m, // CCM Initial + Adjustment đều null
});
await db.SaveChangesAsync(CancellationToken.None);
@ -654,8 +701,8 @@ public class PeWorkItemBudgetTests
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.FullAmount.Should().Be(500m, "CCM trống → full = dự trù PRO");
s.FullIsEstimate.Should().BeTrue("cờ FE badge 'dự trù PRO'");
s.FullAmount.Should().Be(500m, "CCM trống → full = ngân sách PRO (ProInitial + ProAdjust)");
s.FullIsEstimate.Should().BeTrue("cờ FE badge 'ngân sách PRO'");
}
[Fact]
@ -671,7 +718,7 @@ public class PeWorkItemBudgetTests
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProEstimateAmount = 500m, // có nhưng KHÔNG dùng khi CCM present
ProInitialAmount = 500m, ProAdjustmentAmount = 20m, // PRO có nhưng KHÔNG dùng khi CCM present
InitialAmount = 400m,
AdjustmentAmount = -50m,
});
@ -681,10 +728,63 @@ public class PeWorkItemBudgetTests
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.FullAmount.Should().Be(350m, "CCM present → full = Initial + Adjustment (400 - 50)");
s.FullAmount.Should().Be(350m, "CCM present → full = Initial + Adjustment (400 - 50), PRO bị bỏ qua");
s.FullIsEstimate.Should().BeFalse("không phải dự trù — CCM đã nhập");
}
[Fact]
public async Task BudgetSummary_FullAmount_ProFull_SumsProInitialAndAdjustment_WhenCcmEmpty()
{
// [S76] CCM trống → proFull = ProInitial(100) + ProAdjust(50) = 150. FullIsEstimate=true.
// DTO cũng surface ProInitial/ProAdjust riêng (2 field cuối record) cho FE render từng cột.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM5");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProInitialAmount = 100m, ProAdjustmentAmount = 50m, // CCM Initial + Adjustment đều null
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.FullAmount.Should().Be(150m, "CCM trống → full = proFull = ProInitial + ProAdjust (100 + 50)");
s.FullIsEstimate.Should().BeTrue("PRO nhập + CCM trống → cờ dự trù PRO");
s.ProInitialAmount.Should().Be(100m, "DTO surface ProInitial riêng cho FE");
s.ProAdjustmentAmount.Should().Be(50m, "DTO surface ProAdjust riêng cho FE");
}
[Fact]
public async Task BudgetSummary_FullAmount_NegativeProAdjustment_ReducesProFull_WhenCcmEmpty()
{
// proFull cho phép ProAdjust ÂM → 100 + (-30) = 70. Chứng minh full = tổng đại số (không clamp).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM6");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProInitialAmount = 100m, ProAdjustmentAmount = -30m,
});
await db.SaveChangesAsync(CancellationToken.None);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.FullAmount.Should().Be(70m, "proFull = ProInitial + ProAdjust ÂM (100 - 30)");
s.FullIsEstimate.Should().BeTrue();
}
[Fact]
public async Task BudgetSummary_CanEditFlags_FollowRole()
{
@ -696,7 +796,7 @@ public class PeWorkItemBudgetTests
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id, ProEstimateAmount = 10m,
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id, ProInitialAmount = 10m,
});
await db.SaveChangesAsync(CancellationToken.None);