[CLAUDE] PurchaseEvaluation: ngan sach goi thau theo Excel anh Kiet - bang tong hop 2 block + nhap theo role PRO/CCM + xoa module Budget cu (Mig 50)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s

- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-13 01:07:27 +07:00
parent 6db195dd42
commit 79ef8da9f4
70 changed files with 9052 additions and 5956 deletions

View File

@ -74,7 +74,6 @@ public class CreateContractCommandApplicableTypeTests
NoiDung: null,
BypassProcurementAndCCM: false,
DraftData: null,
BudgetId: null,
BudgetManualName: null,
BudgetManualAmount: null,
ApprovalWorkflowId: peOnlyWf.Id);

View File

@ -0,0 +1,694 @@
using Microsoft.AspNetCore.Identity;
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.Master;
using SolutionErp.Domain.Master.Catalogs;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
using SolutionErp.Infrastructure.Tests.Services; // NoOpNotificationService (reuse internal helper)
namespace SolutionErp.Infrastructure.Tests.Application;
// [S61 Mig 50] "Ngân sách PE theo Excel anh Kiệt" — thay module Budget cũ (5 bảng
// drop cùng Mig 50). Entity mới PeWorkItemBudget = 1 record/cặp (ProjectId,
// WorkItemId) loose-Guid, UNIQUE composite FILTERED [IsDeleted]=0 (gotcha #57 day-1).
//
// Test guard theo CODE đã land (single source of truth — S34 rule). 6 nhóm:
// 1. Auto-create on PE create (CreatePurchaseEvaluationCommandHandler ~:96-129)
// 2. EnsureTrackedAsync helper (idempotent + soft-deleted slot → tạo MỚI được)
// 3. UpdatePeBudgetPro authz matrix (Procurement|Admin fail-closed TRƯỚC side-effect)
// 4. UpdatePeBudgetCcm authz matrix (CostControl|Admin, Adjustment cho phép ÂM)
// 5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler ~:762-829)
// 6. AdjustBudget mới (absolute-set 2 field + validator >0 / -≥0 when HasValue)
//
// Pattern reuse: IdentityFixture + TestApplicationDbContext (SQLite) +
// FixedDateTime + FakeCurrentUser (mirror PeWorkItemGuardTests S57bis +
// ItTicketReassignAuthzTests S54). GUARD-FIRST seed: test fail đúng lý do.
//
// LƯU Ý SOFT-DELETE TRONG TEST: AuditingInterceptor (prod soft-delete) KHÔNG wire
// trong TestApplicationDbContext → Remove = HARD delete. Để test slot soft-deleted
// (test nhóm 2) SEED row IsDeleted=true thủ công. Filtered index [IsDeleted]=0 đã
// có day-1 (PeWorkItemBudgetConfiguration.cs:24-26) nên reuse slot hoạt động —
// KHÁC gotcha #57 RED của MasterCatalog (bare .IsUnique()).
public class PeWorkItemBudgetTests
{
// ICurrentUser fake với Roles configurable (matrix PRO / CCM / Admin / none).
private sealed class FakeCurrentUser : ICurrentUser
{
public Guid? UserId { get; init; } = Guid.NewGuid();
public string? Email { get; init; } = "actor@test.local";
public string? FullName { get; init; } = "Actor Test";
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
public bool IsAuthenticated => UserId is not null;
}
private static FakeCurrentUser AsRoles(params string[] roles)
=> new() { Roles = roles };
// ---- shared seed helpers ----
private static async Task<Project> SeedProjectAsync(TestApplicationDbContext db, string code = "PRJ-BG")
{
var p = new Project { Id = Guid.NewGuid(), Code = code, Name = "Dự án ngân sách " + code };
db.Projects.Add(p);
await db.SaveChangesAsync(CancellationToken.None);
return p;
}
private static async Task<WorkItem> SeedWorkItemAsync(
TestApplicationDbContext db, string code, bool isActive = true)
{
var wi = new WorkItem
{
Id = Guid.NewGuid(),
Code = code,
Name = "Hạng mục " + code,
IsActive = isActive,
};
db.WorkItems.Add(wi);
await db.SaveChangesAsync(CancellationToken.None);
return wi;
}
// Create handler stack đầy đủ (db + ICurrentUser + workflow svc + codeGen).
private static CreatePurchaseEvaluationCommandHandler BuildCreateHandler(
TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
{
var dt = new FixedDateTime(new DateTime(2026, 6, 13, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var workflow = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
var codeGen = new PurchaseEvaluationCodeGenerator(db, dt);
return new CreatePurchaseEvaluationCommandHandler(db, currentUser, workflow, codeGen);
}
private static CreatePurchaseEvaluationCommand BuildCreateCommand(Guid projectId, Guid? workItemId)
=> new(
Type: PurchaseEvaluationType.DuyetNcc,
TenGoiThau: "Gói thầu test",
ProjectId: projectId,
DepartmentId: null,
DiaDiem: null,
MoTa: null,
PaymentTerms: null,
BudgetPeriodAmount: null,
ApprovalWorkflowId: null,
WorkItemId: workItemId);
// Phiếu Nháp có gắn WorkItemId — dựng thủ công (cho test Pro/Ccm/Adjust/Summary).
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
Guid projectId,
Guid? workItemId,
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DangSoanThao,
Guid? drafterUserId = null,
decimal? budgetPeriodAmount = null,
Guid? selectedSupplierId = null,
DateTime? createdAt = null,
string code = "PE-BG")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = phase,
MaPhieu = code,
TenGoiThau = "Gói thầu gốc",
ProjectId = projectId,
WorkItemId = workItemId,
DrafterUserId = drafterUserId ?? Guid.NewGuid(),
BudgetPeriodAmount = budgetPeriodAmount,
SelectedSupplierId = selectedSupplierId,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
// CreatedAt do BaseEntity/DB default set — test summary cần kiểm soát thứ tự.
// TestApplicationDbContext KHÔNG wire AuditingInterceptor → set sau + Save lại
// để override giá trị default (lũy kế dùng CreatedAt < this).
if (createdAt is DateTime ca)
{
pe.CreatedAt = ca;
await db.SaveChangesAsync(CancellationToken.None);
}
return pe;
}
// =====================================================================
// 1. AUTO-CREATE ON PE CREATE
// =====================================================================
[Fact]
public async Task Create_WithWorkItem_AutoCreatesExactlyOnePeWorkItemBudget()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-AC1");
var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));
await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);
var recs = await db.PeWorkItemBudgets
.Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
recs.Should().HaveCount(1, "tạo phiếu đầu của cặp → auto-create đúng 1 record");
}
[Fact]
public async Task Create_SecondPeSamePair_StillOnlyOneBudgetRecord()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-AC2");
var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));
await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);
// Phiếu THỨ 2 cùng cặp (ProjectId, WorkItemId) → pre-check exists → KHÔNG thêm record.
await handler.Handle(BuildCreateCommand(project.Id, wi.Id), CancellationToken.None);
var recs = await db.PeWorkItemBudgets
.Where(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id).ToListAsync();
recs.Should().HaveCount(1, "1 record DUY NHẤT per cặp dùng chung mọi phiếu cùng gói thầu");
}
[Fact]
public async Task Create_TwoDistinctPairs_CreatesTwoBudgetRecords()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var project = await SeedProjectAsync(db);
var wiA = await SeedWorkItemAsync(db, "WI-ACa");
var wiB = await SeedWorkItemAsync(db, "WI-ACb");
var handler = BuildCreateHandler(db, um, AsRoles(AppRoles.Drafter));
await handler.Handle(BuildCreateCommand(project.Id, wiA.Id), CancellationToken.None);
await handler.Handle(BuildCreateCommand(project.Id, wiB.Id), CancellationToken.None);
(await db.PeWorkItemBudgets.CountAsync(b => b.ProjectId == project.Id))
.Should().Be(2, "2 cặp khác hạng mục → 2 record độc lập");
}
// =====================================================================
// 2. EnsureTrackedAsync helper (internal — gọi qua handler PUT pro)
// Idempotent + soft-deleted slot → tạo record MỚI được (filtered index).
// =====================================================================
[Fact]
public async Task EnsurePair_CalledTwiceSamePair_ReturnsSameRecordId()
{
// EnsureTrackedAsync internal → drive qua UpdatePeBudgetProCommandHandler 2 lần
// (Admin role). Lần 2 PHẢI tái dùng record lần 1 (cùng Id), KHÔNG tạo thêm.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-ENS1");
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);
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);
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");
}
[Fact]
public async Task EnsurePair_SoftDeletedRecordExists_CreatesNewActiveRecord()
{
// Slot (Project, WorkItem) đang bị 1 record SOFT-DELETED (IsDeleted=true) chiếm.
// Filtered unique [IsDeleted]=0 + HasQueryFilter(!IsDeleted) → exists-check bỏ
// qua deleted → EnsureTrackedAsync tạo record MỚI active được (KHÔNG đụng record cũ).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-ENS2");
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(),
ProjectId = project.Id,
WorkItemId = wi.Id,
ProEstimateAmount = 999m,
IsDeleted = true,
});
await db.SaveChangesAsync(CancellationToken.None);
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Admin));
var act = async () => await handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 50m, "mới"), CancellationToken.None);
await act.Should().NotThrowAsync(
"filtered index cho phép tạo record active mới khi slot cũ đã soft-delete");
(await db.PeWorkItemBudgets.CountAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.Should().Be(1, "query filter !IsDeleted chỉ thấy 1 record active");
(await db.PeWorkItemBudgets.IgnoreQueryFilters()
.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ũ");
}
// =====================================================================
// 3. UpdatePeBudgetPro authz matrix
// =====================================================================
[Fact]
public async Task UpdatePro_ProcurementRole_SetsEstimateAndNote()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO1");
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"),
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.ProNote.Should().Be("Dự trù theo đơn giá Q2");
}
[Fact]
public async Task UpdatePro_CostControlRole_ThrowsForbidden_AndDoesNotMutateRecord()
{
// CCM KHÔNG có quyền nhập PRO → ForbiddenException + record KHÔNG đổi (side-effect assert).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO2");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
// Pre-seed record với giá trị PRO sẵn để chứng minh KHÔNG bị mutate khi Forbidden.
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProEstimateAmount = 700m, 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))
.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.ProNote.Should().Be("giữ nguyên");
}
[Fact]
public async Task UpdatePro_AdminRole_Succeeds()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO3");
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 db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.ProEstimateAmount.Should().Be(42m, "Admin được nhập PRO");
}
[Fact]
public async Task UpdatePro_PeWorkItemIdNull_ThrowsConflict()
{
// Phiếu cũ chưa gắn Hạng mục (WorkItemId null) → Conflict (resolve record qua PE.WorkItemId).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var pe = await SeedPeAsync(db, project.Id, workItemId: null);
var handler = new UpdatePeBudgetProCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetProCommand(pe.Id, 10m, null), CancellationToken.None))
.Should().ThrowAsync<ConflictException>()
.WithMessage("*chưa gắn Hạng mục công việc*");
}
[Fact]
public async Task UpdatePro_RecordMissing_AutoCreatesThenSets()
{
// Cặp chưa có record (phiếu seed thủ công KHÔNG đi qua auto-create của Create handler)
// → handler auto-create qua EnsureTrackedAsync rồi set. budgetId non-null sau call.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-PRO4");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
(await db.PeWorkItemBudgets.AnyAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id))
.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);
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);
}
// =====================================================================
// 4. UpdatePeBudgetCcm authz matrix
// =====================================================================
[Fact]
public async Task UpdateCcm_CostControlRole_SetsInitialAndNegativeAdjustment()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCM1");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
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),
CancellationToken.None);
var rec = await db.PeWorkItemBudgets.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
rec.InitialAmount.Should().Be(80_000_000m);
rec.AdjustmentAmount.Should().Be(-5_000_000m, "Adjustment ÂM được chấp nhận");
}
[Fact]
public async Task UpdateCcm_ProcurementRole_ThrowsForbidden()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCM2");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
var handler = new UpdatePeBudgetCcmCommandHandler(db, AsRoles(AppRoles.Procurement));
await FluentActions.Awaiting(() => handler.Handle(
new UpdatePeBudgetCcmCommand(pe.Id, 10m, 0m), CancellationToken.None))
.Should().ThrowAsync<ForbiddenException>("chỉ CostControl | Admin được nhập ban hành/hiệu chỉnh");
}
[Fact]
public async Task UpdateCcm_AdminRole_Succeeds()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-CCM3");
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);
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);
}
// =====================================================================
// 5. budgetSummary aggregates (GetPurchaseEvaluationQueryHandler)
// =====================================================================
// Seed 1 NCC thắng + 1 detail + 1 quote ThanhTien cho phiếu (winner tổng thể).
// Set pe.SelectedSupplierId = supplierId trùng PurchaseEvaluationSupplier.SupplierId
// (join row 2 prevSelectedTotal: PES.SupplierId == p.SelectedSupplierId → quotes).
private static async Task SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
{
var pes = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
SupplierId = supplierId,
Order = 0,
};
var detail = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
GroupCode = "A.I",
GroupName = "Bê tông",
NoiDung = "Concrete",
Order = 0,
};
db.PurchaseEvaluationSuppliers.Add(pes);
db.PurchaseEvaluationDetails.Add(detail);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien,
});
await db.SaveChangesAsync(CancellationToken.None);
}
private static GetPurchaseEvaluationQueryHandler BuildQueryHandler(
IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser)
=> new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser);
[Fact]
public async Task BudgetSummary_AccumulatesPreviousSubmittedAndSelected_IgnoresTraLaiAndOtherPairs()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM1");
var otherWi = await SeedWorkItemAsync(db, "WI-SUM-OTHER");
var baseT = new DateTime(2026, 6, 13, 8, 0, 0, DateTimeKind.Utc);
// P1 DaDuyet — BudgetPeriod 100, winner quote 90, CreatedAt -3d.
var p1Winner = Guid.NewGuid();
var p1 = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
budgetPeriodAmount: 100m, selectedSupplierId: p1Winner,
createdAt: baseT.AddDays(-3), code: "PE-SUM-1");
await SeedWinnerWithQuoteAsync(db, p1, p1Winner, 90m);
// P2 ChoDuyet — BudgetPeriod 50, CreatedAt -2d (tính submitted, KHÔNG tính selected).
await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriodAmount: 50m, createdAt: baseT.AddDays(-2), code: "PE-SUM-2");
// P3 TraLai — BudgetPeriod 999, CreatedAt -1d → KHÔNG tính (quay về soạn).
await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.TraLai,
budgetPeriodAmount: 999m, createdAt: baseT.AddDays(-1), code: "PE-SUM-3");
// Phiếu KHÁC CẶP (cùng project, hạng mục khác) DaDuyet 777 → KHÔNG lẫn.
var otherWinner = Guid.NewGuid();
var pOther = await SeedPeAsync(db, project.Id, otherWi.Id, PurchaseEvaluationPhase.DaDuyet,
budgetPeriodAmount: 777m, selectedSupplierId: otherWinner,
createdAt: baseT.AddDays(-2), code: "PE-SUM-OTHER");
await SeedWinnerWithQuoteAsync(db, pOther, otherWinner, 777m);
// P_this DangSoanThao — BudgetPeriod 70, CreatedAt = baseT (mới nhất).
var pThis = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DangSoanThao,
budgetPeriodAmount: 70m, createdAt: baseT, code: "PE-SUM-THIS");
// GET P_this bằng Admin (bỏ qua authz scope) → đọc peBudgetSummary.
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pThis.Id), CancellationToken.None);
bundle.BudgetSummary.Should().NotBeNull("phiếu có WorkItemId → summary build");
var s = bundle.BudgetSummary!;
s.PreviousSubmittedTotal.Should().Be(150m, "P1(100,DaDuyet)+P2(50,ChoDuyet); P3 TraLai loại");
s.PreviousSubmittedCount.Should().Be(2);
s.PreviousSelectedTotal.Should().Be(90m, "chỉ P1 DaDuyet + có winner quote");
s.PreviousSelectedCount.Should().Be(1);
}
[Fact]
public async Task BudgetSummary_FullAmount_FallsBackToProEstimate_WhenCcmEmpty()
{
// CCM (Initial+Adjustment) cả null → fallback ProEstimate=500, FullIsEstimate=true.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM2");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(), ProjectId = project.Id, WorkItemId = wi.Id,
ProEstimateAmount = 500m, // 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(500m, "CCM trống → full = dự trù PRO");
s.FullIsEstimate.Should().BeTrue("cờ FE badge 'dự trù PRO'");
}
[Fact]
public async Task BudgetSummary_FullAmount_UsesCcmInitialPlusAdjustment_WhenCcmPresent()
{
// CCM Initial=400 + Adjustment=-50 → full=350, FullIsEstimate=false.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM3");
var pe = await SeedPeAsync(db, project.Id, wi.Id);
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
InitialAmount = 400m,
AdjustmentAmount = -50m,
});
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(350m, "CCM present → full = Initial + Adjustment (400 - 50)");
s.FullIsEstimate.Should().BeFalse("không phải dự trù — CCM đã nhập");
}
[Fact]
public async Task BudgetSummary_CanEditFlags_FollowRole()
{
// canEditPro theo Procurement|Admin; canEditCcm theo CostControl|Admin.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-SUM4");
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,
});
await db.SaveChangesAsync(CancellationToken.None);
// PRO user (drafter của phiếu để pass authz scope) → canEditPro true, canEditCcm false.
var proUser = new FakeCurrentUser { UserId = pe.DrafterUserId, Roles = new[] { AppRoles.Procurement } };
var bundlePro = await BuildQueryHandler(fix, db, proUser)
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundlePro.BudgetSummary!.CanEditPro.Should().BeTrue();
bundlePro.BudgetSummary!.CanEditCcm.Should().BeFalse("Procurement không sửa CCM");
var ccmUser = new FakeCurrentUser { UserId = pe.DrafterUserId, Roles = new[] { AppRoles.CostControl } };
var bundleCcm = await BuildQueryHandler(fix, db, ccmUser)
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundleCcm.BudgetSummary!.CanEditPro.Should().BeFalse("CostControl không sửa PRO");
bundleCcm.BudgetSummary!.CanEditCcm.Should().BeTrue();
}
[Fact]
public async Task BudgetSummary_Null_WhenPeHasNoWorkItem()
{
// Phiếu cũ chưa gắn Hạng mục (WorkItemId null) → summary null (FE banner nhắc gắn).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var pe = await SeedPeAsync(db, project.Id, workItemId: null);
var bundle = await BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin))
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
bundle.BudgetSummary.Should().BeNull("WorkItemId null → KHÔNG build summary");
}
// =====================================================================
// 6. AdjustBudget mới (absolute-set 2 field + validator)
// =====================================================================
[Fact]
public async Task AdjustBudget_DrafterDraft_SetsBothFields()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-ADJ1");
var drafterId = Guid.NewGuid();
var pe = await SeedPeAsync(db, project.Id, wi.Id,
PurchaseEvaluationPhase.DangSoanThao, drafterUserId: drafterId);
// Actor = chính Drafter của phiếu (scope Drafter khi Nháp).
var drafter = new FakeCurrentUser { UserId = drafterId, Roles = new[] { AppRoles.Drafter } };
var handler = new AdjustPurchaseEvaluationBudgetCommandHandler(db, drafter);
await handler.Handle(new AdjustPurchaseEvaluationBudgetCommand(pe.Id, 80m, 30m),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
reloaded.BudgetPeriodAmount.Should().Be(80m);
reloaded.ExpectedRemainingAmount.Should().Be(30m);
}
[Fact]
public void AdjustBudget_Validator_BudgetPeriodZero_FailsValidation()
{
// GreaterThan(0) when HasValue → 0 không hợp lệ.
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 0m, null));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e =>
e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.BudgetPeriodAmount));
}
[Fact]
public void AdjustBudget_Validator_ExpectedRemainingNegative_FailsValidation()
{
// GreaterThanOrEqualTo(0) when HasValue → âm không hợp lệ (row 8 không cho âm).
var validator = new AdjustPurchaseEvaluationBudgetCommandValidator();
var result = validator.Validate(new AdjustPurchaseEvaluationBudgetCommand(Guid.NewGuid(), 80m, -1m));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e =>
e.PropertyName == nameof(AdjustPurchaseEvaluationBudgetCommand.ExpectedRemainingAmount));
}
[Fact]
public async Task AdjustBudget_AbsoluteSet_NullExpectedRemaining_ClearsField()
{
// Absolute-set: gửi (BudgetPeriod=80, ExpectedRemaining=null) → ExpectedRemaining
// bị CLEAR về null (đúng thiết kế — KHÔNG partial-keep). Bắt đầu từ giá trị có sẵn.
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-ADJ2");
var drafterId = Guid.NewGuid();
var pe = await SeedPeAsync(db, project.Id, wi.Id,
PurchaseEvaluationPhase.DangSoanThao, drafterUserId: drafterId);
// Set ExpectedRemaining sẵn = 999 trước khi adjust với null.
var tracked = await db.PurchaseEvaluations.SingleAsync(x => x.Id == pe.Id);
tracked.ExpectedRemainingAmount = 999m;
await db.SaveChangesAsync(CancellationToken.None);
var drafter = new FakeCurrentUser { UserId = drafterId, Roles = new[] { AppRoles.Drafter } };
var handler = new AdjustPurchaseEvaluationBudgetCommandHandler(db, drafter);
await handler.Handle(new AdjustPurchaseEvaluationBudgetCommand(pe.Id, 80m, null),
CancellationToken.None);
var reloaded = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
reloaded.BudgetPeriodAmount.Should().Be(80m, "field này được set");
reloaded.ExpectedRemainingAmount.Should().BeNull("absolute-set: null request CLEAR field, KHÔNG giữ 999");
}
}

View File

@ -83,9 +83,7 @@ public class PeWorkItemGuardTests
DiaDiem: null,
MoTa: null,
PaymentTerms: null,
BudgetId: null,
BudgetManualName: null,
BudgetManualAmount: null,
BudgetPeriodAmount: null,
ApprovalWorkflowId: null,
WorkItemId: workItemId);
@ -233,9 +231,7 @@ public class PeWorkItemGuardTests
DiaDiem: null,
MoTa: null,
PaymentTerms: null,
BudgetId: null,
BudgetManualName: null,
BudgetManualAmount: null,
BudgetPeriodAmount: null,
ApprovalWorkflowId: null,
WorkItemId: workItemId);