[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

- 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:
pqhuy1987
2026-07-17 11:14:25 +07:00
parent 38b6d196d6
commit d4fbced02f
16 changed files with 7829 additions and 61 deletions

View File

@ -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");
}
}
}