[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:
pqhuy1987
2026-04-23 16:43:47 +07:00
parent 2c6f0cabfb
commit 4678d192e2
8 changed files with 1261 additions and 0 deletions

View File

@ -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);

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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>();

View File

@ -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);
}
}