[CLAUDE] App+Api+FE: Kế thừa HĐ từ phiếu Duyệt NCC (Phase 4)

BE:
 - CreateContractFromEvaluationCommand: guard DaDuyet + SelectedSupplier
   + ContractId=null → tạo Contract draft mới với SupplierId/ProjectId/
   DepartmentId kế thừa từ PE. GiaTri = sum(details.thanhTienNganSach).
   DraftData = PE.PaymentTerms. Gen MaHopDong ngay + pin WorkflowDefinitionId
   theo ContractType user chọn. Log Changelog cả 2 bảng (Contract +
   PurchaseEvaluation), link 2 chiều PE.ContractId = contract.Id.
 - ListApprovedPurchaseEvaluationsQuery: DaDuyet + ContractId=null cho
   FE picker.
 - 2 endpoint mới:
   GET  /api/purchase-evaluations/approved-pending-contract
   POST /api/purchase-evaluations/{id}/create-contract

FE:
 - PeDetailTabs InfoTab: nếu Phase=DaDuyet && !ContractId && SelectedSupplierId
   → banner emerald + button "Tạo HĐ từ phiếu" → CreateContractDialog
   (pick ContractType dropdown 7 loại + TenHopDong + bypass CCM flag)
 - Sau khi tạo → navigate /contracts/{newId}
 - Mirror fe-user.

KHÔNG auto-map PE Details → Contract Details per-type (PE schema ≠ 7
ContractType details schemas — user điền lại sau). PE → Contract link
qua FK ContractId cho navigation + history.
This commit is contained in:
pqhuy1987
2026-04-23 16:58:41 +07:00
parent a737196b21
commit a385d70c2e
4 changed files with 341 additions and 20 deletions

View File

@ -149,8 +149,29 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
[HttpGet("{id:guid}/changelogs")]
public async Task<List<PurchaseEvaluationChangelogDto>> 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<List<PurchaseEvaluationListItemDto>> ListApproved(CancellationToken ct)
=> await mediator.Send(new ListApprovedPurchaseEvaluationsQuery(), ct);
[HttpPost("{id:guid}/create-contract")]
public async Task<ActionResult<object>> 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(

View File

@ -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<Guid>;
public class CreateContractFromEvaluationCommandValidator : AbstractValidator<CreateContractFromEvaluationCommand>
{
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<CreateContractFromEvaluationCommand, Guid>
{
public async Task<Guid> 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<List<PurchaseEvaluationListItemDto>>;
public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext db)
: IRequestHandler<ListApprovedPurchaseEvaluationsQuery, List<PurchaseEvaluationListItemDto>>
{
public async Task<List<PurchaseEvaluationListItemDto>> 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);
}
}