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