[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
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:
@ -294,9 +294,9 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
public async Task<ActionResult<object>> 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 ==========
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user