[CLAUDE] PurchaseEvaluation: D2 multi-winner outward — create-contract 1→N per winner + list-card winner names
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
Completes the Mig 58 multi-winner (>=2 cung trung) wiring — the audit's joint-winner
dead-ends, CEO-approved scope ("1 HD per winner"):
- create-contract 1->N: one draft HD per IsWinner supplier (was 409 dead-end for >=2).
Command -> List<Guid>. ContractCodeGenerator self-commits its sequence per call, so
gen all N codes first (contracts built local) then atomic add + single final
SaveChanges (no partial create on failure). GiaTri: single = details ThanhTienNganSach
sum (legacy preserved); >=2 = per-winner quote total (DangSoanThao draft, editable —
anh Kiet UAT the basis). pe.ContractId = first (idempotency). Controller -> {contractIds}.
- list-card winner names: PurchaseEvaluationListItemDto +WinnerSupplierNames (EF
collection subquery over IsWinner suppliers; translates + runs on SQLite — test-verified).
FE PeListPanel shows joined names for >=2 (was blank). Dialog -> contractIds[] + "Da tao N HD".
Tests 413 -> 419 (+6: multi-contract 5 [per-winner GiaTri 140/260 vs single legacy 800,
idempotency, 0-winner, not-DaDuyet] + EF-subquery translation smoke). Full suite GREEN.
FE 2-app SHA-identical. reviewer PASS (codegen-sequencing, positional-record arg-order,
no-partial-create all independently verified).
UAT (anh Kiet): open a >=2-winner phieu -> "Tao HD" creates N draft contracts (1/winner);
confirm per-winner contract GiaTri (quote total) is the wanted basis.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,326 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.PurchaseEvaluations;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [PE D2 — Mig 58 multi-winner] test-after cho CreateContractFromEvaluationCommand
|
||||
// SAU khi đổi single-winner (SelectedSupplierId) → multi-winner (Suppliers.IsWinner)
|
||||
// trả List<Guid> (1 HĐ per đơn vị IsWinner). KHÔNG có test cũ cho handler này.
|
||||
//
|
||||
// Code under test: CreateContractFromEvaluationFeatures.cs:45-152.
|
||||
// Lock các invariant anh Kiệt UAT con số:
|
||||
// - GiaTri: 1 winner → SUM details.ThanhTienNganSach (HÀNH VI CŨ giữ); ≥2 →
|
||||
// mỗi HĐ = SUM ThanhTien báo giá của ĐÚNG winner đó (quote.PESupplierId == winner-row.Id).
|
||||
// - 1 Contract per winner, distinct MaHopDong (codegen per supplier), Phase=DangSoanThao,
|
||||
// SupplierId = supplier của winner đó.
|
||||
// - Idempotency: pe.ContractId = HĐ ĐẦU; re-call → ConflictException "đã tạo HĐ rồi".
|
||||
// - 0 winner → ConflictException "chưa chọn NCC/TP trúng thầu".
|
||||
// - Phase != DaDuyet → ConflictException.
|
||||
//
|
||||
// Wiring mirror CreateContractCommandApplicableTypeTests (S33): real
|
||||
// ContractCodeGenerator(db, clock) + real ContractWorkflowService (BeginTransactionAsync
|
||||
// SERIALIZABLE chạy SẠCH trên SQLite — proven WorkflowAppApproveV2Tests / ContractCodeGeneratorTests,
|
||||
// KHÔNG try/skip). IdentityFixture cần cho ChangelogService → UserManager (dù changelog
|
||||
// chỉ stash UserId).
|
||||
//
|
||||
// ⚠️ Seed-FK: PurchaseEvaluationQuote có HARD FK PurchaseEvaluationSupplierId → Supplier
|
||||
// nav (Restrict, PurchaseEvaluationConfiguration:135) → MỖI Quote PHẢI ref 1
|
||||
// PurchaseEvaluationSupplier-row thật (random Guid → SQLite Error 19). Contract.SupplierId/
|
||||
// ProjectId KHÔNG FK vật lý (chỉ index) nhưng handler load Project (NotFound) + build
|
||||
// supplierMap từ Suppliers thật → vẫn seed real Supplier + Project rows.
|
||||
public class CreateContractFromEvaluationMultiWinnerTests
|
||||
{
|
||||
private static readonly DateTime Clock = new(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Build handler với deps thật (mirror prod ContractFeatures wiring).
|
||||
private static CreateContractFromEvaluationCommandHandler BuildHandler(
|
||||
TestApplicationDbContext db, IServiceProvider sp, User actor)
|
||||
{
|
||||
var um = sp.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(Clock);
|
||||
var notify = new NoOpNotificationServiceApp();
|
||||
var currentUser = new TestCurrentUser { UserId = actor.Id, Roles = new[] { AppRoles.Drafter } };
|
||||
var changelog = new ChangelogService(db, currentUser, um);
|
||||
var codeGen = new ContractCodeGenerator(db, dt);
|
||||
var workflowSvc = new ContractWorkflowService(db, codeGen, dt, notify, changelog, um);
|
||||
return new CreateContractFromEvaluationCommandHandler(db, currentUser, workflowSvc, codeGen);
|
||||
}
|
||||
|
||||
private sealed record Seeded(
|
||||
PurchaseEvaluation Pe,
|
||||
Project Project,
|
||||
List<(Supplier supplier, PurchaseEvaluationSupplier row)> Suppliers,
|
||||
User Actor);
|
||||
|
||||
// Seed 1 PE (default DaDuyet) + N supplier rows; winnersByIndex marks which supplier-rows
|
||||
// are IsWinner. detailQuotes[i][j] = ThanhTien báo giá của supplier j cho detail i.
|
||||
// detailBudgets[i] = ThanhTienNganSach (ngân sách) của detail i.
|
||||
private static async Task<Seeded> SeedAsync(
|
||||
IdentityFixture fix,
|
||||
TestApplicationDbContext db,
|
||||
int supplierCount,
|
||||
int[] winnerIndexes,
|
||||
decimal[] detailBudgets,
|
||||
decimal[][] detailQuotes,
|
||||
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet)
|
||||
{
|
||||
var actor = await fix.CreateUserAsync("drafter-d2@test.local", "Drafter D2",
|
||||
departmentId: null, roles: new[] { AppRoles.Drafter });
|
||||
|
||||
var project = new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "Dự án FLOCK 01" };
|
||||
db.Projects.Add(project);
|
||||
|
||||
var suppliers = new List<(Supplier, PurchaseEvaluationSupplier)>();
|
||||
var peId = Guid.NewGuid();
|
||||
for (var i = 0; i < supplierCount; i++)
|
||||
{
|
||||
var supplier = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = $"NCC{i + 1}",
|
||||
Name = $"Nhà cung cấp {i + 1}",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
db.Suppliers.Add(supplier);
|
||||
var row = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
SupplierId = supplier.Id,
|
||||
Order = i,
|
||||
IsWinner = winnerIndexes.Contains(i),
|
||||
};
|
||||
suppliers.Add((supplier, row));
|
||||
}
|
||||
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = peId,
|
||||
MaPhieu = "PE-2026-001",
|
||||
Type = PurchaseEvaluationType.DuyetNccPhuongAn,
|
||||
Phase = phase,
|
||||
TenGoiThau = "Cung cấp bê tông",
|
||||
ProjectId = project.Id,
|
||||
DepartmentId = null,
|
||||
DrafterUserId = actor.Id,
|
||||
MoTa = "Mô tả gói thầu",
|
||||
PaymentTerms = "{\"tamUng\":30}",
|
||||
BudgetPeriodAmount = 999_000_000m,
|
||||
// SelectedSupplierId: với ≥2 winner = null (multi); single = winner duy nhất —
|
||||
// handler KHÔNG đọc field này nữa (đọc Suppliers.IsWinner) nên để null an toàn.
|
||||
SelectedSupplierId = null,
|
||||
};
|
||||
pe.Suppliers.AddRange(suppliers.Select(s => s.Item2));
|
||||
|
||||
for (var i = 0; i < detailBudgets.Length; i++)
|
||||
{
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Bê tông",
|
||||
NoiDung = $"Hạng mục {i + 1}",
|
||||
Order = i,
|
||||
ThanhTienNganSach = detailBudgets[i],
|
||||
};
|
||||
// Quote per supplier cho detail này — FK PESupplierId → supplier-row.Id.
|
||||
for (var j = 0; j < supplierCount; j++)
|
||||
{
|
||||
detail.Quotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = suppliers[j].Item2.Id,
|
||||
ThanhTien = detailQuotes[i][j],
|
||||
});
|
||||
}
|
||||
pe.Details.Add(detail);
|
||||
}
|
||||
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return new Seeded(pe, project,
|
||||
suppliers.Select(s => (s.Item1, s.Item2)).ToList(), actor);
|
||||
}
|
||||
|
||||
// ===== (a) ≥2 winners → N HĐ, mỗi GiaTri = winner's quote total =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_TwoWinners_CreatesTwoContracts_EachGiaTriIsThatWinnerQuoteTotal()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// 2 winner (idx 0 + 1), 1 non-winner (idx 2). 2 details.
|
||||
// Quote totals: winner0 = 100 + 40 = 140; winner1 = 200 + 60 = 260; loser2 = 999.
|
||||
// detailBudgets (NGÂN SÁCH) khác hẳn quote → chứng minh ≥2 KHÔNG dùng budget sum.
|
||||
var seeded = await SeedAsync(fix, db,
|
||||
supplierCount: 3,
|
||||
winnerIndexes: new[] { 0, 1 },
|
||||
detailBudgets: new[] { 500m, 300m }, // sum = 800 (PHẢI bị bỏ qua khi ≥2)
|
||||
detailQuotes: new[]
|
||||
{
|
||||
new[] { 100m, 200m, 7m }, // detail 0: w0=100, w1=200
|
||||
new[] { 40m, 60m, 9m }, // detail 1: w0=40, w1=60
|
||||
});
|
||||
var handler = BuildHandler(db, fix.Services, seeded.Actor);
|
||||
|
||||
var ids = await handler.Handle(
|
||||
new CreateContractFromEvaluationCommand(seeded.Pe.Id, ContractType.HopDongThauPhu, "HĐ liên-danh"),
|
||||
CancellationToken.None);
|
||||
|
||||
ids.Should().HaveCount(2);
|
||||
|
||||
var contracts = await db.Contracts.AsNoTracking()
|
||||
.Where(c => ids.Contains(c.Id)).ToListAsync();
|
||||
contracts.Should().HaveCount(2);
|
||||
contracts.Should().OnlyContain(c => c.Phase == ContractPhase.DangSoanThao);
|
||||
contracts.Should().OnlyContain(c => c.Type == ContractType.HopDongThauPhu);
|
||||
|
||||
// Mỗi Contract.SupplierId khớp 1 winner; KHÔNG có loser.
|
||||
var winnerSupplierIds = new[] { seeded.Suppliers[0].supplier.Id, seeded.Suppliers[1].supplier.Id };
|
||||
contracts.Select(c => c.SupplierId).Should().BeEquivalentTo(winnerSupplierIds);
|
||||
contracts.Should().NotContain(c => c.SupplierId == seeded.Suppliers[2].supplier.Id);
|
||||
|
||||
// GiaTri per-winner = SUM ThanhTien báo giá của ĐÚNG winner đó.
|
||||
var bySupplier = contracts.ToDictionary(c => c.SupplierId, c => c.GiaTri);
|
||||
bySupplier[seeded.Suppliers[0].supplier.Id].Should().Be(140m); // 100 + 40
|
||||
bySupplier[seeded.Suppliers[1].supplier.Id].Should().Be(260m); // 200 + 60
|
||||
|
||||
// 2 mã HĐ DISTINCT (codegen per supplier).
|
||||
contracts.Select(c => c.MaHopDong).Should().OnlyHaveUniqueItems();
|
||||
contracts.Should().OnlyContain(c => c.MaHopDong != null);
|
||||
|
||||
// Idempotency anchor: pe.ContractId = HĐ ĐẦU (contracts[0].Id trong list trả về).
|
||||
var peReloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == seeded.Pe.Id);
|
||||
peReloaded.ContractId.Should().Be(ids[0]);
|
||||
}
|
||||
|
||||
// ===== (b) single winner → 1 HĐ, GiaTri = details budget sum (LEGACY preserved) =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SingleWinner_GiaTriIsDetailsBudgetSum_NotQuoteTotal()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// 1 winner (idx 0). detailBudgets sum = 500 + 300 = 800. winner0 quote total =
|
||||
// 100 + 40 = 140. Single-winner PHẢI = 800 (budget), KHÔNG = 140 (quote).
|
||||
var seeded = await SeedAsync(fix, db,
|
||||
supplierCount: 2,
|
||||
winnerIndexes: new[] { 0 },
|
||||
detailBudgets: new[] { 500m, 300m },
|
||||
detailQuotes: new[]
|
||||
{
|
||||
new[] { 100m, 7m },
|
||||
new[] { 40m, 9m },
|
||||
});
|
||||
var handler = BuildHandler(db, fix.Services, seeded.Actor);
|
||||
|
||||
var ids = await handler.Handle(
|
||||
new CreateContractFromEvaluationCommand(seeded.Pe.Id, ContractType.HopDongThauPhu, null),
|
||||
CancellationToken.None);
|
||||
|
||||
ids.Should().HaveCount(1);
|
||||
|
||||
var contract = await db.Contracts.AsNoTracking().FirstAsync(c => c.Id == ids[0]);
|
||||
contract.SupplierId.Should().Be(seeded.Suppliers[0].supplier.Id);
|
||||
contract.GiaTri.Should().Be(800m); // LEGACY: budget sum, NOT 140
|
||||
contract.Phase.Should().Be(ContractPhase.DangSoanThao);
|
||||
contract.MaHopDong.Should().NotBeNull();
|
||||
// TenHopDong null trong request → fallback pe.TenGoiThau.
|
||||
contract.TenHopDong.Should().Be("Cung cấp bê tông");
|
||||
|
||||
var peReloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(p => p.Id == seeded.Pe.Id);
|
||||
peReloaded.ContractId.Should().Be(ids[0]);
|
||||
}
|
||||
|
||||
// ===== (c) idempotency → re-call throws ConflictException =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Again_AfterSuccess_ThrowsConflict_DaTaoHopDongRoi()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var seeded = await SeedAsync(fix, db,
|
||||
supplierCount: 2,
|
||||
winnerIndexes: new[] { 0, 1 },
|
||||
detailBudgets: new[] { 500m },
|
||||
detailQuotes: new[] { new[] { 100m, 200m } });
|
||||
var handler = BuildHandler(db, fix.Services, seeded.Actor);
|
||||
|
||||
var firstIds = await handler.Handle(
|
||||
new CreateContractFromEvaluationCommand(seeded.Pe.Id, ContractType.HopDongThauPhu, null),
|
||||
CancellationToken.None);
|
||||
firstIds.Should().HaveCount(2);
|
||||
|
||||
// Re-call cùng phiếu (pe.ContractId đã set) → block.
|
||||
var act = async () => await handler.Handle(
|
||||
new CreateContractFromEvaluationCommand(seeded.Pe.Id, ContractType.HopDongThauPhu, null),
|
||||
CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*đã tạo HĐ rồi*");
|
||||
|
||||
// Không tạo thêm HĐ nào (vẫn đúng 2).
|
||||
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(2);
|
||||
}
|
||||
|
||||
// ===== (d) 0 winner → ConflictException (chưa chọn NCC/TP trúng thầu) =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NoWinner_ThrowsConflict_ChuaChonTrungThau()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// supplier rows tồn tại nhưng KHÔNG có IsWinner.
|
||||
var seeded = await SeedAsync(fix, db,
|
||||
supplierCount: 2,
|
||||
winnerIndexes: Array.Empty<int>(),
|
||||
detailBudgets: new[] { 500m },
|
||||
detailQuotes: new[] { new[] { 100m, 200m } });
|
||||
var handler = BuildHandler(db, fix.Services, seeded.Actor);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new CreateContractFromEvaluationCommand(seeded.Pe.Id, ContractType.HopDongThauPhu, null),
|
||||
CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*chưa chọn NCC/TP trúng thầu*");
|
||||
|
||||
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
// ===== (e) not DaDuyet phase → ConflictException =====
|
||||
|
||||
[Fact]
|
||||
public async Task Create_NotDaDuyet_ThrowsConflict()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// Phiếu còn ChoDuyet (chưa duyệt xong) dù có winner → chặn.
|
||||
var seeded = await SeedAsync(fix, db,
|
||||
supplierCount: 2,
|
||||
winnerIndexes: new[] { 0 },
|
||||
detailBudgets: new[] { 500m },
|
||||
detailQuotes: new[] { new[] { 100m, 200m } },
|
||||
phase: PurchaseEvaluationPhase.ChoDuyet);
|
||||
var handler = BuildHandler(db, fix.Services, seeded.Actor);
|
||||
|
||||
var act = async () => await handler.Handle(
|
||||
new CreateContractFromEvaluationCommand(seeded.Pe.Id, ContractType.HopDongThauPhu, null),
|
||||
CancellationToken.None);
|
||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*DaDuyet*");
|
||||
|
||||
(await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.PurchaseEvaluations;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [PE D2 — Mig 58 multi-winner] TRANSLATION-SMOKE cho EF subquery
|
||||
// PurchaseEvaluationListItemDto.WinnerSupplierNames — collection projection trong
|
||||
// Select (from sw in db.PurchaseEvaluationSuppliers where ...IsWinner join Suppliers
|
||||
// select sup.Name).ToList(). RỦI RO: collection-projection trong Select có thể KHÔNG
|
||||
// translate trên 1 số provider → cần CHẠY THẬT trên SQLite provider để chốt.
|
||||
//
|
||||
// Target = ListApprovedPurchaseEvaluationsQuery (CreateContractFromEvaluationFeatures.cs:158)
|
||||
// — sạch nhất (chỉ filter Phase==DaDuyet && ContractId==null, KHÔNG role-paging). Cùng
|
||||
// subquery xuất hiện ở ListPurchaseEvaluationsQuery (paged) + inbox
|
||||
// (PurchaseEvaluationFeatures.cs:627/724) → smoke 1 chỗ đủ chứng minh EF translate được.
|
||||
//
|
||||
// NẾU EF KHÔNG translate (runtime InvalidOperationException) → test FAIL = blocker
|
||||
// REPORT em main (fallback 2-pass). Dùng SqliteDbFixture (KHÔNG cần Identity — query
|
||||
// chỉ đọc, DrafterUserId LEFT-join Users nullable).
|
||||
public class PeListWinnerNamesProjectionTests
|
||||
{
|
||||
private static (Project project, Supplier[] suppliers) SeedMasters(TestApplicationDbContext db, int supplierCount)
|
||||
{
|
||||
var project = new Project { Id = Guid.NewGuid(), Code = "FLOCK01", Name = "Dự án FLOCK 01" };
|
||||
db.Projects.Add(project);
|
||||
var suppliers = new Supplier[supplierCount];
|
||||
for (var i = 0; i < supplierCount; i++)
|
||||
{
|
||||
suppliers[i] = new Supplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = $"NCC{i + 1}",
|
||||
Name = $"Nhà cung cấp {i + 1}",
|
||||
Type = SupplierType.NhaThauPhu,
|
||||
};
|
||||
db.Suppliers.Add(suppliers[i]);
|
||||
}
|
||||
return (project, suppliers);
|
||||
}
|
||||
|
||||
// Seed 1 DaDuyet PE (ContractId=null để lọt ListApproved filter) + supplier-rows;
|
||||
// winnerIndexes = đơn vị IsWinner. Trả PE id.
|
||||
private static Guid SeedPe(
|
||||
TestApplicationDbContext db, Project project, Supplier[] suppliers,
|
||||
int[] winnerIndexes, string maPhieu)
|
||||
{
|
||||
var peId = Guid.NewGuid();
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = peId,
|
||||
MaPhieu = maPhieu,
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DaDuyet,
|
||||
TenGoiThau = $"Gói {maPhieu}",
|
||||
ProjectId = project.Id,
|
||||
ContractId = null, // chưa gen HĐ → lọt picker
|
||||
DrafterUserId = null,
|
||||
};
|
||||
for (var i = 0; i < suppliers.Length; i++)
|
||||
{
|
||||
pe.Suppliers.Add(new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
SupplierId = suppliers[i].Id,
|
||||
Order = i,
|
||||
IsWinner = winnerIndexes.Contains(i),
|
||||
});
|
||||
}
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
return peId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListApproved_WinnerSupplierNames_Translates_AndReturnsBothNames()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
|
||||
var (project, suppliers) = SeedMasters(db, supplierCount: 3);
|
||||
// PE-A: 2 winner (idx 0 + 2 → "Nhà cung cấp 1" + "Nhà cung cấp 3"), idx 1 loser.
|
||||
var peTwo = SeedPe(db, project, suppliers, new[] { 0, 2 }, "PE-TWO");
|
||||
// PE-B: 1 winner (idx 1 → "Nhà cung cấp 2").
|
||||
var peOne = SeedPe(db, project, suppliers, new[] { 1 }, "PE-ONE");
|
||||
// PE-C: 0 winner.
|
||||
var peNone = SeedPe(db, project, suppliers, Array.Empty<int>(), "PE-NONE");
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new ListApprovedPurchaseEvaluationsQueryHandler(db);
|
||||
|
||||
// Nếu subquery KHÔNG translate → dòng này ném InvalidOperationException = FAIL (blocker).
|
||||
var result = await handler.Handle(new ListApprovedPurchaseEvaluationsQuery(), CancellationToken.None);
|
||||
|
||||
var two = result.Single(x => x.Id == peTwo);
|
||||
two.WinnerSupplierNames.Should().HaveCount(2);
|
||||
two.WinnerSupplierNames.Should().Contain("Nhà cung cấp 1");
|
||||
two.WinnerSupplierNames.Should().Contain("Nhà cung cấp 3");
|
||||
two.WinnerSupplierNames.Should().NotContain("Nhà cung cấp 2"); // loser excluded
|
||||
// orderby sup.Name → deterministic thứ tự ("1" < "3").
|
||||
two.WinnerSupplierNames.Should().Equal("Nhà cung cấp 1", "Nhà cung cấp 3");
|
||||
|
||||
var one = result.Single(x => x.Id == peOne);
|
||||
one.WinnerSupplierNames.Should().ContainSingle().Which.Should().Be("Nhà cung cấp 2");
|
||||
|
||||
var none = result.Single(x => x.Id == peNone);
|
||||
none.WinnerSupplierNames.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user