[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);
|
||||
Reference in New Issue
Block a user