[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

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:
pqhuy1987
2026-06-25 10:30:14 +07:00
parent 73cce1f027
commit 1aa3bdb591
12 changed files with 584 additions and 70 deletions

View File

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

View File

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