[CLAUDE] PurchaseEvaluation: freeze ngân sách phiếu DaDuyet — Mig 67 snapshot 11 cột + display-gate budgetFrozen + 2 terminal-guard (UAT anh Kiệt 07-16)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m29s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m29s
- Bug UAT: PeWorkItemBudgets (record NS dùng chung per Dự án × Hạng mục) sửa được sau duyệt → phiếu ĐÃ DUYỆT đổi số retroactive (detail-bundle load pair LIVE :911 + FE fallback ?? bs.*). - Fix: Mig 67 AddPeApprovedBudgetSnapshot — 11 cột ApprovedBudget* trên PurchaseEvaluations; helper ApplyBudgetSnapshotOnFinalizeAsync gọi ở 5 site set DaDuyet (4 finalize + admin-override catch-all — reviewer catch #81-class, assignment qua biến lọt literal-grep); display-gate: DaDuyet+SnapshotAt → serve snapshot + BudgetFrozen=true + CanEdit=false, else LIVE (R2: NS live vẫn là "bản cập nhật gần nhất" cho phiếu chưa duyệt — UpdatePeBudgetPro/Ccm giữ nguyên không phase-guard). - 2 terminal-guard block CẢ Admin: SetPeCcmBudgetPeriod + AdjustBudget → Conflict khi DaDuyet/TuChoi (đường chính thống: un-terminal → duyệt lại → re-snapshot overwrite). - Backfill idempotent phiếu DaDuyet cũ = live hiện tại (số tại-thời-điểm-duyệt không khôi phục được — Changelogs attribution-gap); OUTER APPLY dịch đúng predicate PeBudgetAccumulator (3 bản khớp: SQL ↔ accumulator ↔ code gốc); demo-seeder DbInitializer cũng snapshot (fresh-DB gap — reviewer catch). - PeBudgetAccumulator extract lũy kế behavior-preserving (dùng chung GetDetail live + helper snapshot); FE 2 app: types +budgetFrozen + PeDetailTabs badge khoá NS (SHA-mirror giữ, hash 2 app bằng nhau). - Tests 509→520 (T0 reproduce RED→GREEN + T1-T9 + AdminOverride site-5); build slnx 0/0; npm build ×2 PASS; Mig applied 2 LocalDB (Dev catch-up cả Mig 66 lag). - Pipeline S133: /fable-real invest (wf_348f6155) → spec → HMW Opus MAX (wf_d9639faa / wf_4bef08b8 / wf_75c6bfd3) → reviewer PASS_WITH_FIXES (wf_f9c0b939) → 2 SHOULD fixed. #53 return-fail ×3/5 lane — disk-truth recover 3/3, 0 mất việc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,906 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions; // ConflictException (T5/T6 guards)
|
||||
using SolutionErp.Application.PurchaseEvaluations;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
|
||||
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;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
|
||||
// ===== S133 (2026-07-17) T0 REPRODUCE — bug "phiếu DaDuyet đổi số ngân sách theo bảng LIVE" =====
|
||||
// UAT anh Kiệt: "các phiếu đã duyệt thì KHÔNG cho thay đổi gì hết". Hiện tại mọi phiếu cùng cặp
|
||||
// (ProjectId × WorkItemId) đọc CHUNG 1 record PeWorkItemBudgets LIVE:
|
||||
// - GetPurchaseEvaluationQueryHandler (PurchaseEvaluationFeatures.cs:911-912) load pairRec LIVE,
|
||||
// KHÔNG phân biệt Phase → :964-972 build PeBudgetSummaryDto từ pairRec.
|
||||
// ⇒ ai đó nhập lại ngân sách cho gói thầu (record dùng chung) thì phiếu ĐÃ DUYỆT cũng đổi số.
|
||||
//
|
||||
// TEST-BEFORE (bug fix = reproduce → fix, docs/rules.md §7). Test này PHẢI **RED** trên code hiện
|
||||
// tại (đọc live → 999tr/888tr) và sẽ GREEN sau khi fix snapshot ngân sách tại thời điểm chuyển
|
||||
// DaDuyet. KHÔNG touch production (src/Backend, fe-*), KHÔNG sửa test cũ — chỉ WRITE test này.
|
||||
//
|
||||
// ⚠️ Tiền đề T0 CHUẨN (MEMORY-SLICE): đưa phiếu tới DaDuyet QUA service finalize path (mirror
|
||||
// PeApproverFinalizeTests test #5 AtLastSlot: workflow V2 1 Bước 1 Cấp AllowApproverFinalize=true,
|
||||
// approver duyệt + giá chốt → TỰ DaDuyet). TUYỆT ĐỐI KHÔNG seed thẳng Phase=DaDuyet — vì sau fix,
|
||||
// snapshot chỉ được set qua transition; seed thẳng sẽ làm T0 mất nghĩa (không có bản chụp để giữ).
|
||||
public class PeBudgetFreezeTests
|
||||
{
|
||||
// Giá chốt hợp lệ khi finalize (ApplyApprovedPriceOnFinalize đòi source ∈ ValidApprovedPriceSources).
|
||||
private const decimal ValidApprovedPrice = 400_000_000m;
|
||||
private const string ValidApprovedSource = "Ncc";
|
||||
|
||||
// Bộ số ngân sách TẠI thời điểm duyệt (snapshot phải giữ) vs SAU khi duyệt (record live bị đổi).
|
||||
private const decimal ProAtApproval = 100_000_000m; // PRO ban hành lúc duyệt (kỳ vọng giữ)
|
||||
private const decimal CcmAtApproval = 200_000_000m; // CCM ban hành lúc duyệt (kỳ vọng giữ)
|
||||
private const decimal ProAfterApproval = 999_000_000m; // PRO đổi SAU khi duyệt (record live)
|
||||
private const decimal CcmAfterApproval = 888_000_000m; // CCM đổi SAU khi duyệt (record live)
|
||||
|
||||
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
|
||||
TestApplicationDbContext db) CreateService()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var clock = new FixedDateTime(new DateTime(2026, 7, 17, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
|
||||
return (svc, fix, db);
|
||||
}
|
||||
|
||||
// Workflow V2 1 Bước 1 Cấp, ApproverUserId = approver, AllowApproverFinalize=true → duyệt tới
|
||||
// cấp này (slot cuối) = TỰ DaDuyet qua finalize-branch (PurchaseEvaluationWorkflowService.cs:855).
|
||||
private static async Task<ApprovalWorkflow> SeedFinalizeWorkflowAsync(
|
||||
TestApplicationDbContext db, Guid approverUserId, string code = "QT-BF-V2")
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = code, // unique per call — UNIQUE(Code,Version) khi 1 test seed nhiều workflow
|
||||
Version = 1,
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
Name = "QT test budget-freeze",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
Name = "Bước 1",
|
||||
};
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
Name = "Cấp 1",
|
||||
ApproverUserId = approverUserId,
|
||||
AllowApproverFinalize = true, // slot cuối = KẾT THÚC → duyệt là DaDuyet
|
||||
});
|
||||
wf.Steps.Add(step);
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T0 REPRODUCE — phiếu DaDuyet PHẢI giữ ngân sách tại thời điểm duyệt (snapshot),
|
||||
// KHÔNG chạy theo record PeWorkItemBudgets live bị đổi sau khi duyệt.
|
||||
// RED hiện tại: budgetSummary đọc live → 999tr / 888tr.
|
||||
// GREEN sau fix: snapshot → 100tr / 200tr.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task DaDuyet_BudgetSummary_KeepsSnapshotValue_DoesNotFollowLivePairUpdate()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// (a) Cặp (Dự án × Hạng mục) + record ngân sách gói thầu PRO=100tr / CCM=200tr.
|
||||
var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-BF", Name = "Dự án budget-freeze" };
|
||||
var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-BF", Name = "Hạng mục budget-freeze", IsActive = true };
|
||||
db.Projects.Add(project);
|
||||
db.WorkItems.Add(wi);
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
ProInitialAmount = ProAtApproval, // 100tr (PRO ban hành lúc duyệt)
|
||||
InitialAmount = CcmAtApproval, // 200tr (CCM ban hành lúc duyệt)
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// approver = người duyệt cấp finalize; drafter = "user thường" xem phiếu sau khi duyệt.
|
||||
var approver = (await fix.CreateUserAsync("appr@bf.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var drafter = (await fix.CreateUserAsync("drafter@bf.test", "Drafter", null, Array.Empty<string>())).Id;
|
||||
|
||||
var wf = await SeedFinalizeWorkflowAsync(db, approver);
|
||||
|
||||
// (b) Phiếu tại slot duyệt cuối (ChoDuyet) gắn ĐÚNG cặp + workflow finalize.
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = "PE-BF-001",
|
||||
TenGoiThau = "Gói thầu budget-freeze",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = drafter,
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
SlaDeadline = new DateTime(2026, 7, 24, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// (b') Đưa tới DaDuyet QUA service finalize path (KHÔNG seed thẳng Phase=DaDuyet).
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place
|
||||
actorUserId: approver,
|
||||
actorRoles: Array.Empty<string>(),
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
approvedPriceAmount: ValidApprovedPrice,
|
||||
approvedPriceSource: ValidApprovedSource,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||
"tiền đề T0: phiếu PHẢI ở DaDuyet qua finalize path (mirror PeApproverFinalizeTests), không seed thẳng");
|
||||
|
||||
// (c) SAU khi duyệt, đổi record ngân sách LIVE: PRO 100tr→999tr, CCM 200tr→888tr (Admin).
|
||||
// UpdatePeBudgetPro/Ccm KHÔNG có phase-guard → chạy được ở DaDuyet (đây chính là bug:
|
||||
// pair dùng chung bị đổi → phiếu đã duyệt cũng đổi số).
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
await new UpdatePeBudgetProCommandHandler(db, admin)
|
||||
.Handle(new UpdatePeBudgetProCommand(pe.Id, ProAfterApproval, null, "đổi sau khi duyệt"),
|
||||
CancellationToken.None);
|
||||
await new UpdatePeBudgetCcmCommandHandler(db, admin)
|
||||
.Handle(new UpdatePeBudgetCcmCommand(pe.Id, CcmAfterApproval, null, "đổi sau khi duyệt"),
|
||||
CancellationToken.None);
|
||||
|
||||
// Sanity: record LIVE thật sự đã đổi (chứng minh update ăn — assert (d) không vacuous-pass).
|
||||
var liveRec = await db.PeWorkItemBudgets.AsNoTracking()
|
||||
.SingleAsync(b => b.ProjectId == project.Id && b.WorkItemId == wi.Id);
|
||||
liveRec.ProInitialAmount.Should().Be(ProAfterApproval, "record LIVE đã đổi PRO → 999tr");
|
||||
liveRec.InitialAmount.Should().Be(CcmAfterApproval, "record LIVE đã đổi CCM → 888tr");
|
||||
|
||||
// (d) "user thường" (drafter, isDrafter=true qua authz) xem phiếu DaDuyet → budgetSummary
|
||||
// PHẢI GIỮ số tại thời điểm duyệt (snapshot), KHÔNG chạy theo record live mới đổi.
|
||||
var querier = new TestCurrentUser(drafter); // no roles = user thường
|
||||
var handler = new GetPurchaseEvaluationQueryHandler(
|
||||
db, fix.Services.GetRequiredService<UserManager<User>>(), querier);
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
bundle.BudgetSummary.Should().NotBeNull("phiếu có WorkItemId → budgetSummary được build");
|
||||
var s = bundle.BudgetSummary!;
|
||||
|
||||
// ⭐ RED trên code hiện tại (đọc live → 999tr / 888tr). GREEN sau fix snapshot (100tr / 200tr).
|
||||
s.ProInitialAmount.Should().Be(ProAtApproval,
|
||||
"phiếu DaDuyet phải GIỮ ngân sách PRO tại thời điểm duyệt (100tr), KHÔNG chạy theo record live (999tr)");
|
||||
s.InitialAmount.Should().Be(CcmAtApproval,
|
||||
"phiếu DaDuyet phải GIỮ ngân sách CCM tại thời điểm duyệt (200tr), KHÔNG chạy theo record live (888tr)");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ===== INV-C (S133) T1-T9 — phủ 4 nhánh snapshot + 2 guard + edge =====
|
||||
// BE ĐÃ LAND (11 cột ApprovedBudget* + helper ApplyBudgetSnapshotOnFinalizeAsync 4 site
|
||||
// + display-gate frozen/live + 2 terminal-guard). T1-T9 GREEN ngay (KHÔNG RED phase —
|
||||
// T0 là reproduce duy nhất). Test theo CODE (S34 single-source-of-truth). KHÔNG sửa T0.
|
||||
// =====================================================================
|
||||
|
||||
// Bộ số ngân sách gói thầu TẠI thời điểm duyệt (snapshot phải giữ nguyên bộ này).
|
||||
private const decimal PairProInitial = 100_000_000m; // pair.ProInitialAmount (PRO)
|
||||
private const decimal PairProAdjust = -10_000_000m; // pair.ProAdjustmentAmount (PRO, ÂM hợp lệ)
|
||||
private const string PairProNote = "PRO ban hành Q3 — dự trù đơn giá";
|
||||
private const decimal PairCcmInitial = 200_000_000m; // pair.InitialAmount (CCM)
|
||||
private const decimal PairCcmAdjust = -20_000_000m; // pair.AdjustmentAmount (CCM, ÂM hợp lệ)
|
||||
private const string PairCcmNote = "CCM nguồn dự toán đã duyệt";
|
||||
|
||||
// Clock của CreateService (mirror T0) — SnapshotAt kỳ vọng = giá trị này.
|
||||
private static readonly DateTime ClockNow = new(2026, 7, 17, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ---- shared seed helpers (INV-C) ----
|
||||
|
||||
private static async Task<(Project project, WorkItem wi)> SeedProjectAndWorkItemAsync(
|
||||
TestApplicationDbContext db, string suffix)
|
||||
{
|
||||
var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-BF-" + suffix, Name = "Dự án " + suffix };
|
||||
var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-BF-" + suffix, Name = "Hạng mục " + suffix, IsActive = true };
|
||||
db.Projects.Add(project);
|
||||
db.WorkItems.Add(wi);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (project, wi);
|
||||
}
|
||||
|
||||
// 1 record PeWorkItemBudget per cặp (ProjectId × WorkItemId). NAMING: Initial/Adjustment = CCM.
|
||||
private static async Task SeedPairAsync(
|
||||
TestApplicationDbContext db, Guid projectId, Guid wiId,
|
||||
decimal? proInit, decimal? proAdj, string? proNote,
|
||||
decimal? ccmInit, decimal? ccmAdj, string? ccmNote)
|
||||
{
|
||||
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProjectId = projectId,
|
||||
WorkItemId = wiId,
|
||||
ProInitialAmount = proInit,
|
||||
ProAdjustmentAmount = proAdj,
|
||||
ProNote = proNote,
|
||||
InitialAmount = ccmInit,
|
||||
AdjustmentAmount = ccmAdj,
|
||||
CcmNote = ccmNote,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// PE đứng tại slot duyệt cuối V2 (ChoDuyet) gắn cặp + workflow đã pin.
|
||||
private static PurchaseEvaluation BuildPeAtFinalizeSlot(
|
||||
Guid approvalWorkflowId, Guid projectId, Guid? workItemId, Guid drafterUserId, string code)
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Gói thầu " + code,
|
||||
ProjectId = projectId,
|
||||
WorkItemId = workItemId,
|
||||
DrafterUserId = drafterUserId,
|
||||
ApprovalWorkflowId = approvalWorkflowId,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
CurrentApprovalLevelOrder = 1,
|
||||
SlaDeadline = new DateTime(2026, 7, 24, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
// Approve-in-place + giá chốt hợp lệ (mọi nhánh finalize human cần giá chốt).
|
||||
private static Task ApproveWithPriceAsync(
|
||||
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid approver, string[] roles) =>
|
||||
svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: approver,
|
||||
actorRoles: roles,
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
approvedPriceAmount: ValidApprovedPrice,
|
||||
approvedPriceSource: ValidApprovedSource,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// Seed 1 đơn vị IsWinner + 1 detail + 1 quote IsSelected cho phiếu (own quotes → winnerQuoteTotal).
|
||||
private static async Task SeedWinnerQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal thanhTien)
|
||||
{
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = Guid.NewGuid(),
|
||||
Order = 0,
|
||||
IsWinner = true,
|
||||
};
|
||||
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,
|
||||
IsSelected = true,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Peer PE cùng cặp cho accumulator (lũy kế phiếu TRƯỚC). CreatedAt override thủ công
|
||||
// (TestApplicationDbContext KHÔNG wire audit interceptor) để < CreatedAt phiếu đang xét.
|
||||
private static async Task SeedPeerAsync(
|
||||
TestApplicationDbContext db, Guid projectId, Guid wiId,
|
||||
PurchaseEvaluationPhase phase, decimal? budgetPeriod, DateTime createdAt,
|
||||
decimal? winnerQuote, string code)
|
||||
{
|
||||
var peer = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = phase,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Peer " + code,
|
||||
ProjectId = projectId,
|
||||
WorkItemId = wiId,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
BudgetPeriodAmount = budgetPeriod,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(peer);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
peer.CreatedAt = createdAt; // accumulator dùng CreatedAt < this
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
if (winnerQuote is decimal wq)
|
||||
await SeedWinnerQuoteAsync(db, peer, wq);
|
||||
}
|
||||
|
||||
// Workflow V2 1 Bước 1 Cấp KHÔNG finalize-flag KHÔNG threshold → approve slot cuối =
|
||||
// nhánh all-steps-done (:933). Khác SeedFinalizeWorkflowAsync (AllowApproverFinalize=true, :861).
|
||||
private static async Task<ApprovalWorkflow> SeedPlainWorkflowAsync(
|
||||
TestApplicationDbContext db, Guid approverUserId)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = "QT-BF-PLAIN",
|
||||
Version = 1,
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
Name = "QT budget-freeze all-steps",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep { ApprovalWorkflowId = wf.Id, Order = 1, Name = "Bước 1" };
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
Name = "Cấp 1",
|
||||
ApproverUserId = approverUserId,
|
||||
});
|
||||
wf.Steps.Add(step);
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
// Workflow V2 1 Bước 1 Cấp CCM + CeoApprovalThreshold → CCM tích uỷ-quyền finalize
|
||||
// (nhánh :900). Cấp KHÔNG AllowApproverFinalize (mặc định) → rơi xuống ccm-delegation.
|
||||
private static async Task<ApprovalWorkflow> SeedCcmThresholdWorkflowAsync(
|
||||
TestApplicationDbContext db, Guid ccmUserId, decimal ceoThreshold)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = "QT-BF-CCM",
|
||||
Version = 1,
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
Name = "QT budget-freeze CCM threshold",
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
CeoApprovalThreshold = ceoThreshold,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep { ApprovalWorkflowId = wf.Id, Order = 1, Name = "Bước 1" };
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
Name = "Cấp 1 (CCM)",
|
||||
ApproverUserId = ccmUserId,
|
||||
});
|
||||
wf.Steps.Add(step);
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
// Seed cặp (full pair 6 cột) + workflow level-finalize + PE ChoDuyet → finalize qua service →
|
||||
// trả PE ĐÃ DaDuyet (snapshot đã chốt). Reuse cho T5/T6/T7(frozen). withWorkItem/seedPair tuỳ chọn.
|
||||
private static async Task<(PurchaseEvaluation pe, Guid drafter, Project project, WorkItem wi)>
|
||||
SeedAndFinalizeAsync(
|
||||
PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db,
|
||||
string suffix, bool withWorkItem = true, bool seedPair = true)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, suffix);
|
||||
Guid? wiId = withWorkItem ? wi.Id : null;
|
||||
if (seedPair && withWorkItem)
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, PairProAdjust, PairProNote, PairCcmInitial, PairCcmAdjust, PairCcmNote);
|
||||
|
||||
var approver = (await fix.CreateUserAsync($"appr-{suffix}@bf.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var drafter = (await fix.CreateUserAsync($"drafter-{suffix}@bf.test", "Drafter", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedFinalizeWorkflowAsync(db, approver, $"QT-BF-V2-{suffix}");
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wiId, drafter, $"PE-BF-{suffix}");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "tiền đề helper: PE phải ở DaDuyet qua finalize path");
|
||||
return (pe, drafter, project, wi);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T1 — LEVEL-FINALIZE (:861) chốt ĐỦ 11 cột snapshot: 6 cột pair (PRO/CCM Initial+
|
||||
// Adjustment+Note) + 4 cột lũy kế (accumulator, theo 2 peer seed) + SnapshotAt.
|
||||
// (T0 chỉ assert 2 số — T1 phủ toàn bộ bộ chụp.)
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task LevelFinalize_SnapshotsAll11Columns_FromPairAndAccumulator()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T1");
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, PairProAdjust, PairProNote, PairCcmInitial, PairCcmAdjust, PairCcmNote);
|
||||
|
||||
// 2 peer cùng cặp (CreatedAt sớm hơn) → lũy kế xác định:
|
||||
// peer DaDuyet: budget 100tr (PrevSubmitted) + winner quote 90tr (PrevSelected).
|
||||
// peer ChoDuyet: budget 50tr (PrevSubmitted, KHÔNG PrevSelected).
|
||||
var baseT = new DateTime(2026, 7, 17, 8, 0, 0, DateTimeKind.Utc);
|
||||
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
|
||||
budgetPeriod: 100_000_000m, createdAt: baseT.AddDays(-3), winnerQuote: 90_000_000m, code: "PE-BF-T1-DD");
|
||||
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
|
||||
budgetPeriod: 50_000_000m, createdAt: baseT.AddDays(-2), winnerQuote: null, code: "PE-BF-T1-CD");
|
||||
|
||||
var approver = (await fix.CreateUserAsync("appr@t1.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var drafter = (await fix.CreateUserAsync("drafter@t1.test", "Drafter", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedFinalizeWorkflowAsync(db, approver);
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, drafter, "PE-BF-T1");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
pe.CreatedAt = baseT; // muộn hơn 2 peer → cả 2 lọt lũy kế
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
|
||||
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
// 6 cột pair (NAMING: Pro* = PRO; Ccm* = CCM).
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(PairProInitial, "chốt PRO ban hành từ pair");
|
||||
s.ApprovedBudgetProAdjustmentAmount.Should().Be(PairProAdjust, "chốt PRO hiệu chỉnh (ÂM) từ pair");
|
||||
s.ApprovedBudgetProNote.Should().Be(PairProNote, "chốt ghi chú PRO từ pair");
|
||||
s.ApprovedBudgetCcmInitialAmount.Should().Be(PairCcmInitial, "chốt CCM ban hành từ pair.InitialAmount");
|
||||
s.ApprovedBudgetCcmAdjustmentAmount.Should().Be(PairCcmAdjust, "chốt CCM hiệu chỉnh (ÂM) từ pair.AdjustmentAmount");
|
||||
s.ApprovedBudgetCcmNote.Should().Be(PairCcmNote, "chốt ghi chú CCM từ pair.CcmNote");
|
||||
// 4 cột lũy kế từ accumulator.
|
||||
s.ApprovedBudgetPrevSubmittedTotal.Should().Be(150_000_000m, "peer DaDuyet 100tr + ChoDuyet 50tr");
|
||||
s.ApprovedBudgetPrevSubmittedCount.Should().Be(2);
|
||||
s.ApprovedBudgetPrevSelectedTotal.Should().Be(90_000_000m, "chỉ peer DaDuyet có winner quote");
|
||||
s.ApprovedBudgetPrevSelectedCount.Should().Be(1);
|
||||
// Cờ đã-chốt.
|
||||
s.ApprovedBudgetSnapshotAt.Should().NotBeNull("SnapshotAt LUÔN set khi finalize = cờ đã chốt");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T2 — CCM-THRESHOLD delegation (:900): CCM tích uỷ-quyền + gói < ngưỡng CEO →
|
||||
// DaDuyet qua nhánh delegation → snapshot ngân sách set (pair cols + SnapshotAt).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task CcmThresholdFinalize_SetsBudgetSnapshot()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T2");
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, null, null, PairCcmInitial, null, null);
|
||||
|
||||
var ccm = (await fix.CreateUserAsync("ccm@t2.test", "CCM", null, new[] { AppRoles.CostControl })).Id;
|
||||
var wf = await SeedCcmThresholdWorkflowAsync(db, ccm, ceoThreshold: 1_000_000_000m);
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-BF-T2");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Gói 400tr < ngưỡng 1 tỷ (winnerQuoteTotal = SUM quote IsSelected của phiếu).
|
||||
await SeedWinnerQuoteAsync(db, pe, ValidApprovedPrice);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: ccm,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
finalizeByCcmDelegation: true,
|
||||
approvedPriceAmount: ValidApprovedPrice,
|
||||
approvedPriceSource: ValidApprovedSource,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "CCM uỷ-quyền + gói < ngưỡng → DaDuyet (nhánh delegation)");
|
||||
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
s.ApprovedBudgetSnapshotAt.Should().NotBeNull("nhánh CCM-threshold PHẢI chốt snapshot");
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(PairProInitial, "chốt PRO từ pair tại nhánh delegation");
|
||||
s.ApprovedBudgetCcmInitialAmount.Should().Be(PairCcmInitial, "chốt CCM từ pair tại nhánh delegation");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T3 — ALL-STEPS-DONE (:933): 1 Bước 1 Cấp KHÔNG finalize-flag → approve slot cuối →
|
||||
// terminal DaDuyet nhánh all-steps → snapshot set.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task AllStepsDoneFinalize_SetsBudgetSnapshot()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T3");
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, PairProAdjust, PairProNote, PairCcmInitial, PairCcmAdjust, PairCcmNote);
|
||||
|
||||
var approver = (await fix.CreateUserAsync("appr@t3.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedPlainWorkflowAsync(db, approver); // AllowApproverFinalize=false
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-BF-T3");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "slot cuối, hết bước → terminal DaDuyet (all-steps)");
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
s.ApprovedBudgetSnapshotAt.Should().NotBeNull("nhánh all-steps-done PHẢI chốt snapshot");
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(PairProInitial);
|
||||
s.ApprovedBudgetCcmInitialAmount.Should().Be(PairCcmInitial);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T4 — V1 LEGACY (:1108): phiếu pin WorkflowDefinitionId (V1 flat) đi hết steps →
|
||||
// nhánh V1 → snapshot set NHƯNG ApprovedPriceAmount KHÔNG set (V1 không có luồng giá-chốt).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task V1LegacyFinalize_SetsBudgetSnapshot_WithoutApprovedPrice()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T4");
|
||||
await SeedPairAsync(db, project.Id, wi.Id,
|
||||
PairProInitial, PairProAdjust, PairProNote, PairCcmInitial, PairCcmAdjust, PairCcmNote);
|
||||
|
||||
var approver = (await fix.CreateUserAsync("appr@t4.test", "Approver V1", null, Array.Empty<string>())).Id;
|
||||
|
||||
// V1 workflow definition: 1 step, approver = explicit User (match qua userManager).
|
||||
var def = new PurchaseEvaluationWorkflowDefinition
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "PE-V1-BF",
|
||||
Version = 1,
|
||||
EvaluationType = PurchaseEvaluationType.DuyetNcc,
|
||||
Name = "V1 legacy budget-freeze",
|
||||
IsActive = true,
|
||||
};
|
||||
var step = new PurchaseEvaluationWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationWorkflowDefinitionId = def.Id,
|
||||
Order = 1,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
Name = "Bước 1 (V1)",
|
||||
};
|
||||
step.Approvers.Add(new PurchaseEvaluationWorkflowStepApprover
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationWorkflowStepId = step.Id,
|
||||
Kind = WorkflowApproverKind.User,
|
||||
AssignmentValue = approver.ToString(),
|
||||
});
|
||||
def.Steps.Add(step);
|
||||
db.PurchaseEvaluationWorkflowDefinitions.Add(def);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// PE pin V1 (WorkflowDefinitionId) — ApprovalWorkflowId=null → route ApproveV1LegacyAsync.
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = "PE-BF-T4",
|
||||
TenGoiThau = "Gói thầu V1 legacy",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
WorkflowDefinitionId = def.Id,
|
||||
ApprovalWorkflowId = null,
|
||||
CurrentWorkflowStepIndex = 0,
|
||||
SlaDeadline = new DateTime(2026, 7, 24, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// V1 KHÔNG có luồng giá-chốt → KHÔNG truyền approvedPrice.
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: approver,
|
||||
actorRoles: Array.Empty<string>(),
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "V1 legacy hết step → DaDuyet (:1108)");
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
s.ApprovedBudgetSnapshotAt.Should().NotBeNull("V1 legacy PHẢI chốt snapshot (nhánh :1108)");
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(PairProInitial, "V1 vẫn chốt số ngân sách từ pair");
|
||||
s.ApprovedBudgetCcmInitialAmount.Should().Be(PairCcmInitial);
|
||||
s.ApprovedPriceAmount.Should().BeNull("V1 legacy KHÔNG có luồng giá-chốt → ApprovedPriceAmount null");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T5 — GUARD SetPeCcmBudgetPeriod: phiếu DaDuyet → CCM VÀ Admin đều ConflictException
|
||||
// (guard đặt TRƯỚC role-gate → chặn cả 2). Phiếu ChoDuyet → CCM vẫn set được (guard không lạm).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task SetCcmBudgetPeriodGuard_BlocksDaDuyet_ForCcmAndAdmin_AllowsChoDuyet()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// Phiếu DaDuyet THẬT qua finalize path.
|
||||
var (peDone, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T5done");
|
||||
|
||||
var ccm = new TestCurrentUser(Guid.NewGuid(), "CCM", null, AppRoles.CostControl);
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
|
||||
var byCcm = async () => await new SetPeCcmBudgetPeriodCommandHandler(db, ccm)
|
||||
.Handle(new SetPeCcmBudgetPeriodCommand(peDone.Id, 123_000_000m), CancellationToken.None);
|
||||
await byCcm.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã kết thúc*");
|
||||
|
||||
var byAdmin = async () => await new SetPeCcmBudgetPeriodCommandHandler(db, admin)
|
||||
.Handle(new SetPeCcmBudgetPeriodCommand(peDone.Id, 456_000_000m), CancellationToken.None);
|
||||
await byAdmin.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã kết thúc*", "guard chặn CẢ Admin (đặt TRƯỚC role-gate)");
|
||||
|
||||
// Phiếu ChoDuyet (chưa duyệt) → CCM set BÌNH THƯỜNG (guard không lạm sang phiếu chưa kết thúc).
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T5open");
|
||||
var peOpen = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = "PE-BF-T5-OPEN",
|
||||
TenGoiThau = "Gói thầu chưa duyệt",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(peOpen);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await new SetPeCcmBudgetPeriodCommandHandler(db, ccm)
|
||||
.Handle(new SetPeCcmBudgetPeriodCommand(peOpen.Id, 77_000_000m), CancellationToken.None);
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == peOpen.Id);
|
||||
reloaded.CcmBudgetPeriodAmount.Should().Be(77_000_000m, "phiếu ChoDuyet → CCM vẫn nhập NS kỳ này được");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T6 — GUARD AdjustBudget: phiếu DaDuyet (qua finalize) + Admin → Conflict; phiếu
|
||||
// TuChoi (seed thẳng — SPEC cho phép vì guard chỉ đọc Phase) + Admin → Conflict.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task AdjustBudgetGuard_BlocksDaDuyetAndTuChoi_ForAdmin()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
|
||||
// (a) DaDuyet THẬT qua finalize → Admin adjust → Conflict.
|
||||
var (peDone, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T6done");
|
||||
var adjDone = async () => await new AdjustPurchaseEvaluationBudgetCommandHandler(db, admin)
|
||||
.Handle(new AdjustPurchaseEvaluationBudgetCommand(peDone.Id, 10_000_000m, null), CancellationToken.None);
|
||||
await adjDone.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã kết thúc*", "phiếu DaDuyet → chặn CẢ Admin (guard TRƯỚC isAdmin block)");
|
||||
|
||||
// (b) TuChoi seed thẳng (không reachable qua service — S60 gỡ Từ chối). Guard chỉ đọc Phase.
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T6rej");
|
||||
var peReject = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.TuChoi,
|
||||
MaPhieu = "PE-BF-T6-REJ",
|
||||
TenGoiThau = "Gói thầu bị từ chối",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
};
|
||||
db.PurchaseEvaluations.Add(peReject);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var adjReject = async () => await new AdjustPurchaseEvaluationBudgetCommandHandler(db, admin)
|
||||
.Handle(new AdjustPurchaseEvaluationBudgetCommand(peReject.Id, 5_000_000m, null), CancellationToken.None);
|
||||
await adjReject.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*đã kết thúc*", "phiếu TuChoi cũng = kết thúc → ngân sách bất biến");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T7 — DISPLAY: nhánh else-live (phiếu ChoDuyet) → số MỚI theo pair + BudgetFrozen=false
|
||||
// + CanEdit theo role; nhánh frozen (phiếu DaDuyet) → CanEditPro=CanEditCcm=false (kể cả
|
||||
// Admin) + BudgetFrozen=true.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task BudgetSummary_LiveVsFrozen_FlagsAndEditability()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
// ---- Nhánh LIVE: phiếu ChoDuyet + pair, querier = Procurement + đồng thời drafter ----
|
||||
var (projectL, wiL) = await SeedProjectAndWorkItemAsync(db, "T7live");
|
||||
await SeedPairAsync(db, projectL.Id, wiL.Id, null, null, null, PairCcmInitial, null, null);
|
||||
var proUser = await fix.CreateUserAsync("pro@t7.test", "PRO", null, new[] { AppRoles.Procurement });
|
||||
var peLive = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = "PE-BF-T7-LIVE",
|
||||
TenGoiThau = "Gói thầu live",
|
||||
ProjectId = projectL.Id,
|
||||
WorkItemId = wiL.Id,
|
||||
DrafterUserId = proUser.Id, // drafter → authz pass
|
||||
};
|
||||
db.PurchaseEvaluations.Add(peLive);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var proQuerier = new TestCurrentUser(proUser.Id, "PRO", null, AppRoles.Procurement);
|
||||
var liveBundle = await new GetPurchaseEvaluationQueryHandler(db, um, proQuerier)
|
||||
.Handle(new GetPurchaseEvaluationQuery(peLive.Id), CancellationToken.None);
|
||||
var live = liveBundle.BudgetSummary!;
|
||||
live.BudgetFrozen.Should().BeFalse("phiếu ChoDuyet → nhánh live");
|
||||
live.InitialAmount.Should().Be(PairCcmInitial, "nhánh live đọc số MỚI từ record pair");
|
||||
live.CanEditPro.Should().BeTrue("querier role Procurement → được sửa PRO khi chưa duyệt");
|
||||
live.CanEditCcm.Should().BeFalse("querier KHÔNG có role CostControl → không sửa CCM");
|
||||
|
||||
// ---- Nhánh FROZEN: phiếu DaDuyet (finalize) + querier Admin → vẫn KHÓA sửa ----
|
||||
var (peDone, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T7frozen");
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
var frozenBundle = await new GetPurchaseEvaluationQueryHandler(db, um, admin)
|
||||
.Handle(new GetPurchaseEvaluationQuery(peDone.Id), CancellationToken.None);
|
||||
var frozen = frozenBundle.BudgetSummary!;
|
||||
frozen.BudgetFrozen.Should().BeTrue("phiếu DaDuyet + SnapshotAt != null → nhánh frozen");
|
||||
frozen.CanEditPro.Should().BeFalse("phiếu đã chốt → KHÔNG cho sửa PRO, kể cả Admin");
|
||||
frozen.CanEditCcm.Should().BeFalse("phiếu đã chốt → KHÔNG cho sửa CCM, kể cả Admin");
|
||||
frozen.InitialAmount.Should().Be(PairCcmInitial, "nhánh frozen serve CCM từ cột snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T8 — NULL-SAFE: (a) phiếu WorkItemId null finalize → không NRE, bundle.BudgetSummary=null
|
||||
// NHƯNG SnapshotAt VẪN set trên entity; (b) phiếu có WorkItemId nhưng KHÔNG có pair record →
|
||||
// finalize OK, frozen bs non-null với 6 cột pair null.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task Finalize_NullSafe_NoWorkItemAndNoPairRecord()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
|
||||
|
||||
// (a) WorkItemId null → helper set SnapshotAt nhưng bundle.BudgetSummary null.
|
||||
var (peNoWi, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T8nowi", withWorkItem: false, seedPair: false);
|
||||
var sNoWi = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == peNoWi.Id);
|
||||
sNoWi.ApprovedBudgetSnapshotAt.Should().NotBeNull("WorkItemId null vẫn set SnapshotAt (không NRE)");
|
||||
sNoWi.ApprovedBudgetProInitialAmount.Should().BeNull("WorkItemId null → không cặp → 10 cột null");
|
||||
var noWiBundle = await new GetPurchaseEvaluationQueryHandler(db, um, admin)
|
||||
.Handle(new GetPurchaseEvaluationQuery(peNoWi.Id), CancellationToken.None);
|
||||
noWiBundle.BudgetSummary.Should().BeNull("phiếu không gắn Hạng mục → BudgetSummary null");
|
||||
|
||||
// (b) WorkItemId set nhưng KHÔNG seed pair → finalize OK, frozen bs non-null, 6 pair cols null.
|
||||
var (peNoPair, _, _, _) = await SeedAndFinalizeAsync(svc, fix, db, "T8nopair", withWorkItem: true, seedPair: false);
|
||||
var sNoPair = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == peNoPair.Id);
|
||||
sNoPair.ApprovedBudgetSnapshotAt.Should().NotBeNull("pair thiếu vẫn set SnapshotAt");
|
||||
sNoPair.ApprovedBudgetCcmInitialAmount.Should().BeNull("pair thiếu → 6 cột pair null");
|
||||
var noPairBundle = await new GetPurchaseEvaluationQueryHandler(db, um, admin)
|
||||
.Handle(new GetPurchaseEvaluationQuery(peNoPair.Id), CancellationToken.None);
|
||||
var bs = noPairBundle.BudgetSummary;
|
||||
bs.Should().NotBeNull("có WorkItemId → frozen bs vẫn build (dù pair rỗng)");
|
||||
bs!.BudgetFrozen.Should().BeTrue();
|
||||
bs.ProInitialAmount.Should().BeNull("pair thiếu → FE hiển thị '—' (cột PRO null)");
|
||||
bs.InitialAmount.Should().BeNull("pair thiếu → cột CCM null");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// T9 — RE-FINALIZE OVERWRITE: phiếu ĐÃ có cột snapshot CŨ (seed thẳng — SPEC cho phép để
|
||||
// test semantics 'LUÔN overwrite') đi qua finalize → cột = giá trị pair HIỆN TẠI (mới),
|
||||
// SnapshotAt refresh (không giữ giá trị cũ).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ReFinalize_AlwaysOverwritesStaleSnapshot_RefreshesSnapshotAt()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "T9");
|
||||
// Pair MỚI: ProInitial 500tr.
|
||||
await SeedPairAsync(db, project.Id, wi.Id, 500_000_000m, null, null, 600_000_000m, null, null);
|
||||
|
||||
var approver = (await fix.CreateUserAsync("appr@t9.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedFinalizeWorkflowAsync(db, approver);
|
||||
|
||||
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-BF-T9");
|
||||
// Seed thẳng cột snapshot CŨ (stale) + SnapshotAt cũ để chứng minh helper LUÔN overwrite.
|
||||
var staleAt = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
pe.ApprovedBudgetProInitialAmount = 111_000_000m; // giá trị cũ SAI
|
||||
pe.ApprovedBudgetCcmInitialAmount = 222_000_000m;
|
||||
pe.ApprovedBudgetSnapshotAt = staleAt;
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
|
||||
|
||||
// Tracked entity: helper mutate in-place → overwrite bằng pair HIỆN TẠI.
|
||||
pe.ApprovedBudgetProInitialAmount.Should().Be(500_000_000m, "LUÔN overwrite: cột = pair mới (500tr), KHÔNG giữ 111tr cũ");
|
||||
pe.ApprovedBudgetCcmInitialAmount.Should().Be(600_000_000m, "LUÔN overwrite CCM: 600tr mới, KHÔNG giữ 222tr cũ");
|
||||
pe.ApprovedBudgetSnapshotAt.Should().Be(ClockNow, "SnapshotAt refresh về thời điểm duyệt (clock), KHÔNG giữ 2020");
|
||||
pe.ApprovedBudgetSnapshotAt.Should().NotBe(staleAt);
|
||||
|
||||
// Persisted.
|
||||
var s = await db.PurchaseEvaluations.AsNoTracking().SingleAsync(x => x.Id == pe.Id);
|
||||
s.ApprovedBudgetProInitialAmount.Should().Be(500_000_000m, "overwrite persist xuống DB");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// S133 site-5 (reviewer PASS_WITH_FIXES catch — #81 indirect writer): Admin manual-override
|
||||
// chuyển THẲNG → DaDuyet qua nhánh catch-all (`evaluation.Phase = targetPhase` — assignment
|
||||
// qua BIẾN, không phải token enum → literal-grep 4-site không thấy) cũng PHẢI chốt snapshot.
|
||||
// Thiếu → phiếu DaDuyet SnapshotAt=null → display-gate false → serve LIVE (mất bất biến).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task AdminOverride_DirectToDaDuyet_SetsBudgetSnapshot()
|
||||
{
|
||||
var (svc, fix, db) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "AO");
|
||||
await SeedPairAsync(db, project.Id, wi.Id, ProAtApproval, null, null, CcmAtApproval, null, null);
|
||||
|
||||
var admin = (await fix.CreateUserAsync("admin@ao.test", "Admin AO", null, new[] { AppRoles.Admin })).Id;
|
||||
|
||||
// Phiếu ở DangSoanThao — KHÔNG rơi approve-step (fromPhase != ChoDuyet) → catch-all
|
||||
// admin-override khi actor là Admin.
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||
MaPhieu = "PE-BF-AO",
|
||||
TenGoiThau = "Gói thầu admin-override",
|
||||
ProjectId = project.Id,
|
||||
WorkItemId = wi.Id,
|
||||
DrafterUserId = admin,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.DaDuyet,
|
||||
actorUserId: admin,
|
||||
actorRoles: new[] { AppRoles.Admin },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "admin override thẳng DaDuyet",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "admin catch-all override phải ăn");
|
||||
pe.ApprovedBudgetSnapshotAt.Should().Be(ClockNow,
|
||||
"site-5: override thẳng → DaDuyet cũng phải chốt snapshot (RULE helper — reviewer S133)");
|
||||
pe.ApprovedBudgetProInitialAmount.Should().Be(ProAtApproval, "snapshot PRO từ pair lúc override");
|
||||
pe.ApprovedBudgetCcmInitialAmount.Should().Be(CcmAtApproval, "snapshot CCM từ pair lúc override");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user