diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 6b4dbd3..1a1c6ec 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -121,18 +121,97 @@ export function PeDetailTabs({ // ===== Tab: Thông tin ===== function InfoTab({ ev }: { ev: PeDetailBundle }) { + const canCreateContract = ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId + const [createOpen, setCreateOpen] = useState(false) return ( -
- - - - - - - {ev.contractId && ( - ✓ Xem HĐ} /> +
+
+ + + + + + + {ev.contractId && ( + ✓ Xem HĐ} /> + )} +
+ {canCreateContract && ( +
+
+
+ ✓ Phiếu đã duyệt. Bấm để tạo HĐ mới kế thừa NCC + hạng mục. +
+ +
+
)} -
+ {createOpen && setCreateOpen(false)} />} + + ) +} + +function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) { + const navigate = useNavigate() + const [form, setForm] = useState({ + contractType: 1, + tenHopDong: evaluation.tenGoiThau, + bypassProcurementAndCCM: false, + }) + const mut = useMutation({ + mutationFn: async () => + api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form), + onSuccess: res => { + toast.success('Đã tạo HĐ từ phiếu.') + navigate(`/contracts/${res.data.contractId}`) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + const typeOptions = [ + [1, 'HĐ Thầu phụ'], + [2, 'HĐ Giao khoán'], + [3, 'HĐ Nhà cung cấp'], + [4, 'HĐ Dịch vụ'], + [5, 'HĐ Mua bán'], + [6, 'HĐ Nguyên tắc NCC'], + [7, 'HĐ Nguyên tắc DV'], + ] as const + return ( + + + + } + > +
+

+ NCC: {evaluation.selectedSupplierName} · Dự án: {evaluation.projectName} +

+
+ + +
+
+ + setForm({ ...form, tenHopDong: e.target.value })} /> +
+ +
+
) } diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx index 6b4dbd3..1a1c6ec 100644 --- a/fe-user/src/components/pe/PeDetailTabs.tsx +++ b/fe-user/src/components/pe/PeDetailTabs.tsx @@ -121,18 +121,97 @@ export function PeDetailTabs({ // ===== Tab: Thông tin ===== function InfoTab({ ev }: { ev: PeDetailBundle }) { + const canCreateContract = ev.phase === PurchaseEvaluationPhase.DaDuyet && !ev.contractId && ev.selectedSupplierId + const [createOpen, setCreateOpen] = useState(false) return ( -
- - - - - - - {ev.contractId && ( - ✓ Xem HĐ} /> +
+
+ + + + + + + {ev.contractId && ( + ✓ Xem HĐ} /> + )} +
+ {canCreateContract && ( +
+
+
+ ✓ Phiếu đã duyệt. Bấm để tạo HĐ mới kế thừa NCC + hạng mục. +
+ +
+
)} -
+ {createOpen && setCreateOpen(false)} />} + + ) +} + +function CreateContractDialog({ evaluation, onClose }: { evaluation: PeDetailBundle; onClose: () => void }) { + const navigate = useNavigate() + const [form, setForm] = useState({ + contractType: 1, + tenHopDong: evaluation.tenGoiThau, + bypassProcurementAndCCM: false, + }) + const mut = useMutation({ + mutationFn: async () => + api.post<{ contractId: string }>(`/purchase-evaluations/${evaluation.id}/create-contract`, form), + onSuccess: res => { + toast.success('Đã tạo HĐ từ phiếu.') + navigate(`/contracts/${res.data.contractId}`) + }, + onError: e => toast.error(getErrorMessage(e)), + }) + const typeOptions = [ + [1, 'HĐ Thầu phụ'], + [2, 'HĐ Giao khoán'], + [3, 'HĐ Nhà cung cấp'], + [4, 'HĐ Dịch vụ'], + [5, 'HĐ Mua bán'], + [6, 'HĐ Nguyên tắc NCC'], + [7, 'HĐ Nguyên tắc DV'], + ] as const + return ( + + + + } + > +
+

+ NCC: {evaluation.selectedSupplierName} · Dự án: {evaluation.projectName} +

+
+ + +
+
+ + setForm({ ...form, tenHopDong: e.target.value })} /> +
+ +
+
) } diff --git a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs index 8b9b4ee..3f06ac8 100644 --- a/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/PurchaseEvaluationsController.cs @@ -149,8 +149,29 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase [HttpGet("{id:guid}/changelogs")] public async Task> GetChangelogs(Guid id, CancellationToken ct) => await mediator.Send(new ListPurchaseEvaluationChangelogsQuery(id), ct); + + // ========== Kế thừa HĐ ========== + + // List phiếu đã DaDuyet chưa gen HĐ — dùng cho modal "Tạo HĐ từ phiếu" + [HttpGet("approved-pending-contract")] + public async Task> ListApproved(CancellationToken ct) + => await mediator.Send(new ListApprovedPurchaseEvaluationsQuery(), ct); + + [HttpPost("{id:guid}/create-contract")] + public async Task> CreateContractFromEvaluation( + Guid id, [FromBody] CreateContractFromEvaluationBody body, CancellationToken ct) + { + var contractId = await mediator.Send(new CreateContractFromEvaluationCommand( + id, body.ContractType, body.TenHopDong, body.BypassProcurementAndCCM), ct); + return Ok(new { contractId }); + } } +public record CreateContractFromEvaluationBody( + Domain.Contracts.ContractType ContractType, + string? TenHopDong, + bool BypassProcurementAndCCM = false); + public record TransitionPeBody(PurchaseEvaluationPhase TargetPhase, ApprovalDecision Decision, string? Comment); public record AddSupplierBody( diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs new file mode 100644 index 0000000..6231c6b --- /dev/null +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/CreateContractFromEvaluationFeatures.cs @@ -0,0 +1,142 @@ +using FluentValidation; +using MediatR; +using Microsoft.EntityFrameworkCore; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.Common.Interfaces; +using SolutionErp.Application.Contracts.Services; +using SolutionErp.Application.PurchaseEvaluations.Dtos; +using SolutionErp.Domain.Contracts; +using SolutionErp.Domain.PurchaseEvaluations; + +namespace SolutionErp.Application.PurchaseEvaluations; + +// Kế thừa từ phiếu Duyệt NCC đã DaDuyet → tạo HĐ Draft mới. Map cơ bản: +// SupplierId = PE.SelectedSupplierId, ProjectId, DepartmentId, TenHopDong, +// GiaTri (sum ThanhTienNganSach của details). ContractType do user chọn +// (phiếu có thể gen HĐ ThauPhu/NhaCungCap/MuaBan... tùy gói thầu). +// +// 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á. +public record CreateContractFromEvaluationCommand( + Guid PurchaseEvaluationId, + ContractType ContractType, + string? TenHopDong, + bool BypassProcurementAndCCM = false) : IRequest; + +public class CreateContractFromEvaluationCommandValidator : AbstractValidator +{ + public CreateContractFromEvaluationCommandValidator() + { + RuleFor(x => x.PurchaseEvaluationId).NotEmpty(); + RuleFor(x => x.ContractType).IsInEnum(); + RuleFor(x => x.TenHopDong).MaximumLength(500); + } +} + +public class CreateContractFromEvaluationCommandHandler( + IApplicationDbContext db, + ICurrentUser currentUser, + IContractWorkflowService workflow, + IContractCodeGenerator codeGenerator) : IRequestHandler +{ + public async Task Handle(CreateContractFromEvaluationCommand request, CancellationToken ct) + { + var pe = await db.PurchaseEvaluations + .Include(p => p.Details) + .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."); + 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 project = await db.Projects.FirstOrDefaultAsync(p => p.Id == pe.ProjectId, ct) + ?? throw new NotFoundException("Project", pe.ProjectId); + + var activeWfId = await db.WorkflowDefinitions.AsNoTracking() + .Where(w => w.ContractType == request.ContractType && w.IsActive) + .Select(w => (Guid?)w.Id) + .FirstOrDefaultAsync(ct); + + var giaTri = pe.Details.Sum(d => d.ThanhTienNganSach); + + var contract = new Contract + { + 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 + 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); + + // 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}", + }); + + // 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}", + }); + + await db.SaveChangesAsync(ct); + return contract.Id; + } +} + +// List phiếu đã duyệt chưa gen HĐ (cho FE modal picker trong ContractCreatePage) +public record ListApprovedPurchaseEvaluationsQuery : IRequest>; + +public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext db) + : IRequestHandler> +{ + public async Task> Handle( + ListApprovedPurchaseEvaluationsQuery request, CancellationToken ct) + { + return await ( + from e in db.PurchaseEvaluations.AsNoTracking() + join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id + join s in db.Suppliers.AsNoTracking() on e.SelectedSupplierId equals s.Id into sj + from s in sj.DefaultIfEmpty() + where e.Phase == PurchaseEvaluationPhase.DaDuyet && e.ContractId == null + orderby e.CreatedAt descending + select new PurchaseEvaluationListItemDto( + e.Id, e.MaPhieu, e.TenGoiThau, e.Type, e.Phase, + e.ProjectId, p.Name, + e.SelectedSupplierId, s != null ? s.Name : null, + e.ContractId, e.SlaDeadline, e.CreatedAt)).ToListAsync(ct); + } +}