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