[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

@ -2143,10 +2143,12 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
}) })
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => 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 => { onSuccess: res => {
toast.success('Đã tạo từ phiếu.') toast.success(`Đã tạo ${res.data.contractIds.length} hợp đồng từ phiếu.`)
navigate(`/contracts/${res.data.contractId}`) navigate(`/contracts/${res.data.contractIds[0]}`)
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })

View File

@ -165,9 +165,16 @@ export function PeListPanel({
<span>·</span> <span>·</span>
<span className="truncate">{p.projectName}</span> <span className="truncate">{p.projectName}</span>
</div> </div>
{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) && (
<div className="mt-0.5 truncate text-[11px] text-emerald-600"> <div className="mt-0.5 truncate text-[11px] text-emerald-600">
{p.selectedSupplierName} {p.winnerSupplierNames?.length > 0
? p.winnerSupplierNames.join(', ')
: p.selectedSupplierName}
</div> </div>
)} )}
</div> </div>

View File

@ -137,6 +137,10 @@ export type PeListItem = {
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list. // S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list.
isUrgentByPro: boolean isUrgentByPro: boolean
isUrgentByCcm: 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 = { export type PeSupplier = {

View File

@ -2143,10 +2143,12 @@ function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBun
}) })
const mut = useMutation({ const mut = useMutation({
mutationFn: async () => 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 => { onSuccess: res => {
toast.success('Đã tạo từ phiếu.') toast.success(`Đã tạo ${res.data.contractIds.length} hợp đồng từ phiếu.`)
navigate(`/contracts/${res.data.contractId}`) navigate(`/contracts/${res.data.contractIds[0]}`)
}, },
onError: e => toast.error(getErrorMessage(e)), onError: e => toast.error(getErrorMessage(e)),
}) })

View File

@ -165,9 +165,16 @@ export function PeListPanel({
<span>·</span> <span>·</span>
<span className="truncate">{p.projectName}</span> <span className="truncate">{p.projectName}</span>
</div> </div>
{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) && (
<div className="mt-0.5 truncate text-[11px] text-emerald-600"> <div className="mt-0.5 truncate text-[11px] text-emerald-600">
{p.selectedSupplierName} {p.winnerSupplierNames?.length > 0
? p.winnerSupplierNames.join(', ')
: p.selectedSupplierName}
</div> </div>
)} )}
</div> </div>

View File

