diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 883e010..5865d1f 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -2143,10 +2143,12 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun }) const mut = useMutation({ mutationFn: async () => - api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form), + // [Mig 58 — anh Kiệt FDC] Multi-winner: BE tạo 1 HĐ / mỗi đơn vị trúng (IsWinner) + // → trả LIST contractIds. ContractType người dùng chọn áp cho mọi HĐ. + api.post<{ contractIds: string[] }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form), onSuccess: res => { - toast.success('Đã tạo HĐ từ phiếu.') - navigate(`/contracts/${res.data.contractId}`) + toast.success(`Đã tạo ${res.data.contractIds.length} hợp đồng từ phiếu.`) + navigate(`/contracts/${res.data.contractIds[0]}`) }, onError: e => toast.error(getErrorMessage(e)), }) diff --git a/fe-admin/src/components/pe/PeListPanel.tsx b/fe-admin/src/components/pe/PeListPanel.tsx index 7b9abba..c63972d 100644 --- a/fe-admin/src/components/pe/PeListPanel.tsx +++ b/fe-admin/src/components/pe/PeListPanel.tsx @@ -165,9 +165,16 @@ export function PeListPanel({ · {p.projectName} - {p.selectedSupplierName && ( + {/* [Mig 58 — anh Kiệt FDC] Multi-winner: ưu tiên winnerSupplierNames + (BE null selectedSupplierName khi joint ≥2 winner), fallback + selectedSupplierName cho phiếu 1-winner cũ. */} + {(p.winnerSupplierNames?.length > 0 + ? p.winnerSupplierNames.join(', ') + : p.selectedSupplierName) && (
- ✓ {p.selectedSupplierName} + ✓ {p.winnerSupplierNames?.length > 0 + ? p.winnerSupplierNames.join(', ') + : p.selectedSupplierName}
)} diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts index e641412..3c1f7af 100644 --- a/fe-admin/src/types/purchaseEvaluation.ts +++ b/fe-admin/src/types/purchaseEvaluation.ts @@ -137,6 +137,10 @@ export type PeListItem = { // S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list. isUrgentByPro: boolean isUrgentByCcm: boolean + // [Mig 58 — anh Kiệt FDC] Multi-winner: tên MỌI đơn vị trúng thầu (IsWinner). BE + // null selectedSupplierName khi joint ≥2 winner → list card ưu tiên field này, + // fallback selectedSupplierName cho phiếu 1-winner cũ. + winnerSupplierNames: string[] } export type PeSupplier = { diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 883e010..5865d1f 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -2143,10 +2143,12 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun }) const mut = useMutation({ mutationFn: async () => - api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form), + // [Mig 58 — anh Kiệt FDC] Multi-winner: BE tạo 1 HĐ / mỗi đơn vị trúng (IsWinner) + // → trả LIST contractIds. ContractType người dùng chọn áp cho mọi HĐ. + api.post<{ contractIds: string[] }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form), onSuccess: res => { - toast.success('Đã tạo HĐ từ phiếu.') - navigate(`/contracts/${res.data.contractId}`) + toast.success(`Đã tạo ${res.data.contractIds.length} hợp đồng từ phiếu.`) + navigate(`/contracts/${res.data.contractIds[0]}`) }, onError: e => toast.error(getErrorMessage(e)), }) diff --git a/fe-user/src/components/pe/PeListPanel.tsx b/fe-user/src/components/pe/PeListPanel.tsx index 7b9abba..c63972d 100644 --- a/fe-user/src/components/pe/PeListPanel.tsx +++ b/fe-user/src/components/pe/PeListPanel.tsx @@ -165,9 +165,16 @@ export function PeListPanel({ · {p.projectName} - {p.selectedSupplierName && ( + {/* [Mig 58 — anh Kiệt FDC] Multi-winner: ưu tiên winnerSupplierNames + (BE null selectedSupplierName khi joint ≥2 winner), fallback + selectedSupplierName cho phiếu 1-winner cũ. */} + {(p.winnerSupplierNames?.length > 0 + ? p.winnerSupplierNames.join(', ') + : p.selectedSupplierName) && (
- ✓ {p.selectedSupplierName} + ✓ {p.winnerSupplierNames?.length > 0 + ? p.winnerSupplierNames.join(', ') + : p.selectedSupplierName}
)} diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts index 687daf0..7c96f18 100644 --- a/fe-user/src/types/purchaseEvaluation.ts +++ b/fe-user/src/types/purchaseEvaluation.ts @@ -141,6 +141,10 @@ export type PeListItem = { // S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list. isUrgentByPro: boolean isUrgentByCcm: boolean + // [Mig 58 — anh Kiệt FDC] Multi-winner: tên MỌI đơn vị trúng thầu (IsWinner). BE + // null selectedSupplierName khi joint ≥2 winner → list card ưu tiên field này, + // fallback selectedSupplierName cho phiếu 1-winner cũ. + winnerSupplierNames: string[] } export type PeSupplier = { diff --git a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs index 4687766..bf82cda 100644 --- a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs @@ -294,9 +294,9 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase public async Task> CreateContractFromEvaluation( Guid id, [FromBody] CreateContractFromEvaluationBody body, CancellationToken ct) { - var contractId = await mediator.Send(new CreateContractFromEvaluationCommand( + var contractIds = await mediator.Send(new CreateContractFromEvaluationCommand( id, body.ContractType, body.TenHopDong, body.BypassProcurementAndCCM), ct); - return Ok(new { contractId }); + return Ok(new { contractIds }); // [Mig 58 multi-winner] 1 HĐ / NCC trúng → list ids } // ========== Ý kiến 4 phòng ban ========== diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs index 18ca382..4e95540 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs @@ -18,11 +18,13 @@ namespace SolutionErp.Application.PurchaseEvaluations; // KHÔNG copy Details per-type automatically — user điền riêng sau khi HĐ // gen, tránh mapping sai (PE detail schema ≠ 7 Contract detail schemas). // User có thể reference PE qua PE.ContractId để xem lại báo giá. +// [Mig 58 multi-winner] Trả List — 1 HĐ cho MỖI đơn vị IsWinner (liên-danh ≥2 → +// N hợp đồng draft; single winner → 1 phần tử, hành vi cũ giữ nguyên). public record CreateContractFromEvaluationCommand( Guid PurchaseEvaluationId, ContractType ContractType, string? TenHopDong, - bool BypassProcurementAndCCM = false) : IRequest; + bool BypassProcurementAndCCM = false) : IRequest>; public class CreateContractFromEvaluationCommandValidator : AbstractValidator { @@ -38,24 +40,28 @@ public class CreateContractFromEvaluationCommandHandler( IApplicationDbContext db, ICurrentUser currentUser, IContractWorkflowService workflow, - IContractCodeGenerator codeGenerator) : IRequestHandler + IContractCodeGenerator codeGenerator) : IRequestHandler> { - public async Task Handle(CreateContractFromEvaluationCommand request, CancellationToken ct) + public async Task> Handle(CreateContractFromEvaluationCommand request, CancellationToken ct) { var pe = await db.PurchaseEvaluations - .Include(p => p.Details) + .Include(p => p.Details).ThenInclude(d => d.Quotes) // [Mig 58] Quotes → GiaTri per-winner + .Include(p => p.Suppliers) // [Mig 58] IsWinner = tập winner .FirstOrDefaultAsync(p => p.Id == request.PurchaseEvaluationId, ct) ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); if (pe.Phase != PurchaseEvaluationPhase.DaDuyet) throw new ConflictException("Chỉ tạo HĐ từ phiếu đã duyệt xong (DaDuyet)."); - if (pe.SelectedSupplierId is null) - throw new ConflictException("Phiếu chưa chọn NCC thắng — click 'Chọn NCC' trước."); + // [Mig 58 multi-winner] ≥1 đơn vị IsWinner (thay SelectedSupplierId single — null khi ≥2). + var winners = pe.Suppliers.Where(s => s.IsWinner).ToList(); + if (winners.Count == 0) + throw new ConflictException("Phiếu chưa chọn NCC/TP trúng thầu — click 'Chọn NCC' trước."); if (pe.ContractId is not null) throw new ConflictException("Phiếu này đã tạo HĐ rồi."); - var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == pe.SelectedSupplierId, ct) - ?? throw new NotFoundException("Supplier", pe.SelectedSupplierId.Value); + var supplierIds = winners.Select(w => w.SupplierId).ToList(); + var supplierMap = await db.Suppliers.Where(s => supplierIds.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, ct); var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == pe.ProjectId, ct) ?? throw new NotFoundException("Project", pe.ProjectId); @@ -64,60 +70,85 @@ public class CreateContractFromEvaluationCommandHandler( .Select(w => (Guid?)w.Id) .FirstOrDefaultAsync(ct); - var giaTri = pe.Details.Sum(d => d.ThanhTienNganSach); + // GiaTri: 1 winner → tổng ngân sách details (HÀNH VI CŨ giữ nguyên); ≥2 liên-danh → + // mỗi HĐ = tổng báo giá của NCC đó (giá HĐ = giá NCC chào; HĐ draft sửa được sau — + // anh Kiệt UAT con số này). 0 nếu NCC chưa báo giá. + var detailsSum = pe.Details.Sum(d => d.ThanhTienNganSach); + var isSingle = winners.Count == 1; - var contract = new Contract + // [Mig 58] Codegen TỰ-COMMIT (transaction + ContractCodeSequence RIÊNG mỗi call) → gen + // TẤT CẢ mã TRƯỚC khi Add vào context, build N Contract trong list LOCAL → SaveChanges + // nội-bộ codegen KHÔNG flush contract dở. Sau đó Add cả N + final SaveChanges 1 LẦN = + // atomic (không tạo HĐ dở-dang nếu 1 bước lỗi; chỉ phí vài số seq — chấp nhận được). + var contracts = new List(); + foreach (var w in winners) { - Type = request.ContractType, - Phase = ContractPhase.DangSoanThao, - SupplierId = pe.SelectedSupplierId.Value, - ProjectId = pe.ProjectId, - DepartmentId = pe.DepartmentId, - DrafterUserId = currentUser.UserId, - GiaTri = giaTri, - TenHopDong = request.TenHopDong ?? pe.TenGoiThau, - NoiDung = pe.MoTa, - BypassProcurementAndCCM = request.BypassProcurementAndCCM, - DraftData = pe.PaymentTerms, // carry forward payment terms - // [S61 Mig 50] Budget link cũ DROP — kế thừa "Ngân sách - kỳ này" của - // phiếu sang ngân sách nhập tay HĐ (tham chiếu, HĐ sửa được sau). - BudgetManualName = pe.MaPhieu is null ? null : $"NS kỳ này phiếu {pe.MaPhieu}", - BudgetManualAmount = pe.BudgetPeriodAmount, - WorkflowDefinitionId = activeWfId, - SlaDeadline = DateTime.UtcNow.Add( - workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), - }; - contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct); + if (!supplierMap.TryGetValue(w.SupplierId, out var supplier)) + throw new NotFoundException("Supplier", w.SupplierId); - db.Contracts.Add(contract); + var giaTri = isSingle + ? detailsSum + : pe.Details.SelectMany(d => d.Quotes) + .Where(q => q.PurchaseEvaluationSupplierId == w.Id) + .Sum(q => q.ThanhTien); - // Changelog HĐ: note kế thừa từ phiếu - db.ContractChangelogs.Add(new ContractChangelog + var contract = new Contract + { + Type = request.ContractType, + Phase = ContractPhase.DangSoanThao, + SupplierId = w.SupplierId, + ProjectId = pe.ProjectId, + DepartmentId = pe.DepartmentId, + DrafterUserId = currentUser.UserId, + GiaTri = giaTri, + TenHopDong = request.TenHopDong ?? pe.TenGoiThau, + NoiDung = pe.MoTa, + BypassProcurementAndCCM = request.BypassProcurementAndCCM, + DraftData = pe.PaymentTerms, // carry forward payment terms + // [S61 Mig 50] kế thừa "Ngân sách kỳ này" phiếu → ngân sách nhập tay HĐ (sửa sau). + BudgetManualName = pe.MaPhieu is null ? null : $"NS kỳ này phiếu {pe.MaPhieu}", + BudgetManualAmount = pe.BudgetPeriodAmount, + WorkflowDefinitionId = activeWfId, + SlaDeadline = DateTime.UtcNow.Add( + workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)), + }; + // codegen self-commit — contract CHƯA Add nên SaveChanges nội-bộ chỉ flush seq. + contract.MaHopDong = await codeGenerator.GenerateAsync(contract, project.Code, supplier.Code, ct); + contracts.Add(contract); + } + + foreach (var contract in contracts) { - ContractId = contract.Id, - EntityType = ChangelogEntityType.Contract, - Action = ChangelogAction.Insert, - PhaseAtChange = contract.Phase, - UserId = currentUser.UserId, - Summary = $"Tạo HĐ {contract.MaHopDong} từ phiếu {pe.MaPhieu ?? pe.TenGoiThau}", - ContextNote = $"Kế thừa từ PurchaseEvaluation {pe.Id}", - }); + db.Contracts.Add(contract); + // Changelog HĐ: note kế thừa từ phiếu + db.ContractChangelogs.Add(new ContractChangelog + { + ContractId = contract.Id, + EntityType = ChangelogEntityType.Contract, + Action = ChangelogAction.Insert, + PhaseAtChange = contract.Phase, + UserId = currentUser.UserId, + Summary = $"Tạo HĐ {contract.MaHopDong} từ phiếu {pe.MaPhieu ?? pe.TenGoiThau}", + ContextNote = $"Kế thừa từ PurchaseEvaluation {pe.Id}", + }); + db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog + { + PurchaseEvaluationId = pe.Id, + EntityType = PurchaseEvaluationEntityType.Header, + Action = ChangelogAction.Update, + PhaseAtChange = pe.Phase, + UserId = currentUser.UserId, + Summary = $"Tạo HĐ {contract.MaHopDong} từ phiếu", + ContextNote = $"Contract {contract.Id}", + }); + } - // Link 2 chiều - pe.ContractId = contract.Id; - db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog - { - PurchaseEvaluationId = pe.Id, - EntityType = PurchaseEvaluationEntityType.Header, - Action = ChangelogAction.Update, - PhaseAtChange = pe.Phase, - UserId = currentUser.UserId, - Summary = $"Tạo HĐ {contract.MaHopDong} từ phiếu", - ContextNote = $"Contract {contract.Id}", - }); + // Idempotency: pe.ContractId = HĐ ĐẦU (chặn tạo lại). N HĐ liên-danh truy qua changelog + + // Contract (FE nhận list contractIds). 1 phiếu = 1 link chính + N hợp đồng. + pe.ContractId = contracts[0].Id; await db.SaveChangesAsync(ct); - return contract.Id; + return contracts.Select(c => c.Id).ToList(); } } @@ -153,6 +184,11 @@ public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext d e.DrafterUserId, u != null ? u.FullName : null, e.DepartmentId, d != null ? d.Name : null, e.BudgetPeriodAmount, e.ExpectedRemainingAmount, - e.IsUrgentByPro, e.IsUrgentByCcm)).ToListAsync(ct); // [S69] cờ gấp + e.IsUrgentByPro, e.IsUrgentByCcm, + (from sw in db.PurchaseEvaluationSuppliers + where sw.PurchaseEvaluationId == e.Id && sw.IsWinner + join sup in db.Suppliers on sw.SupplierId equals sup.Id + orderby sup.Name + select sup.Name).ToList())).ToListAsync(ct); // [S69] cờ gấp + [Mig 58] winner names } } diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs index 85c200a..979d802 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs @@ -39,7 +39,10 @@ public record PurchaseEvaluationListItemDto( // [S69 2026-06-17] Cờ gấp per-vai — FE render badge ĐỎ (PRO) / XANH (CCM) trên // card list + ưu tiên hiển thị. 2 cờ độc lập. bool IsUrgentByPro, - bool IsUrgentByCcm); + bool IsUrgentByCcm, + // [Mig 58 multi-winner] Tên các đơn vị IsWinner (FE list card join ", "; ≥2 = liên-danh). + // Thay nhãn từ SelectedSupplierName (null khi ≥2 cùng trúng → card trống). [] khi chưa chọn. + List WinnerSupplierNames); public record PurchaseEvaluationSupplierDto( Guid Id, diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index 5acdfc5..d3a134d 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -622,7 +622,13 @@ public class ListPurchaseEvaluationsQueryHandler( x.e.DrafterUserId, x.u != null ? x.u.FullName : null, x.e.DepartmentId, x.d != null ? x.d.Name : null, x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount, - x.e.IsUrgentByPro, x.e.IsUrgentByCcm)) + x.e.IsUrgentByPro, x.e.IsUrgentByCcm, + // [Mig 58 multi-winner] tên đơn vị IsWinner (≥2 = liên-danh) cho card list. + (from sw in db.PurchaseEvaluationSuppliers + where sw.PurchaseEvaluationId == x.e.Id && sw.IsWinner + join sup in db.Suppliers on sw.SupplierId equals sup.Id + orderby sup.Name + select sup.Name).ToList())) .ToListAsync(ct); return new PagedResult(items, total, request.Page, request.PageSize); @@ -713,7 +719,13 @@ public class GetMyPurchaseEvaluationInboxQueryHandler( x.e.DrafterUserId, x.u != null ? x.u.FullName : null, x.e.DepartmentId, x.d != null ? x.d.Name : null, x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount, - x.e.IsUrgentByPro, x.e.IsUrgentByCcm)) + x.e.IsUrgentByPro, x.e.IsUrgentByCcm, + // [Mig 58 multi-winner] tên đơn vị IsWinner (≥2 = liên-danh) cho card list. + (from sw in db.PurchaseEvaluationSuppliers + where sw.PurchaseEvaluationId == x.e.Id && sw.IsWinner + join sup in db.Suppliers on sw.SupplierId equals sup.Id + orderby sup.Name + select sup.Name).ToList())) .Take(100) .ToListAsync(ct); } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromEvaluationMultiWinnerTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromEvaluationMultiWinnerTests.cs new file mode 100644 index 0000000..0896be9 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/CreateContractFromEvaluationMultiWinnerTests.cs @@ -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 (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>(); + 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 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(); + + // 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(); + + // 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(); + + 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().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(); + + // supplier rows tồn tại nhưng KHÔNG có IsWinner. + var seeded = await SeedAsync(fix, db, + supplierCount: 2, + winnerIndexes: Array.Empty(), + 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().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(); + + // 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().WithMessage("*DaDuyet*"); + + (await db.Contracts.AsNoTracking().CountAsync()).Should().Be(0); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeListWinnerNamesProjectionTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeListWinnerNamesProjectionTests.cs new file mode 100644 index 0000000..51f5ae8 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeListWinnerNamesProjectionTests.cs @@ -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(), "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(); + } +}