[CLAUDE] App+Api: PurchaseEvaluation CQRS + Controller + WorkflowService
Application (4 file, ~900 lines): - IPurchaseEvaluationWorkflowService + PurchaseEvaluationDtos - PurchaseEvaluationFeatures: Create / UpdateDraft / Transition / List / Inbox / GetDetail (bundle Suppliers+Details+Quotes+Approvals+Workflow) / Delete / ListChangelogs. IDOR filter role-based phase eligibility. - PurchaseEvaluationSupplierFeatures: Add / Update / Remove supplier (N:M Phiếu × Supplier). Block remove nếu còn Quote FK reference. - PurchaseEvaluationDetailFeatures: Add/Update/Delete hạng mục + Upsert/Delete Quote + SelectWinner (set SelectedSupplierId). Infrastructure: - PurchaseEvaluationWorkflowService: policy load pinned definition → guard role + transition rules. Emit Notification drafter khi state-change. Tạo PurchaseEvaluationApproval + Changelog row. Api: - PurchaseEvaluationsController ~15 endpoint: CRUD phiếu, N:M supplier, hạng mục CRUD, Quote upsert, SelectWinner, Changelog list. Route /api/purchase-evaluations. DI: đăng ký IPurchaseEvaluationWorkflowService scoped. Skip MVP: Attachments upload, Admin PeWorkflows designer UI (sẽ phase sau — framework versioned WF table đã sẵn, designer pattern copy từ HĐ).
This commit is contained in:
@ -0,0 +1,170 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Dtos;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/purchase-evaluations")]
|
||||||
|
[Authorize]
|
||||||
|
public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<PagedResult<PurchaseEvaluationListItemDto>>> List(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null, [FromQuery] bool sortDesc = true,
|
||||||
|
[FromQuery] PurchaseEvaluationType? type = null,
|
||||||
|
[FromQuery] PurchaseEvaluationPhase? phase = null,
|
||||||
|
[FromQuery] Guid? projectId = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(new ListPurchaseEvaluationsQuery(type, phase, projectId)
|
||||||
|
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
|
||||||
|
|
||||||
|
[HttpGet("inbox")]
|
||||||
|
public async Task<ActionResult<List<PurchaseEvaluationListItemDto>>> Inbox(
|
||||||
|
[FromQuery] PurchaseEvaluationType? type = null, CancellationToken ct = default)
|
||||||
|
=> Ok(await mediator.Send(new GetMyPurchaseEvaluationInboxQuery(type), ct));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<ActionResult<PurchaseEvaluationDetailBundleDto>> Get(Guid id, CancellationToken ct)
|
||||||
|
=> Ok(await mediator.Send(new GetPurchaseEvaluationQuery(id), ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<ActionResult<object>> Create([FromBody] CreatePurchaseEvaluationCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var id = await mediator.Send(cmd, ct);
|
||||||
|
return CreatedAtAction(nameof(Get), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Update(Guid id, [FromBody] UpdatePurchaseEvaluationDraftCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
|
||||||
|
await mediator.Send(cmd, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/transitions")]
|
||||||
|
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new TransitionPurchaseEvaluationCommand(id, body.TargetPhase, body.Decision, body.Comment), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeletePurchaseEvaluationCommand(id), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Suppliers (N:M) ==========
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/suppliers")]
|
||||||
|
public async Task<ActionResult<object>> AddSupplier(Guid id, [FromBody] AddSupplierBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var newId = await mediator.Send(new AddPurchaseEvaluationSupplierCommand(
|
||||||
|
id, body.SupplierId, body.DisplayName, body.ContactName, body.ContactEmail,
|
||||||
|
body.ContactPhone, body.PaymentTermText, body.Note), ct);
|
||||||
|
return Ok(new { id = newId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/suppliers/{supplierRowId:guid}")]
|
||||||
|
public async Task<IActionResult> UpdateSupplier(Guid id, Guid supplierRowId, [FromBody] AddSupplierBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdatePurchaseEvaluationSupplierCommand(
|
||||||
|
id, supplierRowId, body.DisplayName, body.ContactName, body.ContactEmail,
|
||||||
|
body.ContactPhone, body.PaymentTermText, body.Note), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/suppliers/{supplierRowId:guid}")]
|
||||||
|
public async Task<IActionResult> RemoveSupplier(Guid id, Guid supplierRowId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new RemovePurchaseEvaluationSupplierCommand(id, supplierRowId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/select-winner")]
|
||||||
|
public async Task<IActionResult> SelectWinner(Guid id, [FromBody] SelectWinnerBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new SelectPurchaseEvaluationWinnerCommand(id, body.SupplierId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Details (hạng mục + ngân sách) ==========
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/details")]
|
||||||
|
public async Task<ActionResult<object>> AddDetail(Guid id, [FromBody] DetailBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var newId = await mediator.Send(new AddPurchaseEvaluationDetailCommand(
|
||||||
|
id, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh,
|
||||||
|
body.KhoiLuongNganSach, body.KhoiLuongThiCong, body.DonGiaNganSach,
|
||||||
|
body.ThanhTienNganSach, body.GhiChu), ct);
|
||||||
|
return Ok(new { id = newId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:guid}/details/{detailId:guid}")]
|
||||||
|
public async Task<IActionResult> UpdateDetail(Guid id, Guid detailId, [FromBody] DetailBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new UpdatePurchaseEvaluationDetailCommand(
|
||||||
|
id, detailId, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh,
|
||||||
|
body.KhoiLuongNganSach, body.KhoiLuongThiCong, body.DonGiaNganSach,
|
||||||
|
body.ThanhTienNganSach, body.GhiChu), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/details/{detailId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteDetail(Guid id, Guid detailId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeletePurchaseEvaluationDetailCommand(id, detailId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Quotes (báo giá per NCC per Detail) ==========
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/quotes")]
|
||||||
|
public async Task<ActionResult<object>> UpsertQuote(Guid id, [FromBody] QuoteBody body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var qid = await mediator.Send(new UpsertPurchaseEvaluationQuoteCommand(
|
||||||
|
id, body.PurchaseEvaluationDetailId, body.PurchaseEvaluationSupplierId,
|
||||||
|
body.BgVat, body.ChuaVat, body.ThanhTien, body.IsSelected, body.Note), ct);
|
||||||
|
return Ok(new { id = qid });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/quotes/{quoteId:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteQuote(Guid id, Guid quoteId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await mediator.Send(new DeletePurchaseEvaluationQuoteCommand(id, quoteId), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Changelogs ==========
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/changelogs")]
|
||||||
|
public async Task<List<PurchaseEvaluationChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
||||||
|
=> await mediator.Send(new ListPurchaseEvaluationChangelogsQuery(id), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TransitionPeBody(PurchaseEvaluationPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
||||||
|
|
||||||
|
public record AddSupplierBody(
|
||||||
|
Guid SupplierId,
|
||||||
|
string? DisplayName, string? ContactName, string? ContactEmail, string? ContactPhone,
|
||||||
|
string? PaymentTermText, string? Note);
|
||||||
|
|
||||||
|
public record SelectWinnerBody(Guid SupplierId);
|
||||||
|
|
||||||
|
public record DetailBody(
|
||||||
|
string GroupCode, string GroupName, string? ItemCode, string NoiDung, string? DonViTinh,
|
||||||
|
decimal KhoiLuongNganSach, decimal KhoiLuongThiCong, decimal DonGiaNganSach,
|
||||||
|
decimal ThanhTienNganSach, string? GhiChu);
|
||||||
|
|
||||||
|
public record QuoteBody(
|
||||||
|
Guid PurchaseEvaluationDetailId, Guid PurchaseEvaluationSupplierId,
|
||||||
|
decimal BgVat, decimal ChuaVat, decimal ThanhTien, bool IsSelected, string? Note);
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations.Dtos;
|
||||||
|
|
||||||
|
public record PurchaseEvaluationListItemDto(
|
||||||
|
Guid Id,
|
||||||
|
string? MaPhieu,
|
||||||
|
string TenGoiThau,
|
||||||
|
PurchaseEvaluationType Type,
|
||||||
|
PurchaseEvaluationPhase Phase,
|
||||||
|
Guid ProjectId,
|
||||||
|
string ProjectName,
|
||||||
|
Guid? SelectedSupplierId,
|
||||||
|
string? SelectedSupplierName,
|
||||||
|
Guid? ContractId,
|
||||||
|
DateTime? SlaDeadline,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationSupplierDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid SupplierId,
|
||||||
|
string SupplierName,
|
||||||
|
string? DisplayName,
|
||||||
|
string? ContactName,
|
||||||
|
string? ContactEmail,
|
||||||
|
string? ContactPhone,
|
||||||
|
string? PaymentTermText,
|
||||||
|
string? Note,
|
||||||
|
int Order);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationQuoteDto(
|
||||||
|
Guid Id,
|
||||||
|
Guid PurchaseEvaluationDetailId,
|
||||||
|
Guid PurchaseEvaluationSupplierId,
|
||||||
|
decimal BgVat,
|
||||||
|
decimal ChuaVat,
|
||||||
|
decimal ThanhTien,
|
||||||
|
bool IsSelected,
|
||||||
|
string? Note);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationDetailDto(
|
||||||
|
Guid Id,
|
||||||
|
string GroupCode,
|
||||||
|
string GroupName,
|
||||||
|
string? ItemCode,
|
||||||
|
string NoiDung,
|
||||||
|
string? DonViTinh,
|
||||||
|
decimal KhoiLuongNganSach,
|
||||||
|
decimal KhoiLuongThiCong,
|
||||||
|
decimal DonGiaNganSach,
|
||||||
|
decimal ThanhTienNganSach,
|
||||||
|
int Order,
|
||||||
|
string? GhiChu,
|
||||||
|
List<PurchaseEvaluationQuoteDto> Quotes);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationApprovalDto(
|
||||||
|
Guid Id,
|
||||||
|
PurchaseEvaluationPhase FromPhase,
|
||||||
|
PurchaseEvaluationPhase ToPhase,
|
||||||
|
Guid? ApproverUserId,
|
||||||
|
string? ApproverName,
|
||||||
|
ApprovalDecision Decision,
|
||||||
|
string? Comment,
|
||||||
|
DateTime ApprovedAt);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationChangelogDto(
|
||||||
|
Guid Id,
|
||||||
|
PurchaseEvaluationEntityType EntityType,
|
||||||
|
Guid? EntityId,
|
||||||
|
ChangelogAction Action,
|
||||||
|
PurchaseEvaluationPhase? PhaseAtChange,
|
||||||
|
Guid? UserId,
|
||||||
|
string? UserName,
|
||||||
|
string? Summary,
|
||||||
|
string? FieldChangesJson,
|
||||||
|
string? ContextNote,
|
||||||
|
DateTime CreatedAt);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationWorkflowSummaryDto(
|
||||||
|
string PolicyName,
|
||||||
|
string PolicyDescription,
|
||||||
|
List<PurchaseEvaluationPhase> ActivePhases,
|
||||||
|
List<PurchaseEvaluationPhase> NextPhases);
|
||||||
|
|
||||||
|
public record PurchaseEvaluationDetailBundleDto(
|
||||||
|
Guid Id,
|
||||||
|
string? MaPhieu,
|
||||||
|
PurchaseEvaluationType Type,
|
||||||
|
PurchaseEvaluationPhase Phase,
|
||||||
|
string TenGoiThau,
|
||||||
|
string? DiaDiem,
|
||||||
|
string? MoTa,
|
||||||
|
Guid ProjectId,
|
||||||
|
string ProjectName,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
string? DepartmentName,
|
||||||
|
Guid? DrafterUserId,
|
||||||
|
string? DrafterName,
|
||||||
|
Guid? SelectedSupplierId,
|
||||||
|
string? SelectedSupplierName,
|
||||||
|
Guid? ContractId,
|
||||||
|
string? PaymentTerms,
|
||||||
|
DateTime? SlaDeadline,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime? UpdatedAt,
|
||||||
|
List<PurchaseEvaluationSupplierDto> Suppliers,
|
||||||
|
List<PurchaseEvaluationDetailDto> Details,
|
||||||
|
List<PurchaseEvaluationApprovalDto> Approvals,
|
||||||
|
PurchaseEvaluationWorkflowSummaryDto Workflow);
|
||||||
@ -0,0 +1,249 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
|
// ========== Detail (hạng mục + ngân sách) ==========
|
||||||
|
|
||||||
|
public record AddPurchaseEvaluationDetailCommand(
|
||||||
|
Guid PurchaseEvaluationId,
|
||||||
|
string GroupCode,
|
||||||
|
string GroupName,
|
||||||
|
string? ItemCode,
|
||||||
|
string NoiDung,
|
||||||
|
string? DonViTinh,
|
||||||
|
decimal KhoiLuongNganSach,
|
||||||
|
decimal KhoiLuongThiCong,
|
||||||
|
decimal DonGiaNganSach,
|
||||||
|
decimal ThanhTienNganSach,
|
||||||
|
string? GhiChu) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class AddPurchaseEvaluationDetailCommandValidator : AbstractValidator<AddPurchaseEvaluationDetailCommand>
|
||||||
|
{
|
||||||
|
public AddPurchaseEvaluationDetailCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
|
||||||
|
RuleFor(x => x.GroupCode).NotEmpty().MaximumLength(50);
|
||||||
|
RuleFor(x => x.GroupName).NotEmpty().MaximumLength(200);
|
||||||
|
RuleFor(x => x.NoiDung).NotEmpty().MaximumLength(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AddPurchaseEvaluationDetailCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<AddPurchaseEvaluationDetailCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(AddPurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||||
|
|
||||||
|
var maxOrder = await db.PurchaseEvaluationDetails
|
||||||
|
.Where(d => d.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
||||||
|
.Select(d => (int?)d.Order)
|
||||||
|
.MaxAsync(ct);
|
||||||
|
|
||||||
|
var entity = new PurchaseEvaluationDetail
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
GroupCode = request.GroupCode,
|
||||||
|
GroupName = request.GroupName,
|
||||||
|
ItemCode = request.ItemCode,
|
||||||
|
NoiDung = request.NoiDung,
|
||||||
|
DonViTinh = request.DonViTinh,
|
||||||
|
KhoiLuongNganSach = request.KhoiLuongNganSach,
|
||||||
|
KhoiLuongThiCong = request.KhoiLuongThiCong,
|
||||||
|
DonGiaNganSach = request.DonGiaNganSach,
|
||||||
|
ThanhTienNganSach = request.ThanhTienNganSach,
|
||||||
|
GhiChu = request.GhiChu,
|
||||||
|
Order = (maxOrder ?? 0) + 1,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationDetails.Add(entity);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Detail,
|
||||||
|
EntityId = entity.Id,
|
||||||
|
Action = ChangelogAction.Insert,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Thêm hạng mục {request.GroupCode} — {request.NoiDung}",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdatePurchaseEvaluationDetailCommand(
|
||||||
|
Guid PurchaseEvaluationId,
|
||||||
|
Guid DetailId,
|
||||||
|
string GroupCode,
|
||||||
|
string GroupName,
|
||||||
|
string? ItemCode,
|
||||||
|
string NoiDung,
|
||||||
|
string? DonViTinh,
|
||||||
|
decimal KhoiLuongNganSach,
|
||||||
|
decimal KhoiLuongThiCong,
|
||||||
|
decimal DonGiaNganSach,
|
||||||
|
decimal ThanhTienNganSach,
|
||||||
|
string? GhiChu) : IRequest;
|
||||||
|
|
||||||
|
public class UpdatePurchaseEvaluationDetailCommandHandler(
|
||||||
|
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationDetailCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdatePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.PurchaseEvaluationDetails
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
||||||
|
|
||||||
|
entity.GroupCode = request.GroupCode;
|
||||||
|
entity.GroupName = request.GroupName;
|
||||||
|
entity.ItemCode = request.ItemCode;
|
||||||
|
entity.NoiDung = request.NoiDung;
|
||||||
|
entity.DonViTinh = request.DonViTinh;
|
||||||
|
entity.KhoiLuongNganSach = request.KhoiLuongNganSach;
|
||||||
|
entity.KhoiLuongThiCong = request.KhoiLuongThiCong;
|
||||||
|
entity.DonGiaNganSach = request.DonGiaNganSach;
|
||||||
|
entity.ThanhTienNganSach = request.ThanhTienNganSach;
|
||||||
|
entity.GhiChu = request.GhiChu;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DeletePurchaseEvaluationDetailCommand(Guid PurchaseEvaluationId, Guid DetailId) : IRequest;
|
||||||
|
|
||||||
|
public class DeletePurchaseEvaluationDetailCommandHandler(
|
||||||
|
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationDetailCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(DeletePurchaseEvaluationDetailCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.PurchaseEvaluationDetails
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.DetailId && x.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluationDetail", request.DetailId);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationDetails.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Quote (báo giá per NCC per Detail) — upsert ==========
|
||||||
|
|
||||||
|
public record UpsertPurchaseEvaluationQuoteCommand(
|
||||||
|
Guid PurchaseEvaluationId,
|
||||||
|
Guid PurchaseEvaluationDetailId,
|
||||||
|
Guid PurchaseEvaluationSupplierId,
|
||||||
|
decimal BgVat,
|
||||||
|
decimal ChuaVat,
|
||||||
|
decimal ThanhTien,
|
||||||
|
bool IsSelected,
|
||||||
|
string? Note) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class UpsertPurchaseEvaluationQuoteCommandHandler(
|
||||||
|
IApplicationDbContext db) : IRequestHandler<UpsertPurchaseEvaluationQuoteCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(UpsertPurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Verify parents exist + same phiếu
|
||||||
|
var detail = await db.PurchaseEvaluationDetails.FirstOrDefaultAsync(
|
||||||
|
d => d.Id == request.PurchaseEvaluationDetailId && d.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluationDetail", request.PurchaseEvaluationDetailId);
|
||||||
|
|
||||||
|
var supplier = await db.PurchaseEvaluationSuppliers.FirstOrDefaultAsync(
|
||||||
|
s => s.Id == request.PurchaseEvaluationSupplierId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.PurchaseEvaluationSupplierId);
|
||||||
|
|
||||||
|
var existing = await db.PurchaseEvaluationQuotes.FirstOrDefaultAsync(
|
||||||
|
q => q.PurchaseEvaluationDetailId == request.PurchaseEvaluationDetailId
|
||||||
|
&& q.PurchaseEvaluationSupplierId == request.PurchaseEvaluationSupplierId, ct);
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.BgVat = request.BgVat;
|
||||||
|
existing.ChuaVat = request.ChuaVat;
|
||||||
|
existing.ThanhTien = request.ThanhTien;
|
||||||
|
existing.IsSelected = request.IsSelected;
|
||||||
|
existing.Note = request.Note;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return existing.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new PurchaseEvaluationQuote
|
||||||
|
{
|
||||||
|
PurchaseEvaluationDetailId = request.PurchaseEvaluationDetailId,
|
||||||
|
PurchaseEvaluationSupplierId = request.PurchaseEvaluationSupplierId,
|
||||||
|
BgVat = request.BgVat,
|
||||||
|
ChuaVat = request.ChuaVat,
|
||||||
|
ThanhTien = request.ThanhTien,
|
||||||
|
IsSelected = request.IsSelected,
|
||||||
|
Note = request.Note,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationQuotes.Add(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DeletePurchaseEvaluationQuoteCommand(Guid PurchaseEvaluationId, Guid QuoteId) : IRequest;
|
||||||
|
|
||||||
|
public class DeletePurchaseEvaluationQuoteCommandHandler(
|
||||||
|
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationQuoteCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(DeletePurchaseEvaluationQuoteCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var quote = await (
|
||||||
|
from q in db.PurchaseEvaluationQuotes
|
||||||
|
join d in db.PurchaseEvaluationDetails on q.PurchaseEvaluationDetailId equals d.Id
|
||||||
|
where q.Id == request.QuoteId && d.PurchaseEvaluationId == request.PurchaseEvaluationId
|
||||||
|
select q).FirstOrDefaultAsync(ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluationQuote", request.QuoteId);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationQuotes.Remove(quote);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Select winner (NCC được chọn tổng thể) ==========
|
||||||
|
|
||||||
|
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid SupplierId) : IRequest;
|
||||||
|
|
||||||
|
public class SelectPurchaseEvaluationWinnerCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<SelectPurchaseEvaluationWinnerCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(SelectPurchaseEvaluationWinnerCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||||
|
|
||||||
|
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
|
||||||
|
?? throw new NotFoundException("Supplier", request.SupplierId);
|
||||||
|
|
||||||
|
// Verify supplier nằm trong danh sách phiếu
|
||||||
|
var hasSupplier = await db.PurchaseEvaluationSuppliers
|
||||||
|
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct);
|
||||||
|
if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
|
||||||
|
|
||||||
|
entity.SelectedSupplierId = request.SupplierId;
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = entity.Id,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Header,
|
||||||
|
Action = ChangelogAction.Update,
|
||||||
|
PhaseAtChange = entity.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = "Chọn NCC trúng thầu",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,440 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Common.Models;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Dtos;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Services;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
|
// ========== CREATE ==========
|
||||||
|
|
||||||
|
public record CreatePurchaseEvaluationCommand(
|
||||||
|
PurchaseEvaluationType Type,
|
||||||
|
string TenGoiThau,
|
||||||
|
Guid ProjectId,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
string? DiaDiem,
|
||||||
|
string? MoTa,
|
||||||
|
string? PaymentTerms) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<CreatePurchaseEvaluationCommand>
|
||||||
|
{
|
||||||
|
public CreatePurchaseEvaluationCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Type).IsInEnum();
|
||||||
|
RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500);
|
||||||
|
RuleFor(x => x.ProjectId).NotEmpty();
|
||||||
|
RuleFor(x => x.DiaDiem).MaximumLength(500);
|
||||||
|
RuleFor(x => x.MoTa).MaximumLength(2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreatePurchaseEvaluationCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IPurchaseEvaluationWorkflowService workflow) : IRequestHandler<CreatePurchaseEvaluationCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(CreatePurchaseEvaluationCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_ = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct)
|
||||||
|
?? throw new NotFoundException("Project", request.ProjectId);
|
||||||
|
|
||||||
|
var activeWfId = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
|
||||||
|
.Where(w => w.EvaluationType == request.Type && w.IsActive)
|
||||||
|
.Select(w => (Guid?)w.Id)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var entity = new PurchaseEvaluation
|
||||||
|
{
|
||||||
|
Type = request.Type,
|
||||||
|
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||||
|
TenGoiThau = request.TenGoiThau,
|
||||||
|
ProjectId = request.ProjectId,
|
||||||
|
DepartmentId = request.DepartmentId,
|
||||||
|
DiaDiem = request.DiaDiem,
|
||||||
|
MoTa = request.MoTa,
|
||||||
|
DrafterUserId = currentUser.UserId,
|
||||||
|
WorkflowDefinitionId = activeWfId,
|
||||||
|
PaymentTerms = request.PaymentTerms,
|
||||||
|
SlaDeadline = DateTime.UtcNow.Add(
|
||||||
|
workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-gen MaPhieu đơn giản PE-YYYYMM-XXXX (4 digit random) — format sau
|
||||||
|
entity.MaPhieu = $"PE-{DateTime.UtcNow:yyyyMM}-{Random.Shared.Next(1000, 9999)}";
|
||||||
|
|
||||||
|
db.PurchaseEvaluations.Add(entity);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = entity.Id,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Header,
|
||||||
|
Action = ChangelogAction.Insert,
|
||||||
|
PhaseAtChange = entity.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Tạo phiếu {entity.MaPhieu} — {entity.TenGoiThau}",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== UPDATE draft ==========
|
||||||
|
|
||||||
|
public record UpdatePurchaseEvaluationDraftCommand(
|
||||||
|
Guid Id,
|
||||||
|
string TenGoiThau,
|
||||||
|
string? DiaDiem,
|
||||||
|
string? MoTa,
|
||||||
|
string? PaymentTerms) : IRequest;
|
||||||
|
|
||||||
|
public class UpdatePurchaseEvaluationDraftCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDraftCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdatePurchaseEvaluationDraftCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||||
|
|
||||||
|
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao)
|
||||||
|
throw new ConflictException("Chỉ sửa được phiếu khi ở phase Đang soạn thảo.");
|
||||||
|
|
||||||
|
entity.TenGoiThau = request.TenGoiThau;
|
||||||
|
entity.DiaDiem = request.DiaDiem;
|
||||||
|
entity.MoTa = request.MoTa;
|
||||||
|
entity.PaymentTerms = request.PaymentTerms;
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = entity.Id,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Header,
|
||||||
|
Action = ChangelogAction.Update,
|
||||||
|
PhaseAtChange = entity.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = "Cập nhật thông tin phiếu",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== TRANSITION ==========
|
||||||
|
|
||||||
|
public record TransitionPurchaseEvaluationCommand(
|
||||||
|
Guid Id,
|
||||||
|
PurchaseEvaluationPhase TargetPhase,
|
||||||
|
ApprovalDecision Decision,
|
||||||
|
string? Comment) : IRequest;
|
||||||
|
|
||||||
|
public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
|
||||||
|
{
|
||||||
|
public TransitionPurchaseEvaluationCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Id).NotEmpty();
|
||||||
|
RuleFor(x => x.TargetPhase).IsInEnum();
|
||||||
|
RuleFor(x => x.Decision).IsInEnum();
|
||||||
|
RuleFor(x => x.Comment).MaximumLength(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TransitionPurchaseEvaluationCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IPurchaseEvaluationWorkflowService workflow) : IRequestHandler<TransitionPurchaseEvaluationCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(TransitionPurchaseEvaluationCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||||
|
|
||||||
|
await workflow.TransitionAsync(
|
||||||
|
entity,
|
||||||
|
request.TargetPhase,
|
||||||
|
currentUser.UserId,
|
||||||
|
currentUser.Roles,
|
||||||
|
request.Decision,
|
||||||
|
request.Comment,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LIST ==========
|
||||||
|
|
||||||
|
public record ListPurchaseEvaluationsQuery(
|
||||||
|
PurchaseEvaluationType? Type = null,
|
||||||
|
PurchaseEvaluationPhase? Phase = null,
|
||||||
|
Guid? ProjectId = null) : PagedRequest, IRequest<PagedResult<PurchaseEvaluationListItemDto>>;
|
||||||
|
|
||||||
|
public class ListPurchaseEvaluationsQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<ListPurchaseEvaluationsQuery, PagedResult<PurchaseEvaluationListItemDto>>
|
||||||
|
{
|
||||||
|
public async Task<PagedResult<PurchaseEvaluationListItemDto>> Handle(
|
||||||
|
ListPurchaseEvaluationsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var q = 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()
|
||||||
|
select new { e, p, s };
|
||||||
|
|
||||||
|
// IDOR: non-admin chỉ thấy phiếu mình là Drafter hoặc role eligible phase
|
||||||
|
if (!currentUser.Roles.Contains(AppRoles.Admin))
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId;
|
||||||
|
var eligiblePhases = GetEligiblePhases(currentUser.Roles);
|
||||||
|
q = q.Where(x => x.e.DrafterUserId == userId || eligiblePhases.Contains(x.e.Phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
|
||||||
|
if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase);
|
||||||
|
if (request.ProjectId is not null) q = q.Where(x => x.e.ProjectId == request.ProjectId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Search))
|
||||||
|
{
|
||||||
|
var s = request.Search.Trim();
|
||||||
|
q = q.Where(x =>
|
||||||
|
(x.e.MaPhieu != null && x.e.MaPhieu.Contains(s)) ||
|
||||||
|
x.e.TenGoiThau.Contains(s) ||
|
||||||
|
x.p.Name.Contains(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
q = request.SortDesc ? q.OrderByDescending(x => x.e.CreatedAt) : q.OrderBy(x => x.e.CreatedAt);
|
||||||
|
|
||||||
|
var total = await q.CountAsync(ct);
|
||||||
|
var items = await q
|
||||||
|
.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
|
||||||
|
.Select(x => new PurchaseEvaluationListItemDto(
|
||||||
|
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
|
||||||
|
x.e.ProjectId, x.p.Name,
|
||||||
|
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
|
||||||
|
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static List<PurchaseEvaluationPhase> GetEligiblePhases(IReadOnlyList<string> userRoles)
|
||||||
|
{
|
||||||
|
var phases = new HashSet<PurchaseEvaluationPhase>();
|
||||||
|
void AddIfAny(string[] required, params PurchaseEvaluationPhase[] toAdd)
|
||||||
|
{
|
||||||
|
if (userRoles.Any(r => required.Contains(r)))
|
||||||
|
foreach (var p in toAdd) phases.Add(p);
|
||||||
|
}
|
||||||
|
AddIfAny([AppRoles.Drafter, AppRoles.DeptManager], PurchaseEvaluationPhase.DangSoanThao);
|
||||||
|
AddIfAny([AppRoles.Procurement], PurchaseEvaluationPhase.ChoPurchasing);
|
||||||
|
AddIfAny([AppRoles.ProjectManager], PurchaseEvaluationPhase.ChoDuAn);
|
||||||
|
AddIfAny([AppRoles.CostControl], PurchaseEvaluationPhase.ChoCCM);
|
||||||
|
AddIfAny([AppRoles.Director, AppRoles.AuthorizedSigner],
|
||||||
|
PurchaseEvaluationPhase.ChoCEODuyetPA, PurchaseEvaluationPhase.ChoCEODuyetNCC);
|
||||||
|
return phases.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== INBOX ==========
|
||||||
|
|
||||||
|
public record GetMyPurchaseEvaluationInboxQuery(PurchaseEvaluationType? Type = null)
|
||||||
|
: IRequest<List<PurchaseEvaluationListItemDto>>;
|
||||||
|
|
||||||
|
public class GetMyPurchaseEvaluationInboxQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<GetMyPurchaseEvaluationInboxQuery, List<PurchaseEvaluationListItemDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<PurchaseEvaluationListItemDto>> Handle(
|
||||||
|
GetMyPurchaseEvaluationInboxQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
var userRoles = currentUser.Roles;
|
||||||
|
var isAdmin = userRoles.Contains(AppRoles.Admin);
|
||||||
|
var eligiblePhases = isAdmin
|
||||||
|
? [
|
||||||
|
PurchaseEvaluationPhase.DangSoanThao,
|
||||||
|
PurchaseEvaluationPhase.ChoPurchasing,
|
||||||
|
PurchaseEvaluationPhase.ChoDuAn,
|
||||||
|
PurchaseEvaluationPhase.ChoCCM,
|
||||||
|
PurchaseEvaluationPhase.ChoCEODuyetPA,
|
||||||
|
PurchaseEvaluationPhase.ChoCEODuyetNCC,
|
||||||
|
]
|
||||||
|
: ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(userRoles);
|
||||||
|
|
||||||
|
if (eligiblePhases.Count == 0) return [];
|
||||||
|
|
||||||
|
var q = 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 eligiblePhases.Contains(e.Phase)
|
||||||
|
select new { e, p, s };
|
||||||
|
|
||||||
|
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
|
||||||
|
|
||||||
|
return await q
|
||||||
|
.OrderBy(x => x.e.SlaDeadline ?? DateTime.MaxValue)
|
||||||
|
.Select(x => new PurchaseEvaluationListItemDto(
|
||||||
|
x.e.Id, x.e.MaPhieu, x.e.TenGoiThau, x.e.Type, x.e.Phase,
|
||||||
|
x.e.ProjectId, x.p.Name,
|
||||||
|
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
|
||||||
|
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt))
|
||||||
|
.Take(100)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== GET detail bundle ==========
|
||||||
|
|
||||||
|
public record GetPurchaseEvaluationQuery(Guid Id) : IRequest<PurchaseEvaluationDetailBundleDto>;
|
||||||
|
|
||||||
|
public class GetPurchaseEvaluationQueryHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
UserManager<User> userManager,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<GetPurchaseEvaluationQuery, PurchaseEvaluationDetailBundleDto>
|
||||||
|
{
|
||||||
|
public async Task<PurchaseEvaluationDetailBundleDto> Handle(
|
||||||
|
GetPurchaseEvaluationQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var e = await db.PurchaseEvaluations.AsNoTracking()
|
||||||
|
.Include(x => x.Suppliers)
|
||||||
|
.Include(x => x.Details).ThenInclude(d => d.Quotes)
|
||||||
|
.Include(x => x.Approvals)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||||
|
|
||||||
|
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
|
||||||
|
if (!isAdmin)
|
||||||
|
{
|
||||||
|
var isDrafter = e.DrafterUserId == currentUser.UserId;
|
||||||
|
var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
|
||||||
|
if (!isDrafter && !eligiblePhases.Contains(e.Phase))
|
||||||
|
throw new ForbiddenException("Bạn không có quyền xem phiếu này.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == e.ProjectId, ct);
|
||||||
|
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
|
||||||
|
var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);
|
||||||
|
|
||||||
|
// Load supplier names for PE suppliers + approver names
|
||||||
|
var supplierIds = e.Suppliers.Select(s => s.SupplierId).ToList();
|
||||||
|
var suppliers = await db.Suppliers.AsNoTracking().Where(s => supplierIds.Contains(s.Id))
|
||||||
|
.ToDictionaryAsync(s => s.Id, s => s.Name, ct);
|
||||||
|
|
||||||
|
var userIds = new HashSet<Guid>();
|
||||||
|
if (e.DrafterUserId is Guid did) userIds.Add(did);
|
||||||
|
foreach (var a in e.Approvals) if (a.ApproverUserId is Guid aid) userIds.Add(aid);
|
||||||
|
var users = await userManager.Users.AsNoTracking()
|
||||||
|
.Where(u => userIds.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
|
||||||
|
|
||||||
|
// Resolve workflow policy
|
||||||
|
PurchaseEvaluationPolicy policy;
|
||||||
|
if (e.WorkflowDefinitionId is Guid wfId)
|
||||||
|
{
|
||||||
|
var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
|
||||||
|
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||||
|
.ThenInclude(s => s.Approvers)
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
|
||||||
|
policy = def is not null
|
||||||
|
? PurchaseEvaluationPolicyRegistry.FromDefinition(def)
|
||||||
|
: PurchaseEvaluationPolicyRegistry.ForEvaluation(e);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PurchaseEvaluationDetailBundleDto(
|
||||||
|
e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
|
||||||
|
e.ProjectId, project?.Name ?? "",
|
||||||
|
e.DepartmentId, department?.Name,
|
||||||
|
e.DrafterUserId, e.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
|
||||||
|
e.SelectedSupplierId, selectedSupplier?.Name,
|
||||||
|
e.ContractId,
|
||||||
|
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
|
||||||
|
e.Suppliers
|
||||||
|
.OrderBy(s => s.Order)
|
||||||
|
.Select(s => new PurchaseEvaluationSupplierDto(
|
||||||
|
s.Id, s.SupplierId,
|
||||||
|
suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "",
|
||||||
|
s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone,
|
||||||
|
s.PaymentTermText, s.Note, s.Order))
|
||||||
|
.ToList(),
|
||||||
|
e.Details
|
||||||
|
.OrderBy(d => d.Order)
|
||||||
|
.Select(d => new PurchaseEvaluationDetailDto(
|
||||||
|
d.Id, d.GroupCode, d.GroupName, d.ItemCode, d.NoiDung, d.DonViTinh,
|
||||||
|
d.KhoiLuongNganSach, d.KhoiLuongThiCong, d.DonGiaNganSach, d.ThanhTienNganSach,
|
||||||
|
d.Order, d.GhiChu,
|
||||||
|
d.Quotes.Select(q => new PurchaseEvaluationQuoteDto(
|
||||||
|
q.Id, q.PurchaseEvaluationDetailId, q.PurchaseEvaluationSupplierId,
|
||||||
|
q.BgVat, q.ChuaVat, q.ThanhTien, q.IsSelected, q.Note)).ToList()))
|
||||||
|
.ToList(),
|
||||||
|
e.Approvals
|
||||||
|
.OrderBy(a => a.ApprovedAt)
|
||||||
|
.Select(a => new PurchaseEvaluationApprovalDto(
|
||||||
|
a.Id, a.FromPhase, a.ToPhase, a.ApproverUserId,
|
||||||
|
a.ApproverUserId is Guid uid && users.TryGetValue(uid, out var an) ? an : null,
|
||||||
|
a.Decision, a.Comment, a.ApprovedAt))
|
||||||
|
.ToList(),
|
||||||
|
new PurchaseEvaluationWorkflowSummaryDto(
|
||||||
|
policy.Name, policy.Description,
|
||||||
|
policy.ActivePhases.ToList(),
|
||||||
|
policy.NextPhasesFrom(e.Phase).ToList()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DELETE ==========
|
||||||
|
|
||||||
|
public record DeletePurchaseEvaluationCommand(Guid Id) : IRequest;
|
||||||
|
|
||||||
|
public class DeletePurchaseEvaluationCommandHandler(
|
||||||
|
IApplicationDbContext db) : IRequestHandler<DeletePurchaseEvaluationCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(DeletePurchaseEvaluationCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
|
||||||
|
|
||||||
|
if (entity.Phase != PurchaseEvaluationPhase.DangSoanThao
|
||||||
|
&& entity.Phase != PurchaseEvaluationPhase.TuChoi)
|
||||||
|
throw new ConflictException("Chỉ xóa được phiếu ở phase Soạn thảo hoặc Từ chối.");
|
||||||
|
|
||||||
|
db.PurchaseEvaluations.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CHANGELOG list ==========
|
||||||
|
|
||||||
|
public record ListPurchaseEvaluationChangelogsQuery(Guid PurchaseEvaluationId, int Take = 200)
|
||||||
|
: IRequest<List<PurchaseEvaluationChangelogDto>>;
|
||||||
|
|
||||||
|
public class ListPurchaseEvaluationChangelogsQueryHandler(IApplicationDbContext db)
|
||||||
|
: IRequestHandler<ListPurchaseEvaluationChangelogsQuery, List<PurchaseEvaluationChangelogDto>>
|
||||||
|
{
|
||||||
|
public async Task<List<PurchaseEvaluationChangelogDto>> Handle(
|
||||||
|
ListPurchaseEvaluationChangelogsQuery request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await db.PurchaseEvaluationChangelogs.AsNoTracking()
|
||||||
|
.Where(c => c.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.Take(request.Take)
|
||||||
|
.Select(c => new PurchaseEvaluationChangelogDto(
|
||||||
|
c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
|
||||||
|
c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote,
|
||||||
|
c.CreatedAt))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||||
|
|
||||||
|
// Add NCC vào phiếu đánh giá (section II + E của form) — admin/drafter làm
|
||||||
|
// ở phase soạn thảo.
|
||||||
|
public record AddPurchaseEvaluationSupplierCommand(
|
||||||
|
Guid PurchaseEvaluationId,
|
||||||
|
Guid SupplierId,
|
||||||
|
string? DisplayName,
|
||||||
|
string? ContactName,
|
||||||
|
string? ContactEmail,
|
||||||
|
string? ContactPhone,
|
||||||
|
string? PaymentTermText,
|
||||||
|
string? Note) : IRequest<Guid>;
|
||||||
|
|
||||||
|
public class AddPurchaseEvaluationSupplierCommandValidator : AbstractValidator<AddPurchaseEvaluationSupplierCommand>
|
||||||
|
{
|
||||||
|
public AddPurchaseEvaluationSupplierCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PurchaseEvaluationId).NotEmpty();
|
||||||
|
RuleFor(x => x.SupplierId).NotEmpty();
|
||||||
|
RuleFor(x => x.DisplayName).MaximumLength(200);
|
||||||
|
RuleFor(x => x.ContactName).MaximumLength(200);
|
||||||
|
RuleFor(x => x.ContactEmail).MaximumLength(200);
|
||||||
|
RuleFor(x => x.ContactPhone).MaximumLength(50);
|
||||||
|
RuleFor(x => x.PaymentTermText).MaximumLength(200);
|
||||||
|
RuleFor(x => x.Note).MaximumLength(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AddPurchaseEvaluationSupplierCommandHandler(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
ICurrentUser currentUser) : IRequestHandler<AddPurchaseEvaluationSupplierCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(AddPurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var evaluation = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
|
||||||
|
|
||||||
|
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == request.SupplierId, ct)
|
||||||
|
?? throw new NotFoundException("Supplier", request.SupplierId);
|
||||||
|
|
||||||
|
var dup = await db.PurchaseEvaluationSuppliers
|
||||||
|
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == request.SupplierId, ct);
|
||||||
|
if (dup) throw new ConflictException("NCC đã được thêm vào phiếu.");
|
||||||
|
|
||||||
|
var maxOrder = await db.PurchaseEvaluationSuppliers
|
||||||
|
.Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId)
|
||||||
|
.Select(s => (int?)s.Order)
|
||||||
|
.MaxAsync(ct);
|
||||||
|
|
||||||
|
var entity = new PurchaseEvaluationSupplier
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
SupplierId = request.SupplierId,
|
||||||
|
DisplayName = request.DisplayName,
|
||||||
|
ContactName = request.ContactName,
|
||||||
|
ContactEmail = request.ContactEmail,
|
||||||
|
ContactPhone = request.ContactPhone,
|
||||||
|
PaymentTermText = request.PaymentTermText,
|
||||||
|
Note = request.Note,
|
||||||
|
Order = (maxOrder ?? 0) + 1,
|
||||||
|
};
|
||||||
|
db.PurchaseEvaluationSuppliers.Add(entity);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = request.PurchaseEvaluationId,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Supplier,
|
||||||
|
EntityId = entity.Id,
|
||||||
|
Action = ChangelogAction.Insert,
|
||||||
|
PhaseAtChange = evaluation.Phase,
|
||||||
|
UserId = currentUser.UserId,
|
||||||
|
Summary = $"Thêm NCC {request.DisplayName ?? "#" + request.SupplierId.ToString()[..8]}",
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdatePurchaseEvaluationSupplierCommand(
|
||||||
|
Guid PurchaseEvaluationId,
|
||||||
|
Guid SupplierRowId,
|
||||||
|
string? DisplayName,
|
||||||
|
string? ContactName,
|
||||||
|
string? ContactEmail,
|
||||||
|
string? ContactPhone,
|
||||||
|
string? PaymentTermText,
|
||||||
|
string? Note) : IRequest;
|
||||||
|
|
||||||
|
public class UpdatePurchaseEvaluationSupplierCommandHandler(
|
||||||
|
IApplicationDbContext db) : IRequestHandler<UpdatePurchaseEvaluationSupplierCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(UpdatePurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await db.PurchaseEvaluationSuppliers
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
|
||||||
|
|
||||||
|
row.DisplayName = request.DisplayName;
|
||||||
|
row.ContactName = request.ContactName;
|
||||||
|
row.ContactEmail = request.ContactEmail;
|
||||||
|
row.ContactPhone = request.ContactPhone;
|
||||||
|
row.PaymentTermText = request.PaymentTermText;
|
||||||
|
row.Note = request.Note;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RemovePurchaseEvaluationSupplierCommand(Guid PurchaseEvaluationId, Guid SupplierRowId) : IRequest;
|
||||||
|
|
||||||
|
public class RemovePurchaseEvaluationSupplierCommandHandler(
|
||||||
|
IApplicationDbContext db) : IRequestHandler<RemovePurchaseEvaluationSupplierCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(RemovePurchaseEvaluationSupplierCommand request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await db.PurchaseEvaluationSuppliers
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == request.SupplierRowId && s.PurchaseEvaluationId == request.PurchaseEvaluationId, ct)
|
||||||
|
?? throw new NotFoundException("PurchaseEvaluationSupplier", request.SupplierRowId);
|
||||||
|
|
||||||
|
// Block nếu có Quote row reference (FK Restrict) — user phải xóa quote trước
|
||||||
|
var hasQuotes = await db.PurchaseEvaluationQuotes.AnyAsync(q => q.PurchaseEvaluationSupplierId == row.Id, ct);
|
||||||
|
if (hasQuotes) throw new ConflictException("Không thể xóa NCC khi còn báo giá. Xóa báo giá trước.");
|
||||||
|
|
||||||
|
db.PurchaseEvaluationSuppliers.Remove(row);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Application.PurchaseEvaluations.Services;
|
||||||
|
|
||||||
|
public interface IPurchaseEvaluationWorkflowService
|
||||||
|
{
|
||||||
|
// Kiểm tra + thực hiện transition. Throw ForbiddenException nếu không hợp lệ.
|
||||||
|
// Tự tạo PurchaseEvaluationApproval + update Phase + SlaDeadline.
|
||||||
|
Task TransitionAsync(
|
||||||
|
PurchaseEvaluation evaluation,
|
||||||
|
PurchaseEvaluationPhase targetPhase,
|
||||||
|
Guid? actorUserId,
|
||||||
|
IReadOnlyList<string> actorRoles,
|
||||||
|
ApprovalDecision decision,
|
||||||
|
string? comment,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ using SolutionErp.Application.Common.Interfaces;
|
|||||||
using SolutionErp.Application.Contracts.Services;
|
using SolutionErp.Application.Contracts.Services;
|
||||||
using SolutionErp.Application.Forms.Services;
|
using SolutionErp.Application.Forms.Services;
|
||||||
using SolutionErp.Application.Notifications;
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Services;
|
||||||
using SolutionErp.Application.Reports.Services;
|
using SolutionErp.Application.Reports.Services;
|
||||||
using SolutionErp.Domain.Identity;
|
using SolutionErp.Domain.Identity;
|
||||||
using SolutionErp.Infrastructure.Forms;
|
using SolutionErp.Infrastructure.Forms;
|
||||||
@ -32,6 +33,7 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>();
|
services.AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>();
|
||||||
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
services.AddScoped<IContractCodeGenerator, ContractCodeGenerator>();
|
||||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||||
|
services.AddScoped<IPurchaseEvaluationWorkflowService, PurchaseEvaluationWorkflowService>();
|
||||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||||
services.AddScoped<INotificationService, NotificationService>();
|
services.AddScoped<INotificationService, NotificationService>();
|
||||||
services.AddScoped<IChangelogService, ChangelogService>();
|
services.AddScoped<IChangelogService, ChangelogService>();
|
||||||
|
|||||||
@ -0,0 +1,133 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SolutionErp.Application.Common.Exceptions;
|
||||||
|
using SolutionErp.Application.Common.Interfaces;
|
||||||
|
using SolutionErp.Application.Notifications;
|
||||||
|
using SolutionErp.Application.PurchaseEvaluations.Services;
|
||||||
|
using SolutionErp.Domain.Contracts;
|
||||||
|
using SolutionErp.Domain.Identity;
|
||||||
|
using SolutionErp.Domain.Notifications;
|
||||||
|
using SolutionErp.Domain.PurchaseEvaluations;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Services;
|
||||||
|
|
||||||
|
// Mirror ContractWorkflowService. Load policy từ pinned
|
||||||
|
// WorkflowDefinition (nếu có) hoặc fallback hardcoded registry.
|
||||||
|
public class PurchaseEvaluationWorkflowService(
|
||||||
|
IApplicationDbContext db,
|
||||||
|
IDateTime dateTime,
|
||||||
|
INotificationService notifications,
|
||||||
|
UserManager<User> userManager) : IPurchaseEvaluationWorkflowService
|
||||||
|
{
|
||||||
|
public TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase) =>
|
||||||
|
PurchaseEvaluationPolicies.NccOnly.PhaseSla.GetValueOrDefault(phase);
|
||||||
|
|
||||||
|
public async Task TransitionAsync(
|
||||||
|
PurchaseEvaluation evaluation,
|
||||||
|
PurchaseEvaluationPhase targetPhase,
|
||||||
|
Guid? actorUserId,
|
||||||
|
IReadOnlyList<string> actorRoles,
|
||||||
|
ApprovalDecision decision,
|
||||||
|
string? comment,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (evaluation.Phase == targetPhase)
|
||||||
|
throw new ConflictException("Phiếu đã ở phase đích.");
|
||||||
|
|
||||||
|
PurchaseEvaluationPolicy policy;
|
||||||
|
if (evaluation.WorkflowDefinitionId is Guid wfId)
|
||||||
|
{
|
||||||
|
var def = await db.PurchaseEvaluationWorkflowDefinitions.AsNoTracking()
|
||||||
|
.Include(d => d.Steps.OrderBy(s => s.Order))
|
||||||
|
.ThenInclude(s => s.Approvers)
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == wfId, ct);
|
||||||
|
policy = def is not null
|
||||||
|
? PurchaseEvaluationPolicyRegistry.FromDefinition(def)
|
||||||
|
: PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
policy = PurchaseEvaluationPolicyRegistry.ForEvaluation(evaluation);
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAdmin = actorRoles.Contains(AppRoles.Admin);
|
||||||
|
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
|
||||||
|
|
||||||
|
if (!isAdmin && !isSystem)
|
||||||
|
{
|
||||||
|
if (!policy.Transitions.TryGetValue((evaluation.Phase, targetPhase), out var allowedRoles))
|
||||||
|
throw new ForbiddenException(
|
||||||
|
$"Policy '{policy.Name}' không cho phép {evaluation.Phase} → {targetPhase}.");
|
||||||
|
|
||||||
|
if (!policy.IsTransitionAllowed(evaluation.Phase, targetPhase, actorRoles, actorUserId))
|
||||||
|
{
|
||||||
|
throw new ForbiddenException(
|
||||||
|
$"Role ({string.Join(",", actorRoles)}) không đủ quyền chuyển {evaluation.Phase} → {targetPhase}. " +
|
||||||
|
$"Policy '{policy.Name}' yêu cầu: {string.Join(",", allowedRoles)}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fromPhase = evaluation.Phase;
|
||||||
|
evaluation.SlaWarningSent = false;
|
||||||
|
evaluation.Phase = targetPhase;
|
||||||
|
|
||||||
|
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
|
||||||
|
evaluation.SlaDeadline = sla is null ? null : dateTime.UtcNow.Add(sla.Value);
|
||||||
|
|
||||||
|
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
|
FromPhase = fromPhase,
|
||||||
|
ToPhase = targetPhase,
|
||||||
|
ApproverUserId = actorUserId,
|
||||||
|
Decision = decision,
|
||||||
|
Comment = comment,
|
||||||
|
ApprovedAt = dateTime.UtcNow,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve actor name for changelog
|
||||||
|
string? actorName = null;
|
||||||
|
if (actorUserId is Guid uid)
|
||||||
|
{
|
||||||
|
var user = await userManager.FindByIdAsync(uid.ToString());
|
||||||
|
actorName = user?.FullName ?? user?.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
|
||||||
|
{
|
||||||
|
PurchaseEvaluationId = evaluation.Id,
|
||||||
|
EntityType = PurchaseEvaluationEntityType.Workflow,
|
||||||
|
Action = ChangelogAction.Transition,
|
||||||
|
PhaseAtChange = targetPhase,
|
||||||
|
UserId = actorUserId,
|
||||||
|
UserName = actorName ?? "Hệ thống",
|
||||||
|
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
|
||||||
|
ContextNote = comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify drafter
|
||||||
|
if (evaluation.DrafterUserId is Guid drafterId && drafterId != actorUserId)
|
||||||
|
{
|
||||||
|
var title = targetPhase switch
|
||||||
|
{
|
||||||
|
PurchaseEvaluationPhase.DaDuyet => $"Phiếu {evaluation.MaPhieu ?? evaluation.TenGoiThau} đã duyệt",
|
||||||
|
PurchaseEvaluationPhase.TuChoi => $"Phiếu {evaluation.TenGoiThau} bị từ chối",
|
||||||
|
_ => $"Phiếu {evaluation.TenGoiThau} chuyển phase mới",
|
||||||
|
};
|
||||||
|
var type = targetPhase switch
|
||||||
|
{
|
||||||
|
PurchaseEvaluationPhase.DaDuyet => NotificationType.ContractPublished,
|
||||||
|
PurchaseEvaluationPhase.TuChoi => NotificationType.ContractRejected,
|
||||||
|
_ => NotificationType.ContractPhaseTransition,
|
||||||
|
};
|
||||||
|
await notifications.NotifyAsync(
|
||||||
|
drafterId, type, title,
|
||||||
|
description: $"{fromPhase} → {targetPhase}" + (string.IsNullOrWhiteSpace(comment) ? "" : $" · {comment}"),
|
||||||
|
href: $"/purchase-evaluations/{evaluation.Id}",
|
||||||
|
refId: evaluation.Id,
|
||||||
|
ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user