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:
2026-07-14 10:20:38 +05:30
parent 4e84a15db7
commit 22f86451e3
90 changed files with 13496 additions and 19 deletions
@@ -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.15.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));
}