[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
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:
@ -1,145 +0,0 @@
|
||||
using SolutionErp.Domain.Budgets;
|
||||
using SolutionErp.Domain.Identity;
|
||||
|
||||
namespace SolutionErp.Domain.Tests.Budgets;
|
||||
|
||||
// Tests cho BudgetPolicy (hardcoded simple 3-step Default).
|
||||
// Chống regression khi BudgetPhase enum thêm phase hoặc role mapping bị edit.
|
||||
|
||||
public class BudgetPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_Drafter_DangSoanThao_To_ChoCCM_Allowed()
|
||||
{
|
||||
BudgetPolicies.Default
|
||||
.IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
|
||||
[AppRoles.Drafter])
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_DeptManager_DangSoanThao_To_ChoCCM_Allowed()
|
||||
{
|
||||
BudgetPolicies.Default
|
||||
.IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
|
||||
[AppRoles.DeptManager])
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_RandomRole_DangSoanThao_To_ChoCCM_Denied()
|
||||
{
|
||||
BudgetPolicies.Default
|
||||
.IsTransitionAllowed(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM,
|
||||
[AppRoles.Procurement])
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_CostControl_ChoCCM_To_ChoCEO_Allowed()
|
||||
{
|
||||
BudgetPolicies.Default
|
||||
.IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.ChoCEO,
|
||||
[AppRoles.CostControl])
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_CostControl_ChoCCM_To_TraLai_Allowed()
|
||||
{
|
||||
// Session 17 spec: Trả lại = Phase riêng (TraLai), không revert DangSoanThao
|
||||
BudgetPolicies.Default
|
||||
.IsTransitionAllowed(BudgetPhase.ChoCCM, BudgetPhase.TraLai,
|
||||
[AppRoles.CostControl])
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_Director_ChoCEO_To_DaDuyet_Allowed()
|
||||
{
|
||||
BudgetPolicies.Default
|
||||
.IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
|
||||
[AppRoles.Director])
|
||||
.Should().BeTrue();
|
||||
|
||||
BudgetPolicies.Default
|
||||
.IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
|
||||
[AppRoles.AuthorizedSigner])
|
||||
.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_CCM_Cannot_Approve_To_DaDuyet()
|
||||
{
|
||||
// CCM chỉ chuyển đến ChoCEO, không tự duyệt thành DaDuyet
|
||||
BudgetPolicies.Default
|
||||
.IsTransitionAllowed(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet,
|
||||
[AppRoles.CostControl])
|
||||
.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_DaDuyet_NoFurtherTransitions()
|
||||
{
|
||||
BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.DaDuyet)
|
||||
.Should().BeEmpty("DaDuyet là terminal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_TuChoi_NoFurtherTransitions()
|
||||
{
|
||||
BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.TuChoi)
|
||||
.Should().BeEmpty("TuChoi là terminal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_ActivePhases_Includes_All6States()
|
||||
{
|
||||
// Session 17: thêm TraLai = Phase riêng cho Trả lại
|
||||
BudgetPolicies.Default.ActivePhases.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
BudgetPhase.DangSoanThao, BudgetPhase.TraLai, BudgetPhase.ChoCCM,
|
||||
BudgetPhase.ChoCEO, BudgetPhase.DaDuyet, BudgetPhase.TuChoi,
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_NextPhasesFrom_DangSoanThao_Includes_ChoCCM_And_TuChoi()
|
||||
{
|
||||
var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.DangSoanThao);
|
||||
next.Should().Contain(BudgetPhase.ChoCCM);
|
||||
next.Should().Contain(BudgetPhase.TuChoi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_NextPhasesFrom_TraLai_Includes_ChoCCM_And_TuChoi()
|
||||
{
|
||||
// Drafter từ TraLai gửi lại = entry point thứ 2 (mirror DangSoanThao)
|
||||
var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.TraLai);
|
||||
next.Should().Contain(BudgetPhase.ChoCCM);
|
||||
next.Should().Contain(BudgetPhase.TuChoi);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_NextPhasesFrom_ChoCEO_Includes_DaDuyet_And_TraLai()
|
||||
{
|
||||
var next = BudgetPolicies.Default.NextPhasesFrom(BudgetPhase.ChoCEO);
|
||||
next.Should().Contain(BudgetPhase.DaDuyet);
|
||||
next.Should().Contain(BudgetPhase.TraLai);
|
||||
next.Should().Contain(BudgetPhase.TuChoi);
|
||||
}
|
||||
|
||||
// SLA — chống regression khi đổi phase deadline accidentally
|
||||
[Fact]
|
||||
public void Default_SlaDeadlines_Match_Spec()
|
||||
{
|
||||
BudgetPolicies.Default.PhaseSla[BudgetPhase.DangSoanThao]
|
||||
.Should().Be(TimeSpan.FromDays(5));
|
||||
BudgetPolicies.Default.PhaseSla[BudgetPhase.ChoCCM]
|
||||
.Should().Be(TimeSpan.FromDays(3));
|
||||
BudgetPolicies.Default.PhaseSla[BudgetPhase.ChoCEO]
|
||||
.Should().Be(TimeSpan.FromDays(2));
|
||||
BudgetPolicies.Default.PhaseSla[BudgetPhase.DaDuyet]
|
||||
.Should().BeNull("Terminal phase không có SLA");
|
||||
}
|
||||
}
|
||||
@ -74,7 +74,6 @@ public class CreateContractCommandApplicableTypeTests
|
||||
NoiDung: null,
|
||||
BypassProcurementAndCCM: false,
|
||||
DraftData: null,
|
||||
BudgetId: null,
|
||||
BudgetManualName: null,
|
||||
BudgetManualAmount: null,
|
||||
ApprovalWorkflowId: peOnlyWf.Id);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -52,8 +52,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
// random (test guard không cần drafter trong chuỗi). V2 nếu awId set.
|
||||
private static PurchaseEvaluation BuildPeNhap(
|
||||
Guid? selectedSupplierId = null,
|
||||
Guid? budgetId = null,
|
||||
decimal? budgetManualAmount = null,
|
||||
decimal? budgetPeriodAmount = null,
|
||||
Guid? approvalWorkflowId = null,
|
||||
Guid? drafterUserId = null,
|
||||
string code = "PE-S60-001")
|
||||
@ -67,8 +66,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = drafterUserId ?? Guid.NewGuid(),
|
||||
SelectedSupplierId = selectedSupplierId,
|
||||
BudgetId = budgetId,
|
||||
BudgetManualAmount = budgetManualAmount,
|
||||
BudgetPeriodAmount = budgetPeriodAmount, // [S61 Mig 50] thay BudgetId/BudgetManualAmount
|
||||
ApprovalWorkflowId = approvalWorkflowId,
|
||||
};
|
||||
}
|
||||
@ -215,7 +213,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetManualAmount: 500_000m);
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
SeedComparisonAttachment(db, pe);
|
||||
@ -237,7 +235,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetManualAmount: 500_000m);
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 0m); // quote = 0
|
||||
@ -261,7 +259,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetId: null, budgetManualAmount: 0m);
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 0m);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||
@ -286,7 +284,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetManualAmount: 500_000m);
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||
@ -309,7 +307,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetManualAmount: 500_000m);
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 500_000m);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||
@ -347,7 +345,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
var (svc, fix, db, clock) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetManualAmount: 750_000m);
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 750_000m);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||
@ -364,27 +362,9 @@ public class PeSubmitGuardAndBypassTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_AllFourMet_ViaBudgetId_ManualNull_SetsChoDuyet()
|
||||
{
|
||||
// (8) Đủ 4 qua BudgetId (manual null) → OK. Cover nhánh budget thoả qua FK
|
||||
// Budget thay vì manual amount.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetId: Guid.NewGuid(), budgetManualAmount: null);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
SeedComparisonAttachment(db, pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await SubmitAsync(svc, pe, Guid.NewGuid());
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
}
|
||||
}
|
||||
// [S61 Mig 50] Test (8) "đủ 4 qua BudgetId" XÓA — nhánh BudgetId không còn
|
||||
// tồn tại (module Budget cũ drop, predicate (3) chỉ còn BudgetPeriodAmount).
|
||||
// Nhánh thoả-mãn duy nhất đã cover bởi test (7) budgetPeriodAmount > 0.
|
||||
|
||||
// =====================================================================
|
||||
// FEATURE 2 — Drafter-in-chain bypass khi submit (V2-only)
|
||||
@ -397,7 +377,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code)
|
||||
{
|
||||
var pe = BuildPeNhap(
|
||||
budgetManualAmount: 1_000_000m,
|
||||
budgetPeriodAmount: 1_000_000m,
|
||||
approvalWorkflowId: workflowId,
|
||||
drafterUserId: drafterUserId,
|
||||
code: code);
|
||||
@ -569,7 +549,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
using (fix)
|
||||
{
|
||||
var drafter = (await fix.CreateUserAsync("v1d@s60.test", "V1 Drafter", null, new[] { AppRoles.Drafter })).Id;
|
||||
var pe = BuildPeNhap(budgetManualAmount: 1_000_000m, approvalWorkflowId: null, drafterUserId: drafter, code: "PE-S60-013");
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 1_000_000m, approvalWorkflowId: null, drafterUserId: drafter, code: "PE-S60-013");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||
|
||||
Reference in New Issue
Block a user