[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

@ -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<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(
Guid PurchaseEvaluationId,
ContractType ContractType,
string? TenHopDong,
bool BypassProcurementAndCCM = false) : IRequest<Guid>;
bool BypassProcurementAndCCM = false) : IRequest<List<Guid>>;
public class CreateContractFromEvaluationCommandValidator : AbstractValidator<CreateContractFromEvaluationCommand>
{
@ -38,24 +40,28 @@ public class CreateContractFromEvaluationCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
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
.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<Contract>();
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
}
}

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
// 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<string> WinnerSupplierNames);
public record PurchaseEvaluationSupplierDto(
Guid Id,

View File

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