[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:
@ -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 HĐ 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)),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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 HĐ 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)),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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 ==========
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user