[CLAUDE] PurchaseEvaluation: luy ke TAM TINH vs CHINH XAC — ComputePendingAsync + 5-field DTO + FE 2-app sub-dong amber/banner man duyet + D4 audit-note (UAT anh Kiet 15-07, 0 mig, test 520→528)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m27s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-17 14:27:19 +07:00
parent e53a69307f
commit 6664a6136a
22 changed files with 1324 additions and 56 deletions

View File

@ -0,0 +1,497 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.PurchaseEvaluations; // PeBudgetAccumulator, ComputeAsync/ComputePendingAsync, GetPurchaseEvaluationQuery
using SolutionErp.Application.PurchaseEvaluations.Dtos; // PePendingPriorPeDto
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ApprovalDecision, ChangelogAction
using SolutionErp.Domain.Identity; // AppRoles
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;
// ===== S134 (2026-07-17) TEST-C — PE lũy kế TẠM TÍNH (PeBudgetAccumulator.ComputePendingAsync) =====
// Code BE ĐÃ LAND (INV-2): lũy kế "phiếu TRƯỚC cùng cặp (ProjectId, WorkItemId) CHƯA duyệt xong"
// BỔ SUNG (KHÔNG thay) số CHÍNH XÁC ComputeAsync (S133). 3 tầng cần phủ:
// (1) PeBudgetAccumulator.ComputePendingAsync — window Y HỆT ComputeAsync (peers cùng cặp,
// Id != peId, CreatedAt < peCreatedAt). 3 nhóm số:
// PendingSubmitted = Phase ∉ {DangSoanThao=1, DaDuyet=7, ChoDuyet=10, TuChoi=99} (= TraLai=98
// + legacy 2-6) → Count + SUM(BudgetPeriodAmount ?? 0). ChoDuyet/DaDuyet
// ĐÃ nằm trong ComputeAsync PrevSubmitted → loại tránh double-count.
// PendingSelected = Phase ∉ {DangSoanThao, DaDuyet, TuChoi} && Suppliers.Any(IsWinner)
// (GỒM ChoDuyet-có-winner = gap chính đề bài) → Count + SUM(ThanhTien IsSelected).
// PriorPes = Phase ∉ {DaDuyet, TuChoi} orderby CreatedAt → PePendingPriorPeDto(Id, MaPhieu, (int)Phase).
// (2) GetPurchaseEvaluationQuery: nhánh live fill 5 field / nhánh frozen (DaDuyet + SnapshotAt) defaults 0/null.
// (3) D4 ApplyBudgetSnapshotOnFinalizeAsync: finalize khi còn phiếu trước chưa duyệt → +1 Changelog audit.
//
// Test theo CODE (S34 single source of truth). TEST-AFTER (code đã land, build 0/0). KHÔNG touch
// production (src/Backend, fe-*). Mirror seed-helper PeBudgetFreezeTests :277-336 (CreatedAt override
// thủ công — TestApplicationDbContext KHÔNG wire audit interceptor).
public class PePendingAccumulationTests
{
// Giá chốt hợp lệ khi finalize (ApplyApprovedPriceOnFinalize đòi source ∈ ValidApprovedPriceSources).
private const decimal ValidApprovedPrice = 400_000_000m;
private const string ValidApprovedSource = "Ncc";
// Mốc thời gian: peers seed TRƯỚC (BaseT.AddDays(-n)); "phiếu đang xét" = CurCreatedAt (muộn hơn).
private static readonly DateTime BaseT = new(2026, 7, 17, 8, 0, 0, DateTimeKind.Utc);
private static readonly DateTime CurCreatedAt = new(2026, 7, 17, 12, 0, 0, DateTimeKind.Utc);
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);
}
// ---- shared seed helpers (mirror PeBudgetFreezeTests) ----
private static async Task<(Project project, WorkItem wi)> SeedProjectAndWorkItemAsync(
TestApplicationDbContext db, string suffix)
{
var project = new Project { Id = Guid.NewGuid(), Code = "PRJ-PA-" + suffix, Name = "Dự án " + suffix };
var wi = new WorkItem { Id = Guid.NewGuid(), Code = "WI-PA-" + 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? ccmInit)
{
db.PeWorkItemBudgets.Add(new PeWorkItemBudget
{
Id = Guid.NewGuid(),
ProjectId = projectId,
WorkItemId = wiId,
InitialAmount = ccmInit,
});
await db.SaveChangesAsync(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.
private static async Task<PurchaseEvaluation> 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 < peCreatedAt
await db.SaveChangesAsync(CancellationToken.None);
if (winnerQuote is decimal wq)
await SeedWinnerQuoteAsync(db, peer, wq);
return peer;
}
// Workflow V2 1 Bước 1 Cấp AllowApproverFinalize=true → duyệt slot cuối = DaDuyet (finalize-branch).
private static async Task<ApprovalWorkflow> SeedFinalizeWorkflowAsync(
TestApplicationDbContext db, Guid approverUserId, string code)
{
var wf = new ApprovalWorkflow
{
Code = code,
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
Name = "QT test pending-accumulation",
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,
});
wf.Steps.Add(step);
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
// 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ệ → DaDuyet qua finalize path.
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);
// Direct call convenience — curPeId synthetic (KHÔNG cần row phiếu đang xét; accumulator chỉ
// filter p.Id != peId && p.CreatedAt < peCreatedAt). Window = CurCreatedAt (muộn hơn mọi peer sớm).
private static Task<PePendingAccumulation> ComputePendingAsync(
TestApplicationDbContext db, Guid projectId, Guid wiId) =>
PeBudgetAccumulator.ComputePendingAsync(db, projectId, wiId, Guid.NewGuid(), CurCreatedAt, CancellationToken.None);
// =====================================================================
// C1 — peer ChoDuyet + IsWinner + quote IsSelected → PendingSelected (gap chính đề bài):
// Total = SUM ThanhTien, Count = 1; peer CÓ MẶT trong PriorPes (đúng Id + MaPhieu + Phase=10).
// PendingSubmitted = 0 (ChoDuyet CỐ Ý loại — đã nằm ComputeAsync PrevSubmitted, tránh double-count).
// =====================================================================
[Fact]
public async Task ChoDuyetWinner_CountsInPendingSelected_AndListedInPriorPes()
{
var (_, fix, db) = CreateService();
using (fix)
{
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C1");
var peer = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriod: 30_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: 70_000_000m, code: "PE-C1-CD");
var r = await ComputePendingAsync(db, project.Id, wi.Id);
r.PendingSelectedTotal.Should().Be(70_000_000m, "peer ChoDuyet + winner + quote IsSelected → tạm tính đã chọn");
r.PendingSelectedCount.Should().Be(1);
r.PendingSubmittedTotal.Should().Be(0m, "ChoDuyet loại khỏi PendingSubmitted (đã nằm ComputeAsync PrevSubmitted)");
r.PendingSubmittedCount.Should().Be(0);
r.PriorPes.Should().ContainSingle("ChoDuyet ∉ {DaDuyet, TuChoi} → có trong danh sách cần lưu ý");
var prior = r.PriorPes.Single();
prior.Id.Should().Be(peer.Id);
prior.MaPhieu.Should().Be("PE-C1-CD");
prior.Phase.Should().Be(10, "Phase = (int)ChoDuyet");
}
}
// =====================================================================
// C2 — peer TuChoi → mọi delta 0 + KHÔNG trong PriorPes; peer DaDuyet → delta 0 (nằm bên
// ComputeAsync chính xác, KHÔNG lũy kế tạm tính) + KHÔNG trong PriorPes.
// =====================================================================
[Fact]
public async Task TuChoiAndDaDuyetPeers_ContributeNothingToPending_AndAbsentFromPriorPes()
{
var (_, fix, db) = CreateService();
using (fix)
{
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C2");
// Cả 2 peer đều có winner + budget để CHỨNG MINH exclusion theo Phase (không phải thiếu data).
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.TuChoi,
budgetPeriod: 50_000_000m, createdAt: BaseT.AddDays(-3), winnerQuote: 30_000_000m, code: "PE-C2-REJ");
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
budgetPeriod: 100_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: 90_000_000m, code: "PE-C2-DD");
var r = await ComputePendingAsync(db, project.Id, wi.Id);
r.PendingSubmittedTotal.Should().Be(0m, "TuChoi + DaDuyet đều bị loại khỏi PendingSubmitted");
r.PendingSubmittedCount.Should().Be(0);
r.PendingSelectedTotal.Should().Be(0m, "TuChoi + DaDuyet đều bị loại khỏi PendingSelected dù có winner");
r.PendingSelectedCount.Should().Be(0);
r.PriorPes.Should().BeEmpty("cả 2 phase = terminal → không nằm danh sách cần lưu ý");
}
}
// =====================================================================
// C3 — peer DangSoanThao (Nháp) → TRONG PriorPes NHƯNG delta tiền = 0 (D1 list-only); peer TraLai
// có BudgetPeriodAmount → PendingSubmittedTotal đúng (D2). Chứng minh: budget 40tr của Nháp
// KHÔNG cộng, budget 60tr của TraLai CÓ cộng.
// =====================================================================
[Fact]
public async Task DraftPeer_ListOnly_TraLaiPeer_CountsInPendingSubmitted()
{
var (_, fix, db) = CreateService();
using (fix)
{
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C3");
var draft = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DangSoanThao,
budgetPeriod: 40_000_000m, createdAt: BaseT.AddDays(-3), winnerQuote: null, code: "PE-C3-NHAP");
var traLai = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.TraLai,
budgetPeriod: 60_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: null, code: "PE-C3-TRALAI");
var r = await ComputePendingAsync(db, project.Id, wi.Id);
r.PendingSubmittedTotal.Should().Be(60_000_000m, "chỉ TraLai cộng (D2); Nháp 40tr là list-only (D1)");
r.PendingSubmittedCount.Should().Be(1, "chỉ TraLai; DangSoanThao loại khỏi PendingSubmitted");
r.PendingSelectedTotal.Should().Be(0m, "không peer nào có winner");
r.PendingSelectedCount.Should().Be(0);
r.PriorPes.Should().HaveCount(2, "Nháp + TraLai đều ∉ {DaDuyet, TuChoi}");
r.PriorPes.Select(p => p.Id).Should().ContainInOrder(new[] { draft.Id, traLai.Id }, "orderby CreatedAt");
r.PriorPes.Should().Contain(p => p.Id == draft.Id && p.Phase == 1,
"peer Nháp CÓ trong danh sách (list-only) dù đóng góp tiền = 0");
}
}
// =====================================================================
// C4 — peer-window: peer CreatedAt > phiếu đang xét → loại HOÀN TOÀN (mirror PeBudgetFreezeTests
// :435-451). Contrast: peer TRƯỚC vẫn lọt để chứng minh filter phân biệt đúng chiều.
// =====================================================================
[Fact]
public async Task PeerCreatedAfterCurrent_ExcludedFromWindow()
{
var (_, fix, db) = CreateService();
using (fix)
{
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C4");
// (a) peer TRƯỚC CurCreatedAt (noon) → LỌT.
var before = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriod: 10_000_000m, createdAt: BaseT.AddDays(-1), winnerQuote: 25_000_000m, code: "PE-C4-BEFORE");
// (b) peer SAU CurCreatedAt → LOẠI hoàn toàn.
var after = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriod: 999_000_000m, createdAt: CurCreatedAt.AddHours(1), winnerQuote: 999_000_000m, code: "PE-C4-AFTER");
var r = await ComputePendingAsync(db, project.Id, wi.Id);
r.PendingSelectedTotal.Should().Be(25_000_000m, "chỉ peer TRƯỚC lọt; peer SAU (999tr) bị loại khỏi window");
r.PendingSelectedCount.Should().Be(1);
r.PriorPes.Should().ContainSingle().Which.Id.Should().Be(before.Id);
r.PriorPes.Should().NotContain(p => p.Id == after.Id, "peer CreatedAt > current → ngoài window");
}
}
// =====================================================================
// C5 — quote ÂM trong PendingSelected (signed-sum): peer ChoDuyet + winner + quote 500k →
// PendingSelectedTotal = 500k (báo giá âm = phát-sinh-giảm/hoàn NCC, KHÔNG clamp).
// =====================================================================
[Fact]
public async Task NegativeSelectedQuote_ProducesSignedPendingSelectedTotal()
{
var (_, fix, db) = CreateService();
using (fix)
{
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C5");
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriod: null, createdAt: BaseT.AddDays(-1), winnerQuote: -500_000m, code: "PE-C5-NEG");
var r = await ComputePendingAsync(db, project.Id, wi.Id);
r.PendingSelectedTotal.Should().Be(-500_000m, "signed-sum: quote âm giữ dấu, KHÔNG clamp về 0");
r.PendingSelectedCount.Should().Be(1);
}
}
// =====================================================================
// C6 REGRESSION chính-xác bất động: cùng seed {DaDuyet+winner, ChoDuyet+winner}, gọi CẢ
// ComputeAsync (S133 số CHÍNH XÁC) và ComputePendingAsync (S134 tạm tính) — chứng minh 2 hàm
// BỔ SUNG nhau, KHÔNG chồng lấn:
// ComputeAsync.PrevSelected = CHỈ DaDuyet+winner (thêm peer ChoDuyet KHÔNG đổi).
// ComputeAsync.PrevSubmitted = ChoDuyet + DaDuyet (như cũ).
// ComputePendingAsync.PendingSelected = CHỈ ChoDuyet+winner (DaDuyet loại).
// ComputePendingAsync.PendingSubmitted = 0 (ChoDuyet + DaDuyet đều loại khỏi tạm tính).
// =====================================================================
[Fact]
public async Task Compute_And_ComputePending_AreComplementary_NoDoubleCount()
{
var (_, fix, db) = CreateService();
using (fix)
{
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C6");
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
budgetPeriod: 100_000_000m, createdAt: BaseT.AddDays(-3), winnerQuote: 90_000_000m, code: "PE-C6-DD");
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriod: 30_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: 50_000_000m, code: "PE-C6-CD");
var curPeId = Guid.NewGuid();
var exact = await PeBudgetAccumulator.ComputeAsync(
db, project.Id, wi.Id, curPeId, CurCreatedAt, Array.Empty<Guid>(), CancellationToken.None);
var pending = await PeBudgetAccumulator.ComputePendingAsync(
db, project.Id, wi.Id, curPeId, CurCreatedAt, CancellationToken.None);
// ComputeAsync (S133) — số CHÍNH XÁC KHÔNG bị peer ChoBudget kéo lệch.
exact.PrevSelectedTotal.Should().Be(90_000_000m, "PrevSelected CHỈ đếm DaDuyet+winner");
exact.PrevSelectedCount.Should().Be(1, "peer ChoDuyet KHÔNG cộng vào PrevSelected (bất động)");
exact.PrevSubmittedTotal.Should().Be(130_000_000m, "PrevSubmitted = ChoDuyet 30tr + DaDuyet 100tr (như cũ)");
exact.PrevSubmittedCount.Should().Be(2);
// ComputePendingAsync (S134) — bổ sung ChoDuyet winner, KHÔNG chồng DaDuyet.
pending.PendingSelectedTotal.Should().Be(50_000_000m, "PendingSelected CHỈ ChoDuyet+winner (DaDuyet loại)");
pending.PendingSelectedCount.Should().Be(1);
pending.PendingSubmittedTotal.Should().Be(0m, "ChoDuyet + DaDuyet đều loại khỏi PendingSubmitted (no double-count)");
pending.PendingSubmittedCount.Should().Be(0);
pending.PriorPes.Should().ContainSingle("chỉ ChoDuyet ∉ {DaDuyet, TuChoi}");
}
}
// =====================================================================
// C7 — DISPLAY frozen: phiếu DaDuyet (finalize) + SnapshotAt qua GetPurchaseEvaluationQuery →
// DTO pending 4 số = 0 + PendingPriorPes null, DÙ tồn tại peer ChoDuyet cùng cặp (nhánh frozen
// hardcode defaults, KHÔNG chạy accumulator). Mirror T7 PeBudgetFreezeTests :730.
// =====================================================================
[Fact]
public async Task FrozenQuery_ReturnsZeroPendingAndNullPriorPes_EvenWithPendingPeer()
{
var (svc, fix, db) = CreateService();
using (fix)
{
var um = fix.Services.GetRequiredService<UserManager<User>>();
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C7");
await SeedPairAsync(db, project.Id, wi.Id, 200_000_000m);
// Peer ChoDuyet + winner cùng cặp, TRƯỚC phiếu B → nếu B ở nhánh LIVE sẽ có pending != 0.
await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriod: 50_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: 80_000_000m, code: "PE-C7-PEER");
var approver = (await fix.CreateUserAsync("appr@c7.test", "Approver", null, Array.Empty<string>())).Id;
var wf = await SeedFinalizeWorkflowAsync(db, approver, "QT-PA-C7");
var pe = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-C7-B");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
pe.CreatedAt = BaseT; // muộn hơn peer → peer lọt window (nếu live)
await db.SaveChangesAsync(CancellationToken.None);
await ApproveWithPriceAsync(svc, pe, approver, Array.Empty<string>());
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
var admin = new TestCurrentUser(Guid.NewGuid(), "Admin", null, AppRoles.Admin);
var bundle = await new GetPurchaseEvaluationQueryHandler(db, um, admin)
.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.BudgetFrozen.Should().BeTrue("phiếu DaDuyet + SnapshotAt → nhánh frozen (test không vacuous)");
s.PendingSubmittedTotal.Should().Be(0m, "nhánh frozen hardcode 0 dù tồn tại peer ChoDuyet");
s.PendingSubmittedCount.Should().Be(0);
s.PendingSelectedTotal.Should().Be(0m, "nhánh frozen KHÔNG chạy accumulator tạm tính");
s.PendingSelectedCount.Should().Be(0);
s.PendingPriorPes.Should().BeNull("nhánh frozen serve PendingPriorPes null");
}
}
// =====================================================================
// C8 — D4 audit: finalize phiếu B tới DaDuyet QUA TransitionAsync (finalize path) khi TỒN TẠI
// peer A ChoDuyet cùng cặp → +1 Changelog Action=Update, EntityType=Workflow, UserId=null,
// UserName="Hệ thống", ContextNote chứa "[S134 pending-prior]" + MaPhieu A (filter discriminator
// ContextNote.Contains — #48 tie-break tránh row Transition). CONTRAST: finalize phiếu KHÔNG có
// pending peer → 0 row loại này.
// =====================================================================
[Fact]
public async Task Finalize_WithPendingPriorPeer_WritesAuditChangelog_ContrastNoPeerWritesNone()
{
var (svc, fix, db) = CreateService();
using (fix)
{
// ---- Case chính: có peer A ChoDuyet trước phiếu B ----
var (project, wi) = await SeedProjectAndWorkItemAsync(db, "C8");
var peerA = await SeedPeerAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.ChoDuyet,
budgetPeriod: 50_000_000m, createdAt: BaseT.AddDays(-2), winnerQuote: null, code: "PE-C8-A");
var approver = (await fix.CreateUserAsync("appr@c8.test", "Approver", null, Array.Empty<string>())).Id;
var wf = await SeedFinalizeWorkflowAsync(db, approver, "QT-PA-C8");
var peB = BuildPeAtFinalizeSlot(wf.Id, project.Id, wi.Id, Guid.NewGuid(), "PE-C8-B");
db.PurchaseEvaluations.Add(peB);
await db.SaveChangesAsync(CancellationToken.None);
peB.CreatedAt = BaseT; // muộn hơn peer A → A lọt window pending
await db.SaveChangesAsync(CancellationToken.None);
await ApproveWithPriceAsync(svc, peB, approver, Array.Empty<string>());
peB.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
// Filter discriminator ContextNote.Contains (#48 tie-break) → cô lập row D4 khỏi row Transition.
var d4 = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.Where(c => c.PurchaseEvaluationId == peB.Id
&& c.ContextNote != null && c.ContextNote.Contains("[S134 pending-prior]"))
.ToListAsync(CancellationToken.None);
d4.Should().ContainSingle("finalize khi còn 1 phiếu trước chưa duyệt → đúng 1 row audit D4");
var row = d4.Single();
row.Action.Should().Be(ChangelogAction.Update, "D4 = Update, KHÔNG Transition (gotcha #49 tránh 2 row chuyển phase)");
row.EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
row.PhaseAtChange.Should().Be(PurchaseEvaluationPhase.DaDuyet);
row.UserId.Should().BeNull("D4 do hệ thống ghi");
row.UserName.Should().Be("Hệ thống");
row.ContextNote.Should().Contain(peerA.MaPhieu!, "audit liệt kê mã phiếu trước còn treo (peer A)");
// ---- CONTRAST: phiếu C cặp MỚI không peer → 0 row D4 ----
var (project2, wi2) = await SeedProjectAndWorkItemAsync(db, "C8none");
var approver2 = (await fix.CreateUserAsync("appr2@c8.test", "Approver2", null, Array.Empty<string>())).Id;
var wf2 = await SeedFinalizeWorkflowAsync(db, approver2, "QT-PA-C8b");
var peC = BuildPeAtFinalizeSlot(wf2.Id, project2.Id, wi2.Id, Guid.NewGuid(), "PE-C8-C");
db.PurchaseEvaluations.Add(peC);
await db.SaveChangesAsync(CancellationToken.None);
peC.CreatedAt = BaseT;
await db.SaveChangesAsync(CancellationToken.None);
await ApproveWithPriceAsync(svc, peC, approver2, Array.Empty<string>());
peC.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
var noneCount = await db.PurchaseEvaluationChangelogs.AsNoTracking()
.CountAsync(c => c.PurchaseEvaluationId == peC.Id
&& c.ContextNote != null && c.ContextNote.Contains("[S134 pending-prior]"),
CancellationToken.None);
noneCount.Should().Be(0, "finalize không còn phiếu trước treo → KHÔNG ghi row D4");
}
}
}