@ -141,6 +141,10 @@ export type PeListItem = {
// S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list. // S69 — cờ gấp per-vai (PRO ĐỎ / CCM XANH). FE render chip nhỏ trên card list.
isUrgentByPro: boolean isUrgentByPro: boolean
isUrgentByCcm: 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 = { export type PeSupplier = {

View File

@ -294,9 +294,9 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
public async Task<ActionResult<object>> CreateContractFromEvaluation( public async Task<ActionResult<object>> CreateContractFromEvaluation(
Guid id, [FromBody] CreateContractFromEvaluationBody body, CancellationToken ct) 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); 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 ========== // ========== Ý kiến 4 phòng ban ==========

View File

@ -18,11 +18,13 @@ namespace SolutionErp.Application.PurchaseEvaluations;
// KHÔNG copy Details per-type automatically — user điền riêng sau khi HĐ // 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). // 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á. // User có thể reference PE qua PE.ContractId để xem lại báo giá.
// [Mig 58 multi-winner] Trả List<Guid> — 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( public record CreateContractFromEvaluationCommand(
Guid PurchaseEvaluationId, Guid PurchaseEvaluationId,
ContractType ContractType, ContractType ContractType,
string? TenHopDong, string? TenHopDong,
bool BypassProcurementAndCCM = false) : IRequest<Guid>; bool BypassProcurementAndCCM = false) : IRequest<List<Guid>>;
public class CreateContractFromEvaluationCommandValidator : AbstractValidator<CreateContractFromEvaluationCommand> public class CreateContractFromEvaluationCommandValidator : AbstractValidator<CreateContractFromEvaluationCommand>
{ {
@ -38,24 +40,28 @@ public class CreateContractFromEvaluationCommandHandler(
IApplicationDbContext db, IApplicationDbContext db,
ICurrentUser currentUser, ICurrentUser currentUser,
IContractWorkflowService workflow, IContractWorkflowService workflow,
IContractCodeGenerator codeGenerator) : IRequestHandler<CreateContractFromEvaluationCommand, Guid> IContractCodeGenerator codeGenerator) : IRequestHandler<CreateContractFromEvaluationCommand, List<Guid>>
{ {
public async Task<Guid> Handle(CreateContractFromEvaluationCommand request, CancellationToken ct) public async Task<List<Guid>> Handle(CreateContractFromEvaluationCommand request, CancellationToken ct)
{ {
var pe = await db.PurchaseEvaluations 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) .FirstOrDefaultAsync(p => p.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId); ?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
if (pe.Phase != PurchaseEvaluationPhase.DaDuyet) if (pe.Phase != PurchaseEvaluationPhase.DaDuyet)
throw new ConflictException("Chỉ tạo HĐ từ phiếu đã duyệt xong (DaDuyet)."); throw new ConflictException("Chỉ tạo HĐ từ phiếu đã duyệt xong (DaDuyet).");
if (pe.SelectedSupplierId is null) // [Mig 58 multi-winner] ≥1 đơn vị IsWinner (thay SelectedSupplierId single — null khi ≥2).
throw new ConflictException("Phiếu chưa chọn NCC thắng — click 'Chọn NCC' trước."); 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) if (pe.ContractId is not null)
throw new ConflictException("Phiếu này đã tạo HĐ rồi."); throw new ConflictException("Phiếu này đã tạo HĐ rồi.");
var supplier = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == pe.SelectedSupplierId, ct) var supplierIds = winners.Select(w => w.SupplierId).ToList();
?? throw new NotFoundException("Supplier", pe.SelectedSupplierId.Value); 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) var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == pe.ProjectId, ct)
?? throw new NotFoundException("Project", pe.ProjectId); ?? throw new NotFoundException("Project", pe.ProjectId);
@ -64,60 +70,85 @@ public class CreateContractFromEvaluationCommandHandler(
.Select(w => (Guid?)w.Id) .Select(w => (Guid?)w.Id)
.FirstOrDefaultAsync(ct); .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<Contract>();
foreach (var w in winners)
{ {
Type = request.ContractType, if (!supplierMap.TryGetValue(w.SupplierId, out var supplier))
Phase = ContractPhase.DangSoanThao, throw new NotFoundException("Supplier", w.SupplierId);
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);
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 var contract = new Contract
db.ContractChangelogs.Add(new ContractChangelog {
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, db.Contracts.Add(contract);
EntityType = ChangelogEntityType.Contract, // Changelog HĐ: note kế thừa từ phiếu
Action = ChangelogAction.Insert, db.ContractChangelogs.Add(new ContractChangelog
PhaseAtChange = contract.Phase, {
UserId = currentUser.UserId, ContractId = contract.Id,
Summary = $"Tạo HĐ {contract.MaHopDong} từ phiếu {pe.MaPhieu ?? pe.TenGoiThau}", EntityType = ChangelogEntityType.Contract,
ContextNote = $"Kế thừa từ PurchaseEvaluation {pe.Id}", 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 // Idempotency: pe.ContractId = HĐ ĐẦU (chặn tạo lại). N HĐ liên-danh truy qua changelog +
pe.ContractId = contract.Id; // Contract (FE nhận list contractIds). 1 phiếu = 1 link chính + N hợp đồng.
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog pe.ContractId = contracts[0].Id;
{
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}",
});
await db.SaveChangesAsync(ct); 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.DrafterUserId, u != null ? u.FullName : null,
e.DepartmentId, d != null ? d.Name : null, e.DepartmentId, d != null ? d.Name : null,
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, 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
} }
} }

View File

@ -39,7 +39,10 @@ public record PurchaseEvaluationListItemDto(
// [S69 2026-06-17] Cờ gấp per-vai — FE render badge ĐỎ (PRO) / XANH (CCM) trên // [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. // card list + ưu tiên hiển thị. 2 cờ độc lập.
bool IsUrgentByPro, 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<string> WinnerSupplierNames);
public record PurchaseEvaluationSupplierDto( public record PurchaseEvaluationSupplierDto(
Guid Id, Guid Id,

View File

@ -622,7 +622,13 @@ public class ListPurchaseEvaluationsQueryHandler(
x.e.DrafterUserId, x.u != null ? x.u.FullName : null, x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null, x.e.DepartmentId, x.d != null ? x.d.Name : null,
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount, 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); .ToListAsync(ct);
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize); return new PagedResult<PurchaseEvaluationListItemDto>(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.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null, x.e.DepartmentId, x.d != null ? x.d.Name : null,
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount, 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) .Take(100)
.ToListAsync(ct); .ToListAsync(ct);
} }

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