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