[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
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:
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user