feat: Add new services and interfaces for GRN, Purchase Return, Reason Code, Reorder, Stock, and Transfer functionalities
- Implemented IGrnService for managing goods receipts including retrieval, creation, and confirmation. - Created IPurchaseReturnService for handling purchase return operations. - Added IReasonCodeService for managing reason codes with listing and creation capabilities. - Developed IReorderService for fetching reorder alerts and creating suggested requisitions. - Introduced IStockMutator for applying stock changes and posting ledger entries. - Established IStockService for stock inquiries, ledger retrieval, and valuation. - Created ITransferService for managing inter-warehouse transfers including dispatch and receiving operations. - Implemented PurchaseReturnService to handle purchase return logic and stock adjustments. - Developed ReasonCodeService for listing and creating reason codes. - Created ReorderService for fetching reorder alerts and generating requisitions. - Implemented FifoCostingService for FIFO cost-layer management and ledger writing. - Developed StockMutator for applying stock deltas and posting ledger entries. - Created StockService for stock inquiries and ledger management. - Implemented TransferService for managing inter-warehouse transfers with dispatch and receive functionalities.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only audit trail (FR-X-02; auditor role). Extends the documented §11 API —
|
||||
/// the audit trail is required (AR-01 compensating control) and read access is the
|
||||
/// only way to consume it.
|
||||
/// </summary>
|
||||
[Route("api/v1/audit-logs")]
|
||||
public sealed class AuditLogsController : ApiControllerBase
|
||||
{
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public AuditLogsController(IAuditService audit) => _audit = audit;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<AuditLogDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<AuditLogDto>>> List(
|
||||
[FromQuery] string? entityType, [FromQuery] long? entityId, [FromQuery] long? userId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _audit.ListLogsAsync(entityType, entityId, userId, from, to, query, ct));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
|
||||
[Route("api/v1/grns")]
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
|
||||
[HttpGet("{grnId:long}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<GrnDto>> GetById(long grnId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _grns.GetAsync(grnId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create a Draft GRN against a PO or direct. Cost is PO-derived for PO lines.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<GrnDto>> Create([FromBody] CreateGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _grns.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/grns/{dto.GrnId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
|
||||
[HttpPost("{grnId:long}/confirm")]
|
||||
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<GrnConfirmResultDto>> Confirm(
|
||||
long grnId, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken ct)
|
||||
=> Ok(await _grns.ConfirmAsync(grnId, idempotencyKey, ct));
|
||||
|
||||
/// <summary>Release or reject an inspection-hold line (FR-GRN-05).</summary>
|
||||
[HttpPost("{grnId:long}/lines/{grnLineId:long}/release")]
|
||||
[ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
||||
long grnId, long grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
||||
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only GL-ready journal stubs (FR-STK-13; consumed by the Accounting phase).
|
||||
/// Data only — no posting in Phase 1.
|
||||
/// </summary>
|
||||
[Route("api/v1/journal-entries")]
|
||||
public sealed class JournalEntriesController : ApiControllerBase
|
||||
{
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public JournalEntriesController(IAuditService audit) => _audit = audit;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<JournalEntryStubDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<JournalEntryStubDto>>> List(
|
||||
[FromQuery] string? sourceDocType, [FromQuery] long? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _audit.ListJournalAsync(sourceDocType, sourceDocId, query, ct));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
|
||||
[Route("api/v1/purchase-returns")]
|
||||
public sealed class PurchaseReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseReturnService _returns;
|
||||
|
||||
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
|
||||
|
||||
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<PurchaseReturnDto>> Create([FromBody] CreatePurchaseReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _returns.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/purchase-returns/{dto.ReturnId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Reference;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Reason-code reference endpoints (docs/11 §6).</summary>
|
||||
[Route("api/v1/reason-codes")]
|
||||
public sealed class ReasonCodesController : ApiControllerBase
|
||||
{
|
||||
private readonly IReasonCodeService _codes;
|
||||
|
||||
public ReasonCodesController(IReasonCodeService codes) => _codes = codes;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ReasonCodeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ReasonCodeDto>>> List(
|
||||
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _codes.ListAsync(context, query, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ReasonCodeDto>> Create([FromBody] CreateReasonCodeRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _codes.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/reason-codes/{dto.ReasonCodeId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
||||
[Route("api/v1/stock-adjustments")]
|
||||
public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
{
|
||||
private readonly IAdjustmentService _adjustments;
|
||||
|
||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
||||
|
||||
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<AdjustmentDto>> Create([FromBody] CreateAdjustmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _adjustments.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
|
||||
[Route("api/v1/stock")]
|
||||
public sealed class StockController : ApiControllerBase
|
||||
{
|
||||
private readonly IStockService _stock;
|
||||
private readonly IReorderService _reorder;
|
||||
|
||||
public StockController(IStockService stock, IReorderService reorder)
|
||||
{
|
||||
_stock = stock;
|
||||
_reorder = reorder;
|
||||
}
|
||||
|
||||
[HttpGet("on-hand")]
|
||||
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] long itemId, [FromQuery] long warehouseId, CancellationToken ct)
|
||||
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
|
||||
|
||||
[HttpGet("ledger")]
|
||||
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||
[FromQuery] long? itemId, [FromQuery] long? warehouseId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
|
||||
|
||||
[HttpGet("valuation")]
|
||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<StockValuationDto>> Valuation([FromQuery] long itemId, [FromQuery] long warehouseId, CancellationToken ct)
|
||||
=> Ok(await _stock.GetValuationAsync(itemId, warehouseId, ct));
|
||||
|
||||
/// <summary>Items at/below their reorder point (FR-STK-10), computed on read.</summary>
|
||||
[HttpGet("reorder-alerts")]
|
||||
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
|
||||
[FromQuery] long? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
|
||||
|
||||
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
|
||||
[HttpPost("reorder-alerts/{itemId:long}/requisition")]
|
||||
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<RequisitionDto>> SuggestRequisition(
|
||||
long itemId, [FromQuery] long warehouseId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _reorder.CreateSuggestedRequisitionAsync(itemId, warehouseId, ct);
|
||||
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
|
||||
[Route("api/v1/stock-counts")]
|
||||
public sealed class StockCountsController : ApiControllerBase
|
||||
{
|
||||
private readonly ICountService _counts;
|
||||
|
||||
public StockCountsController(ICountService counts) => _counts = counts;
|
||||
|
||||
[HttpGet("{countId:long}")]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CountDto>> GetById(long countId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _counts.GetAsync(countId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create a count with system quantities snapshotted (immutable).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<CountDto>> Create([FromBody] CreateCountRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _counts.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/stock-counts/{dto.CountId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Enter counted quantities; variance = counted − system.</summary>
|
||||
[HttpPut("{countId:long}/counts")]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<CountDto>> EnterCounts(long countId, [FromBody] EnterCountsRequest request, CancellationToken ct)
|
||||
=> Ok(await _counts.EnterCountsAsync(countId, request, ct));
|
||||
|
||||
/// <summary>Post: emit a variance adjustment and close the count.</summary>
|
||||
[HttpPost("{countId:long}/post")]
|
||||
[ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<CountPostResultDto>> Post(long countId, CancellationToken ct)
|
||||
=> Ok(await _counts.PostAsync(countId, ct));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
|
||||
[Route("api/v1/stock-transfers")]
|
||||
public sealed class StockTransfersController : ApiControllerBase
|
||||
{
|
||||
private readonly ITransferService _transfers;
|
||||
|
||||
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
|
||||
|
||||
[HttpGet("{transferId:long}")]
|
||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TransferDto>> GetById(long transferId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _transfers.GetAsync(transferId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TransferDto>> Create([FromBody] CreateTransferRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _transfers.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/stock-transfers/{dto.TransferId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Dispatch: consume source FIFO layers into in-transit. 409 STOCK_NEGATIVE_BLOCKED if short.</summary>
|
||||
[HttpPost("{transferId:long}/dispatch")]
|
||||
[ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<DispatchResultDto>> Dispatch(long transferId, CancellationToken ct)
|
||||
=> Ok(await _transfers.DispatchAsync(transferId, ct));
|
||||
|
||||
/// <summary>Receive: create the destination layer at the inherited cost (cost-preserving).</summary>
|
||||
[HttpPost("{transferId:long}/receive")]
|
||||
[ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ReceiveResultDto>> Receive(long transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct)
|
||||
=> Ok(await _transfers.ReceiveAsync(transferId, request, ct));
|
||||
}
|
||||
Reference in New Issue
Block a user