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));
}
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Immutable audit trail entry (FR-X-02) — the compensating control for the deferred
/// RBAC (02-SECURITY AR-01/B.3). One row per create/update/delete of an audited
/// entity, capturing who / when / what changed (old→new in <see cref="ChangeSet"/>).
/// Written automatically by <c>ErpDbContext.SaveChangesAsync</c>. Append-only at the
/// app level; DB-role revocation of UPDATE/DELETE is deferred hardening (B.3).
/// Model: docs/10 Part C.7.
/// </summary>
public class AuditLog
{
public long AuditId { get; set; }
public long UserId { get; set; }
public string EntityType { get; set; } = string.Empty;
public long EntityId { get; set; }
public AuditAction Action { get; set; }
/// <summary>JSON change set: field→value (create/delete) or field→{old,new} (update).</summary>
public string ChangeSet { get; set; } = "{}";
public DateTime CreatedAt { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Batch/lot for a batch-tracked item (FR-GRN-04, FR-WH-03). Expiry drives FEFO
/// picking of perishables. Model: docs/10 Part C.4.
/// </summary>
public class Batch
{
public long BatchId { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public string BatchNo { get; set; } = string.Empty;
public DateOnly? ExpiryDate { get; set; }
}
+37
View File
@@ -0,0 +1,37 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Goods Receipt Note header (FR-GRN-01/02). Raised against a PO or direct
/// (<see cref="PoId"/> null). On confirm each line creates a FIFO layer and posts
/// an inbound ledger entry. Mutable aggregate with an <see cref="RowVersion"/>
/// concurrency token (docs/10 C.10). Model: docs/10 Part C.3.
/// </summary>
public class Grn
{
public long GrnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long? PoId { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
public long VendorId { get; set; }
public Vendor? Vendor { get; set; }
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public long CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? PostedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
public uint RowVersion { get; set; }
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
}
@@ -0,0 +1,37 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
/// </summary>
public class GrnLine
{
public long GrnLineId { get; set; }
public long GrnId { get; set; }
public Grn? Grn { get; set; }
public long? PoLineId { get; set; }
public PoLine? PoLine { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long UomId { get; set; }
public Uom? Uom { get; set; }
public long? BinId { get; set; }
public Bin? Bin { get; set; }
public long? BatchId { get; set; }
public Batch? Batch { get; set; }
public decimal Qty { get; set; }
public decimal UnitCost { get; set; }
public decimal ReceivedValue { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
}
@@ -0,0 +1,18 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// GL-ready journal entry emitted per stock movement (FR-STK-13) — data only, no
/// posting in Phase 1 (the Accounting module consumes these later). One row per
/// ledger entry, referencing the same source document polymorphically. Account
/// codes are Phase-1 placeholders until a chart of accounts exists.
/// Model: docs/10 Part C.7.
/// </summary>
public class JournalEntryStub
{
public long JournalId { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public long SourceDocId { get; set; }
public string DebitAccount { get; set; } = string.Empty;
public string CreditAccount { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Purchase return header (FR-PROC-08) — returns received goods to a vendor,
/// generating an outbound stock movement. Auto-posts with a mandatory reason code.
/// Model: docs/10 Part C.2.
/// </summary>
public class PurchaseReturn
{
public long ReturnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long VendorId { get; set; }
public Vendor? Vendor { get; set; }
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
public long CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Purchase-return line (FR-PROC-08) referencing the original GRN line for
/// traceability. <see cref="Qty"/> is in base UOM. Model: docs/10 Part C.2.
/// </summary>
public class PurchaseReturnLine
{
public long ReturnLineId { get; set; }
public long ReturnId { get; set; }
public PurchaseReturn? Return { get; set; }
public long? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
}
@@ -0,0 +1,15 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Configurable reason code for adjustments, returns and count variances
/// (FR-X-04). Model: docs/10 Part C.7.
/// </summary>
public class ReasonCode
{
public long ReasonCodeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public ReasonContext Context { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04).
/// Model: docs/10 Part C.4.
/// </summary>
public class Serial
{
public long SerialId { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public string SerialNo { get; set; } = string.Empty;
public string Status { get; set; } = "InStock";
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Stock adjustment header (FR-STK-07) — the highest-risk feature in the phase
/// (02-SECURITY C.5). Auto-posts in Phase 1 with a mandatory reason code and user
/// stamp. Mutable aggregate with an <see cref="RowVersion"/> token (docs/10 C.10).
/// Model: docs/10 Part C.6.
/// </summary>
public class StockAdjustment
{
public long AdjustmentId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public long CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
}
@@ -0,0 +1,23 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Adjustment line (FR-STK-07). <see cref="QtyDelta"/> is a signed base-UOM
/// quantity: negative consumes FIFO layers, positive creates a layer at last cost.
/// Model: docs/10 Part C.6.
/// </summary>
public class StockAdjustmentLine
{
public long AdjLineId { get; set; }
public long AdjustmentId { get; set; }
public StockAdjustment? Adjustment { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long? BinId { get; set; }
public long? BatchId { get; set; }
public long? SerialId { get; set; }
public decimal QtyDelta { get; set; }
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Cycle/full physical count header (FR-STK-08). System quantities are snapshotted
/// at creation and are immutable once opened (02-SECURITY C.7); posting emits a
/// variance adjustment. Mutable aggregate with an <see cref="RowVersion"/> token.
/// Model: docs/10 Part C.6.
/// </summary>
public class StockCount
{
public long CountId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
public long CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<StockCountLine> Lines { get; set; } = new List<StockCountLine>();
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Count line (FR-STK-08). <see cref="SystemQty"/> is the immutable snapshot;
/// <see cref="Variance"/> = counted system (in base UOM). Model: docs/10 Part C.6.
/// </summary>
public class StockCountLine
{
public long CountLineId { get; set; }
public long CountId { get; set; }
public StockCount? Count { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long? BinId { get; set; }
public decimal SystemQty { get; set; }
public decimal? CountedQty { get; set; }
public decimal? Variance { get; set; }
}
@@ -0,0 +1,33 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// FIFO cost layer — a quantity received at a specific unit cost, consumed
/// oldest-first (FR-STK-02). Keyed per item **per warehouse**; quantities and
/// <see cref="UnitCost"/> are in the item's base UOM. Answers valuation
/// ("what's on hand and at what cost"). Model: docs/10 Part C.5.
/// </summary>
public class StockLayer
{
public long LayerId { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long? BatchId { get; set; }
public Batch? Batch { get; set; }
public long? SerialId { get; set; }
public Serial? Serial { get; set; }
/// <summary>Originating GRN line — carries the inspection hold status for this stock.</summary>
public long? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public decimal QtyReceived { get; set; }
public decimal QtyRemaining { get; set; }
public decimal UnitCost { get; set; }
public DateTime ReceiptDate { get; set; }
}
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Immutable, append-only stock ledger (FR-STK-01, FR-X-05). One row per costed
/// movement; answers history ("what moved, when, by whom"). The originating
/// document is referenced polymorphically via
/// <see cref="SourceDocType"/>/<see cref="SourceDocId"/> (no hard FK per type) so
/// new transaction types write here without a schema change. Model: docs/10 Part C.5.
/// </summary>
public class StockLedger
{
public long LedgerId { get; set; }
public long ItemId { get; set; }
public long WarehouseId { get; set; }
public long? BinId { get; set; }
public long? BatchId { get; set; }
public long? SerialId { get; set; }
public long UserId { get; set; }
public Direction Direction { get; set; }
public decimal QtyBase { get; set; }
public decimal UnitCost { get; set; }
public decimal Value { get; set; }
public decimal RunningBalance { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public long SourceDocId { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Inter-warehouse stock transfer header (FR-STK-05/06). Dispatch consumes source
/// FIFO layers into in-transit; receive creates the destination layer at the
/// inherited cost (cost-preserving). Mutable aggregate with an
/// <see cref="RowVersion"/> token (docs/10 C.10). Model: docs/10 Part C.6.
/// </summary>
public class StockTransfer
{
public long TransferId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long SrcWarehouseId { get; set; }
public Warehouse? SrcWarehouse { get; set; }
public long DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public long CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<StockTransferLine> Lines { get; set; } = new List<StockTransferLine>();
}
@@ -0,0 +1,35 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Transfer line (FR-STK-05/06). <see cref="Qty"/> is in base UOM.
/// <para>
/// Deviation note: <see cref="UnitCost"/> and <see cref="QtyReceived"/> extend
/// docs/10 Part C.6's <c>STOCK_TRANSFER_LINE</c> to make the transfer
/// cost-preserving: at dispatch the value-weighted cost of the consumed source
/// layers is stored here, and receive recreates the destination layer at that cost
/// (supports partial receive via <see cref="QtyReceived"/>).
/// </para>
/// </summary>
public class StockTransferLine
{
public long TransferLineId { get; set; }
public long TransferId { get; set; }
public StockTransfer? Transfer { get; set; }
public long ItemId { get; set; }
public Item? Item { get; set; }
public long? SrcBinId { get; set; }
public long? DestBinId { get; set; }
public long? BatchId { get; set; }
public long? SerialId { get; set; }
public decimal Qty { get; set; }
/// <summary>Value-weighted unit cost of the consumed source layers (set at dispatch).</summary>
public decimal? UnitCost { get; set; }
/// <summary>Quantity already received at the destination (partial-receive support).</summary>
public decimal QtyReceived { get; set; }
}
@@ -0,0 +1,13 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so
/// <see cref="PendingApproval"/> is reserved for the future threshold-approval
/// workflow (FR-STK-07). Stored as a string.
/// </summary>
public enum AdjustmentStatus
{
Draft,
PendingApproval,
Posted
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Kind of mutation recorded in the audit trail (FR-X-02). Stored as a string.</summary>
public enum AuditAction
{
Create,
Update,
Delete
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Stock-count lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
public enum CountStatus
{
Draft,
Counted,
Posted
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Physical-count scope (docs/11 §8; FR-STK-08). Stored as a string.</summary>
public enum CountType
{
Cycle,
Full
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Stock-ledger movement direction (docs/11 §8). Stored as a string.</summary>
public enum Direction
{
In,
Out
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Goods-receipt-note lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
public enum GrnStatus
{
Draft,
Confirmed,
Closed
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Inspection-hold state of received stock (docs/11 §8; FR-GRN-05). <see cref="OnHold"/>
/// stock is on-hand but not issuable until released (FR-WH-07). Stored as a string.
/// </summary>
public enum HoldStatus
{
Available,
OnHold,
Rejected
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Where a reason code applies (FR-X-04; docs/10 §B.8.3). Stored as a string.</summary>
public enum ReasonContext
{
Adjustment,
Return,
Count
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Purchase-return lifecycle (docs/11 §3.4). Auto-posts in Phase 1. Stored as a string.</summary>
public enum ReturnStatus
{
Draft,
Posted
}
@@ -0,0 +1,10 @@
namespace ERPCore.Domain.Enums;
/// <summary>Stock-transfer lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
public enum TransferStatus
{
Draft,
InTransit,
Received,
Closed
}
+12
View File
@@ -0,0 +1,12 @@
using System.Text.Json;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Audit;
/// <summary>An audit-trail entry (FR-X-02). <c>ChangeSet</c> is the stored JSON, inlined.</summary>
public sealed record AuditLogDto(
long AuditId, long UserId, string EntityType, long EntityId, AuditAction Action, JsonElement ChangeSet, DateTime CreatedAt);
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
public sealed record JournalEntryStubDto(
long JournalId, string SourceDocType, long SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
+63
View File
@@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Grn;
// Responses (docs/11 §4) --------------------------------------------------------
public sealed record GrnLineDto(
long GrnLineId, long? PoLineId, long ItemId, long UomId, long? BinId,
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, long? BatchId);
public sealed record GrnDto(
long GrnId, string DocNo, long? PoId, long VendorId, long WarehouseId, GrnStatus Status,
long CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
public sealed record CreatedLayerDto(
long LayerId, long ItemId, long WarehouseId, long? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto(
long GrnId, GrnStatus Status, DateTime PostedAt,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<long> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(long GrnLineId, HoldStatus HoldStatus);
// Requests ----------------------------------------------------------------------
public sealed class BatchInput
{
[Required, StringLength(50)] public string BatchNo { get; set; } = string.Empty;
public DateOnly? ExpiryDate { get; set; }
}
public sealed class CreateGrnLineInput
{
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
public long? PoLineId { get; set; }
[Required] public long ItemId { get; set; }
[Required] public long UomId { get; set; }
public long? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>Used only for direct (no-PO) receipts; ignored when <see cref="PoLineId"/> is set.</summary>
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
}
public sealed class CreateGrnRequest
{
/// <summary>PO to receive against; null for a direct/emergency receipt (FR-GRN-02).</summary>
public long? PoId { get; set; }
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public long? VendorId { get; set; }
[Required] public long WarehouseId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
public sealed class ReleaseLineRequest
{
/// <summary><c>Release</c> makes the stock available; <c>Reject</c> removes it from on-hand.</summary>
[Required, RegularExpression("Release|Reject")]
public string Action { get; set; } = "Release";
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.4) ------------------------------------------------------
public sealed record PurchaseReturnLineDto(long ReturnLineId, long? GrnLineId, long ItemId, decimal Qty);
public sealed record PurchaseReturnDto(
long ReturnId, string DocNo, long VendorId, long WarehouseId, long ReasonCodeId, ReturnStatus Status,
long CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<long> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreatePurchaseReturnLineInput
{
/// <summary>Original GRN line, for traceability against the receipt.</summary>
public long? GrnLineId { get; set; }
[Required] public long ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreatePurchaseReturnRequest
{
[Required] public long VendorId { get; set; }
[Required] public long WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
public long? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreatePurchaseReturnLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Reference;
/// <summary>Reason code (docs/11 §6).</summary>
public sealed record ReasonCodeDto(long ReasonCodeId, string Code, string Description, ReasonContext Context);
public sealed class CreateReasonCodeRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Description { get; set; } = string.Empty;
[Required, EnumDataType(typeof(ReasonContext))] public ReasonContext Context { get; set; }
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.5) ------------------------------------------------------
public sealed record AdjustmentLineDto(long AdjLineId, long ItemId, long? BinId, long? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto(
long AdjustmentId, string DocNo, long WarehouseId, long ReasonCodeId, AdjustmentStatus Status,
long CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<long> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateAdjustmentLineInput
{
[Required] public long ItemId { get; set; }
public long? BinId { get; set; }
public long? BatchId { get; set; }
/// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary>
public decimal QtyDelta { get; set; }
}
public sealed class CreateAdjustmentRequest
{
[Required] public long WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
public long? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
}
+33
View File
@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.6) ------------------------------------------------------
public sealed record CountLineDto(long CountLineId, long ItemId, long? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
long CountId, string DocNo, long WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
public sealed record CountPostResultDto(long CountId, CountStatus Status, long? AdjustmentId, IReadOnlyList<long> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateCountRequest
{
[Required] public long WarehouseId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List<long> ItemIds { get; set; } = new();
}
public sealed class EnterCountLineInput
{
[Required] public long CountLineId { get; set; }
[Range(0, double.MaxValue)] public decimal CountedQty { get; set; }
}
public sealed class EnterCountsRequest
{
[Required, MinLength(1)] public List<EnterCountLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,6 @@
namespace ERPCore.Dtos.Stock;
/// <summary>An item at/below its reorder point (docs/11 §5.7; FR-STK-10). Computed on read.</summary>
public sealed record ReorderAlertDto(
long ItemId, long WarehouseId, decimal Available,
decimal ReorderPoint, decimal ReorderQty, decimal SuggestedRequisitionQty);
+22
View File
@@ -0,0 +1,22 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand onHold reserved inTransit(out).</summary>
public sealed record StockOnHandDto(
long ItemId, long WarehouseId, decimal OnHand, decimal Available,
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
public sealed record StockLedgerRowDto(
long LedgerId, long ItemId, long WarehouseId, long? BinId, long? BatchId, long? SerialId,
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
string SourceDocType, long SourceDocId, long UserId, DateTime CreatedAt);
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
public sealed record StockValuationLayerDto(long LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
public sealed record StockValuationDto(
long ItemId, long WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.4) ------------------------------------------------------
public sealed record TransferLineDto(
long TransferLineId, long ItemId, long? SrcBinId, long? DestBinId, long? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto(
long TransferId, string DocNo, long SrcWarehouseId, long DestWarehouseId,
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
public sealed record ConsumedLayerDto(long LayerId, decimal QtyConsumed, decimal UnitCost);
public sealed record DispatchResultDto(
long TransferId, TransferStatus Status, IReadOnlyList<ConsumedLayerDto> ConsumedLayers, IReadOnlyList<long> LedgerRefs);
public sealed record TransferCreatedLayerDto(long LayerId, long WarehouseId, decimal QtyReceived, decimal UnitCost);
public sealed record ReceiveResultDto(
long TransferId, TransferStatus Status, IReadOnlyList<TransferCreatedLayerDto> CreatedLayers, IReadOnlyList<long> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateTransferLineInput
{
[Required] public long ItemId { get; set; }
public long? SrcBinId { get; set; }
public long? DestBinId { get; set; }
public long? BatchId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateTransferRequest
{
[Required] public long SrcWarehouseId { get; set; }
[Required] public long DestWarehouseId { get; set; }
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
}
public sealed class ReceiveTransferLineInput
{
[Required] public long TransferLineId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class ReceiveTransferRequest
{
[Required, MinLength(1)] public List<ReceiveTransferLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,98 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace ERPCore.Infra.Persistence.Auditing;
/// <summary>A mutation captured before save, awaiting its (possibly generated) key.</summary>
public sealed record PendingAudit(EntityEntry Entry, string EntityType, AuditAction Action, string ChangeSet, long CapturedId, bool IsAdded);
/// <summary>
/// Builds audit-trail rows from the EF change tracker (FR-X-02). High-volume /
/// derived / self-referential tables are excluded (the stock ledger is itself the
/// stock movement audit). Change sets are captured <b>before</b> save so old→new is
/// accurate; generated keys for inserts are read <b>after</b> save.
/// </summary>
public static class AuditScribe
{
private static readonly HashSet<Type> Excluded =
[
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
typeof(StockLedger), typeof(StockLayer),
];
private static readonly JsonSerializerOptions Json = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static List<PendingAudit> Capture(ChangeTracker tracker)
{
var pending = new List<PendingAudit>();
foreach (var entry in tracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) continue;
if (Excluded.Contains(entry.Entity.GetType())) continue;
var action = entry.State switch
{
EntityState.Added => AuditAction.Create,
EntityState.Deleted => AuditAction.Delete,
_ => AuditAction.Update,
};
var changeSet = BuildChangeSet(entry, action);
if (action == AuditAction.Update && changeSet == "{}") continue; // only concurrency token touched, etc.
var isAdded = entry.State == EntityState.Added;
pending.Add(new PendingAudit(entry, entry.Entity.GetType().Name, action, changeSet, isAdded ? 0 : ReadKey(entry), isAdded));
}
return pending;
}
public static AuditLog ToLog(PendingAudit p, long userId, DateTime now) => new()
{
UserId = userId,
EntityType = p.EntityType,
EntityId = p.IsAdded ? ReadKey(p.Entry) : p.CapturedId,
Action = p.Action,
ChangeSet = p.ChangeSet,
CreatedAt = now,
};
private static long ReadKey(EntityEntry entry)
{
var pk = entry.Metadata.FindPrimaryKey();
if (pk is null || pk.Properties.Count != 1) return 0;
var value = entry.Property(pk.Properties[0].Name).CurrentValue;
return value is null ? 0 : Convert.ToInt64(value);
}
private static string BuildChangeSet(EntityEntry entry, AuditAction action)
{
var set = new Dictionary<string, object?>();
foreach (var p in entry.Properties)
{
if (p.Metadata.IsPrimaryKey()) continue;
if (p.Metadata.Name == nameof(Item.RowVersion)) continue;
switch (action)
{
case AuditAction.Create when p.CurrentValue is not null:
set[p.Metadata.Name] = p.CurrentValue;
break;
case AuditAction.Delete:
set[p.Metadata.Name] = p.OriginalValue;
break;
case AuditAction.Update when p.IsModified && !Equals(p.OriginalValue, p.CurrentValue):
set[p.Metadata.Name] = new Dictionary<string, object?> { ["old"] = p.OriginalValue, ["new"] = p.CurrentValue };
break;
}
}
return JsonSerializer.Serialize(set, Json);
}
}
@@ -0,0 +1,41 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
{
public void Configure(EntityTypeBuilder<AuditLog> builder)
{
builder.ToTable("audit_logs");
builder.HasKey(a => a.AuditId);
builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80);
builder.Property(a => a.Action).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb");
builder.Property(a => a.CreatedAt).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(a => new { a.EntityType, a.EntityId });
builder.HasIndex(a => a.CreatedAt);
builder.HasIndex(a => a.UserId);
}
}
public sealed class JournalEntryStubConfiguration : IEntityTypeConfiguration<JournalEntryStub>
{
public void Configure(EntityTypeBuilder<JournalEntryStub> builder)
{
builder.ToTable("journal_entry_stubs");
builder.HasKey(j => j.JournalId);
builder.Property(j => j.SourceDocType).IsRequired().HasMaxLength(10);
builder.Property(j => j.DebitAccount).IsRequired().HasMaxLength(20);
builder.Property(j => j.CreditAccount).IsRequired().HasMaxLength(20);
builder.Property(j => j.Amount).HasPrecision(18, 4);
builder.HasIndex(j => new { j.SourceDocType, j.SourceDocId });
}
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BatchConfiguration : IEntityTypeConfiguration<Batch>
{
public void Configure(EntityTypeBuilder<Batch> builder)
{
builder.ToTable("batches");
builder.HasKey(b => b.BatchId);
builder.Property(b => b.BatchNo).IsRequired().HasMaxLength(50);
builder.HasOne(b => b.Item)
.WithMany()
.HasForeignKey(b => b.ItemId)
.OnDelete(DeleteBehavior.Restrict);
// Batch number unique within an item.
builder.HasIndex(b => new { b.ItemId, b.BatchNo }).IsUnique();
}
}
public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
{
public void Configure(EntityTypeBuilder<Serial> builder)
{
builder.ToTable("serials");
builder.HasKey(s => s.SerialId);
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
builder.Property(s => s.Status).IsRequired().HasMaxLength(20);
builder.HasOne(s => s.Item)
.WithMany()
.HasForeignKey(s => s.ItemId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique();
}
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
{
public void Configure(EntityTypeBuilder<Grn> builder)
{
builder.ToTable("grns");
builder.HasKey(g => g.GrnId);
builder.Property(g => g.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(g => g.DocNo).IsUnique();
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(g => g.CreatedAt).IsRequired();
builder.Property(g => g.RowVersion).IsRowVersion();
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(g => g.Status);
builder.HasIndex(g => g.PoId);
}
}
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
{
public void Configure(EntityTypeBuilder<GrnLine> builder)
{
builder.ToTable("grn_lines");
builder.HasKey(l => l.GrnLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.ReceivedValue).HasPrecision(18, 4);
builder.Property(l => l.HoldStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration<PurchaseReturn>
{
public void Configure(EntityTypeBuilder<PurchaseReturn> builder)
{
builder.ToTable("purchase_returns");
builder.HasKey(r => r.ReturnId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.CreatedAt).IsRequired();
builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class PurchaseReturnLineConfiguration : IEntityTypeConfiguration<PurchaseReturnLine>
{
public void Configure(EntityTypeBuilder<PurchaseReturnLine> builder)
{
builder.ToTable("purchase_return_lines");
builder.HasKey(l => l.ReturnLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class ReasonCodeConfiguration : IEntityTypeConfiguration<ReasonCode>
{
public void Configure(EntityTypeBuilder<ReasonCode> builder)
{
builder.ToTable("reason_codes");
builder.HasKey(r => r.ReasonCodeId);
builder.Property(r => r.Code).IsRequired().HasMaxLength(20);
builder.Property(r => r.Description).IsRequired().HasMaxLength(200);
builder.Property(r => r.Context).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasIndex(r => new { r.Context, r.Code }).IsUnique();
}
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<StockAdjustment>
{
public void Configure(EntityTypeBuilder<StockAdjustment> builder)
{
builder.ToTable("stock_adjustments");
builder.HasKey(a => a.AdjustmentId);
builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(a => a.DocNo).IsUnique();
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
builder.Property(a => a.RowVersion).IsRowVersion();
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration<StockAdjustmentLine>
{
public void Configure(EntityTypeBuilder<StockAdjustmentLine> builder)
{
builder.ToTable("stock_adjustment_lines");
builder.HasKey(l => l.AdjLineId);
builder.Property(l => l.QtyDelta).HasPrecision(18, 4);
builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,60 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class StockLayerConfiguration : IEntityTypeConfiguration<StockLayer>
{
public void Configure(EntityTypeBuilder<StockLayer> builder)
{
builder.ToTable("stock_layers");
builder.HasKey(l => l.LayerId);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
builder.Property(l => l.QtyRemaining).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.ReceiptDate).IsRequired();
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Warehouse).WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict);
// FIFO consumption orders by receipt date then layer id, scoped per item+warehouse.
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.ReceiptDate, l.LayerId });
builder.HasIndex(l => l.GrnLineId);
}
}
public sealed class StockLedgerConfiguration : IEntityTypeConfiguration<StockLedger>
{
public void Configure(EntityTypeBuilder<StockLedger> builder)
{
// Append-only (FR-STK-01/FR-X-05): the app never updates/deletes ledger rows.
// DB-level revocation of UPDATE/DELETE is a deferred hardening step (02-SECURITY B.3).
builder.ToTable("stock_ledger");
builder.HasKey(l => l.LedgerId);
builder.Property(l => l.Direction).HasConversion<string>().HasMaxLength(5).IsRequired();
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.Value).HasPrecision(18, 4);
builder.Property(l => l.RunningBalance).HasPrecision(18, 4);
builder.Property(l => l.SourceDocType).IsRequired().HasMaxLength(10);
builder.Property(l => l.CreatedAt).IsRequired();
builder.HasOne<Item>().WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Warehouse>().WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
// Time-series query paths (NFR-06) and polymorphic source tracing.
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.LedgerId });
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.CreatedAt });
builder.HasIndex(l => new { l.SourceDocType, l.SourceDocId });
}
}
@@ -0,0 +1,44 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCount>
{
public void Configure(EntityTypeBuilder<StockCount> builder)
{
builder.ToTable("stock_counts");
builder.HasKey(c => c.CountId);
builder.Property(c => c.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(c => c.DocNo).IsUnique();
builder.Property(c => c.CountType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(c => c.Status);
}
}
public sealed class StockCountLineConfiguration : IEntityTypeConfiguration<StockCountLine>
{
public void Configure(EntityTypeBuilder<StockCountLine> builder)
{
builder.ToTable("stock_count_lines");
builder.HasKey(l => l.CountLineId);
builder.Property(l => l.SystemQty).HasPrecision(18, 4);
builder.Property(l => l.CountedQty).HasPrecision(18, 4);
builder.Property(l => l.Variance).HasPrecision(18, 4);
builder.HasOne(l => l.Count).WithMany(c => c.Lines).HasForeignKey(l => l.CountId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,47 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class StockTransferConfiguration : IEntityTypeConfiguration<StockTransfer>
{
public void Configure(EntityTypeBuilder<StockTransfer> builder)
{
builder.ToTable("stock_transfers");
builder.HasKey(t => t.TransferId);
builder.Property(t => t.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(t => t.DocNo).IsUnique();
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(t => t.Status);
}
}
public sealed class StockTransferLineConfiguration : IEntityTypeConfiguration<StockTransferLine>
{
public void Configure(EntityTypeBuilder<StockTransferLine> builder)
{
builder.ToTable("stock_transfer_lines");
builder.HasKey(l => l.TransferLineId);
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
builder.HasOne(l => l.Transfer).WithMany(t => t.Lines).HasForeignKey(l => l.TransferId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.SrcBinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.DestBinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,45 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
/// <summary>
/// Idempotent startup seeding of configurable reference data (docs/10 §B.8.3).
/// Reason codes are seeded at runtime (not via <c>HasData</c>) so the identity
/// sequence advances normally and later admin <c>POST /reason-codes</c> calls
/// cannot collide with seeded ids.
/// </summary>
public static class DataSeeder
{
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
[
("DMG", "Damage", ReasonContext.Adjustment),
("THEFT", "Theft/Loss", ReasonContext.Adjustment),
("VAR", "Count Variance", ReasonContext.Adjustment),
("EXP", "Expiry Write-off", ReasonContext.Adjustment),
("SYS", "System Correction", ReasonContext.Adjustment),
("DEF", "Defective", ReasonContext.Return),
("WRONG", "Wrong Item", ReasonContext.Return),
("OVER", "Over-supply", ReasonContext.Return),
("QREJ", "Quality Reject", ReasonContext.Return),
];
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
{
var existing = await db.ReasonCodes
.Select(r => new { r.Context, r.Code })
.ToListAsync(ct);
var have = existing.Select(x => (x.Context, x.Code)).ToHashSet();
var toAdd = StandardReasonCodes
.Where(r => !have.Contains((r.Context, r.Code)))
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
.ToList();
if (toAdd.Count == 0) return;
db.ReasonCodes.AddRange(toAdd);
await db.SaveChangesAsync(ct);
}
}
@@ -1,4 +1,6 @@
using ERPCore.Domain.Entities;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Persistence.Auditing;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
@@ -8,11 +10,15 @@ namespace ERPCore.Infra.Persistence;
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
/// Every save writes an immutable audit trail (FR-X-02) via <see cref="AuditScribe"/>.
/// </summary>
public class ErpDbContext : DbContext
{
public ErpDbContext(DbContextOptions<ErpDbContext> options) : base(options)
private readonly ICurrentUser _currentUser;
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
{
_currentUser = currentUser;
}
// --- Master Data (docs/10 Part C.1) ---
@@ -39,6 +45,37 @@ public class ErpDbContext : DbContext
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
public DbSet<PoLine> PoLines => Set<PoLine>();
// --- Goods Receipt (docs/10 Part C.3) ---
public DbSet<Grn> Grns => Set<Grn>();
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
// --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>();
public DbSet<Serial> Serials => Set<Serial>();
// --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) ---
public DbSet<StockLayer> StockLayers => Set<StockLayer>();
public DbSet<StockLedger> StockLedger => Set<StockLedger>();
// --- Stock transactions (docs/10 Part C.6) ---
public DbSet<StockTransfer> StockTransfers => Set<StockTransfer>();
public DbSet<StockTransferLine> StockTransferLines => Set<StockTransferLine>();
public DbSet<StockAdjustment> StockAdjustments => Set<StockAdjustment>();
public DbSet<StockAdjustmentLine> StockAdjustmentLines => Set<StockAdjustmentLine>();
public DbSet<StockCount> StockCounts => Set<StockCount>();
public DbSet<StockCountLine> StockCountLines => Set<StockCountLine>();
// --- Purchase returns (docs/10 Part C.2) ---
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
public DbSet<PurchaseReturnLine> PurchaseReturnLines => Set<PurchaseReturnLine>();
// --- Reference data (docs/10 Part C.7) ---
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -47,4 +84,39 @@ public class ErpDbContext : DbContext
// (Infra/Persistence/Configurations/*).
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
}
// Audit trail (FR-X-02): capture mutations before save (accurate old→new), then
// write the log rows once inserts have their generated keys. A second base save
// persists the logs without re-auditing them.
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
{
var pending = AuditScribe.Capture(ChangeTracker);
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
if (pending.Count > 0)
{
WriteAuditLogs(pending);
await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
}
return result;
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
var pending = AuditScribe.Capture(ChangeTracker);
var result = base.SaveChanges(acceptAllChangesOnSuccess);
if (pending.Count > 0)
{
WriteAuditLogs(pending);
base.SaveChanges(acceptAllChangesOnSuccess);
}
return result;
}
private void WriteAuditLogs(IReadOnlyList<PendingAudit> pending)
{
var now = DateTime.UtcNow;
var userId = _currentUser.AuditUserId;
foreach (var p in pending)
AuditLogs.Add(AuditScribe.ToLog(p, userId, now));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,434 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStockAndGrn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "batches",
columns: table => new
{
BatchId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<long>(type: "bigint", nullable: false),
BatchNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
ExpiryDate = table.Column<DateOnly>(type: "date", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_batches", x => x.BatchId);
table.ForeignKey(
name: "FK_batches_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "grns",
columns: table => new
{
GrnId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
PoId = table.Column<long>(type: "bigint", nullable: true),
VendorId = table.Column<long>(type: "bigint", nullable: false),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
PostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grns", x => x.GrnId);
table.ForeignKey(
name: "FK_grns_purchase_orders_PoId",
column: x => x.PoId,
principalTable: "purchase_orders",
principalColumn: "PoId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grns_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grns_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grns_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "serials",
columns: table => new
{
SerialId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<long>(type: "bigint", nullable: false),
SerialNo = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_serials", x => x.SerialId);
table.ForeignKey(
name: "FK_serials_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "grn_lines",
columns: table => new
{
GrnLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GrnId = table.Column<long>(type: "bigint", nullable: false),
PoLineId = table.Column<long>(type: "bigint", nullable: true),
ItemId = table.Column<long>(type: "bigint", nullable: false),
UomId = table.Column<long>(type: "bigint", nullable: false),
BinId = table.Column<long>(type: "bigint", nullable: true),
BatchId = table.Column<long>(type: "bigint", nullable: true),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false),
ReceivedValue = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
HoldStatus = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_lines", x => x.GrnLineId);
table.ForeignKey(
name: "FK_grn_lines_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_lines_bins_BinId",
column: x => x.BinId,
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_lines_grns_GrnId",
column: x => x.GrnId,
principalTable: "grns",
principalColumn: "GrnId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_grn_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_lines_po_lines_PoLineId",
column: x => x.PoLineId,
principalTable: "po_lines",
principalColumn: "PoLineId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_grn_lines_uoms_UomId",
column: x => x.UomId,
principalTable: "uoms",
principalColumn: "UomId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "stock_ledger",
columns: table => new
{
LedgerId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<long>(type: "bigint", nullable: false),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
BinId = table.Column<long>(type: "bigint", nullable: true),
BatchId = table.Column<long>(type: "bigint", nullable: true),
SerialId = table.Column<long>(type: "bigint", nullable: true),
UserId = table.Column<long>(type: "bigint", nullable: false),
Direction = table.Column<string>(type: "character varying(5)", maxLength: 5, nullable: false),
QtyBase = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false),
Value = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
RunningBalance = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
SourceDocType = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
SourceDocId = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_ledger", x => x.LedgerId);
table.ForeignKey(
name: "FK_stock_ledger_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_ledger_bins_BinId",
column: x => x.BinId,
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_ledger_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_ledger_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_ledger_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_ledger_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "stock_layers",
columns: table => new
{
LayerId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ItemId = table.Column<long>(type: "bigint", nullable: false),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
BatchId = table.Column<long>(type: "bigint", nullable: true),
SerialId = table.Column<long>(type: "bigint", nullable: true),
GrnLineId = table.Column<long>(type: "bigint", nullable: true),
QtyReceived = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
QtyRemaining = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false),
ReceiptDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_layers", x => x.LayerId);
table.ForeignKey(
name: "FK_stock_layers_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_layers_grn_lines_GrnLineId",
column: x => x.GrnLineId,
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_layers_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_layers_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_layers_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_batches_ItemId_BatchNo",
table: "batches",
columns: new[] { "ItemId", "BatchNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_grn_lines_BatchId",
table: "grn_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_grn_lines_BinId",
table: "grn_lines",
column: "BinId");
migrationBuilder.CreateIndex(
name: "IX_grn_lines_GrnId",
table: "grn_lines",
column: "GrnId");
migrationBuilder.CreateIndex(
name: "IX_grn_lines_ItemId",
table: "grn_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_grn_lines_PoLineId",
table: "grn_lines",
column: "PoLineId");
migrationBuilder.CreateIndex(
name: "IX_grn_lines_UomId",
table: "grn_lines",
column: "UomId");
migrationBuilder.CreateIndex(
name: "IX_grns_CreatedBy",
table: "grns",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_grns_DocNo",
table: "grns",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_grns_PoId",
table: "grns",
column: "PoId");
migrationBuilder.CreateIndex(
name: "IX_grns_Status",
table: "grns",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_grns_VendorId",
table: "grns",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_grns_WarehouseId",
table: "grns",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_serials_ItemId_SerialNo",
table: "serials",
columns: new[] { "ItemId", "SerialNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_stock_layers_BatchId",
table: "stock_layers",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_stock_layers_GrnLineId",
table: "stock_layers",
column: "GrnLineId");
migrationBuilder.CreateIndex(
name: "IX_stock_layers_ItemId_WarehouseId_ReceiptDate_LayerId",
table: "stock_layers",
columns: new[] { "ItemId", "WarehouseId", "ReceiptDate", "LayerId" });
migrationBuilder.CreateIndex(
name: "IX_stock_layers_SerialId",
table: "stock_layers",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_stock_layers_WarehouseId",
table: "stock_layers",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_stock_ledger_BatchId",
table: "stock_ledger",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_stock_ledger_BinId",
table: "stock_ledger",
column: "BinId");
migrationBuilder.CreateIndex(
name: "IX_stock_ledger_ItemId_WarehouseId_CreatedAt",
table: "stock_ledger",
columns: new[] { "ItemId", "WarehouseId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_stock_ledger_ItemId_WarehouseId_LedgerId",
table: "stock_ledger",
columns: new[] { "ItemId", "WarehouseId", "LedgerId" });
migrationBuilder.CreateIndex(
name: "IX_stock_ledger_SerialId",
table: "stock_ledger",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_stock_ledger_SourceDocType_SourceDocId",
table: "stock_ledger",
columns: new[] { "SourceDocType", "SourceDocId" });
migrationBuilder.CreateIndex(
name: "IX_stock_ledger_UserId",
table: "stock_ledger",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_stock_ledger_WarehouseId",
table: "stock_ledger",
column: "WarehouseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "stock_layers");
migrationBuilder.DropTable(
name: "stock_ledger");
migrationBuilder.DropTable(
name: "grn_lines");
migrationBuilder.DropTable(
name: "serials");
migrationBuilder.DropTable(
name: "batches");
migrationBuilder.DropTable(
name: "grns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStockTransactions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "reason_codes",
columns: table => new
{
ReasonCodeId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Context = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_reason_codes", x => x.ReasonCodeId);
});
migrationBuilder.CreateTable(
name: "stock_transfers",
columns: table => new
{
TransferId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
SrcWarehouseId = table.Column<long>(type: "bigint", nullable: false),
DestWarehouseId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_transfers", x => x.TransferId);
table.ForeignKey(
name: "FK_stock_transfers_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfers_warehouses_DestWarehouseId",
column: x => x.DestWarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfers_warehouses_SrcWarehouseId",
column: x => x.SrcWarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "stock_adjustments",
columns: table => new
{
AdjustmentId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
ReasonCodeId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_adjustments", x => x.AdjustmentId);
table.ForeignKey(
name: "FK_stock_adjustments_reason_codes_ReasonCodeId",
column: x => x.ReasonCodeId,
principalTable: "reason_codes",
principalColumn: "ReasonCodeId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_adjustments_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_adjustments_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "stock_transfer_lines",
columns: table => new
{
TransferLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TransferId = table.Column<long>(type: "bigint", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: false),
SrcBinId = table.Column<long>(type: "bigint", nullable: true),
DestBinId = table.Column<long>(type: "bigint", nullable: true),
BatchId = table.Column<long>(type: "bigint", nullable: true),
SerialId = table.Column<long>(type: "bigint", nullable: true),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: true),
QtyReceived = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_transfer_lines", x => x.TransferLineId);
table.ForeignKey(
name: "FK_stock_transfer_lines_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_lines_bins_DestBinId",
column: x => x.DestBinId,
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_lines_bins_SrcBinId",
column: x => x.SrcBinId,
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_lines_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_lines_stock_transfers_TransferId",
column: x => x.TransferId,
principalTable: "stock_transfers",
principalColumn: "TransferId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "stock_adjustment_lines",
columns: table => new
{
AdjLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AdjustmentId = table.Column<long>(type: "bigint", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: false),
BinId = table.Column<long>(type: "bigint", nullable: true),
BatchId = table.Column<long>(type: "bigint", nullable: true),
SerialId = table.Column<long>(type: "bigint", nullable: true),
QtyDelta = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_adjustment_lines", x => x.AdjLineId);
table.ForeignKey(
name: "FK_stock_adjustment_lines_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_adjustment_lines_bins_BinId",
column: x => x.BinId,
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_adjustment_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_adjustment_lines_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_adjustment_lines_stock_adjustments_AdjustmentId",
column: x => x.AdjustmentId,
principalTable: "stock_adjustments",
principalColumn: "AdjustmentId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_reason_codes_Context_Code",
table: "reason_codes",
columns: new[] { "Context", "Code" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_stock_adjustment_lines_AdjustmentId",
table: "stock_adjustment_lines",
column: "AdjustmentId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustment_lines_BatchId",
table: "stock_adjustment_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustment_lines_BinId",
table: "stock_adjustment_lines",
column: "BinId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustment_lines_ItemId",
table: "stock_adjustment_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustment_lines_SerialId",
table: "stock_adjustment_lines",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_CreatedBy",
table: "stock_adjustments",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_DocNo",
table: "stock_adjustments",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_ReasonCodeId",
table: "stock_adjustments",
column: "ReasonCodeId");
migrationBuilder.CreateIndex(
name: "IX_stock_adjustments_WarehouseId",
table: "stock_adjustments",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_lines_BatchId",
table: "stock_transfer_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_lines_DestBinId",
table: "stock_transfer_lines",
column: "DestBinId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_lines_ItemId",
table: "stock_transfer_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_lines_SerialId",
table: "stock_transfer_lines",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_lines_SrcBinId",
table: "stock_transfer_lines",
column: "SrcBinId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_lines_TransferId",
table: "stock_transfer_lines",
column: "TransferId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_CreatedBy",
table: "stock_transfers",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_DestWarehouseId",
table: "stock_transfers",
column: "DestWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_DocNo",
table: "stock_transfers",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_SrcWarehouseId",
table: "stock_transfers",
column: "SrcWarehouseId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfers_Status",
table: "stock_transfers",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "stock_adjustment_lines");
migrationBuilder.DropTable(
name: "stock_transfer_lines");
migrationBuilder.DropTable(
name: "stock_adjustments");
migrationBuilder.DropTable(
name: "stock_transfers");
migrationBuilder.DropTable(
name: "reason_codes");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,253 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCountsAndReturns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "purchase_returns",
columns: table => new
{
ReturnId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
VendorId = table.Column<long>(type: "bigint", nullable: false),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
ReasonCodeId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_returns", x => x.ReturnId);
table.ForeignKey(
name: "FK_purchase_returns_reason_codes_ReasonCodeId",
column: x => x.ReasonCodeId,
principalTable: "reason_codes",
principalColumn: "ReasonCodeId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_returns_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_returns_vendors_VendorId",
column: x => x.VendorId,
principalTable: "vendors",
principalColumn: "VendorId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_returns_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "stock_counts",
columns: table => new
{
CountId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
CountType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_counts", x => x.CountId);
table.ForeignKey(
name: "FK_stock_counts_users_CreatedBy",
column: x => x.CreatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_counts_warehouses_WarehouseId",
column: x => x.WarehouseId,
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "purchase_return_lines",
columns: table => new
{
ReturnLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ReturnId = table.Column<long>(type: "bigint", nullable: false),
GrnLineId = table.Column<long>(type: "bigint", nullable: true),
ItemId = table.Column<long>(type: "bigint", nullable: false),
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_purchase_return_lines", x => x.ReturnLineId);
table.ForeignKey(
name: "FK_purchase_return_lines_grn_lines_GrnLineId",
column: x => x.GrnLineId,
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_return_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_purchase_return_lines_purchase_returns_ReturnId",
column: x => x.ReturnId,
principalTable: "purchase_returns",
principalColumn: "ReturnId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "stock_count_lines",
columns: table => new
{
CountLineId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CountId = table.Column<long>(type: "bigint", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: false),
BinId = table.Column<long>(type: "bigint", nullable: true),
SystemQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
CountedQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true),
Variance = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_count_lines", x => x.CountLineId);
table.ForeignKey(
name: "FK_stock_count_lines_bins_BinId",
column: x => x.BinId,
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_count_lines_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_count_lines_stock_counts_CountId",
column: x => x.CountId,
principalTable: "stock_counts",
principalColumn: "CountId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_purchase_return_lines_GrnLineId",
table: "purchase_return_lines",
column: "GrnLineId");
migrationBuilder.CreateIndex(
name: "IX_purchase_return_lines_ItemId",
table: "purchase_return_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_purchase_return_lines_ReturnId",
table: "purchase_return_lines",
column: "ReturnId");
migrationBuilder.CreateIndex(
name: "IX_purchase_returns_CreatedBy",
table: "purchase_returns",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_purchase_returns_DocNo",
table: "purchase_returns",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_purchase_returns_ReasonCodeId",
table: "purchase_returns",
column: "ReasonCodeId");
migrationBuilder.CreateIndex(
name: "IX_purchase_returns_VendorId",
table: "purchase_returns",
column: "VendorId");
migrationBuilder.CreateIndex(
name: "IX_purchase_returns_WarehouseId",
table: "purchase_returns",
column: "WarehouseId");
migrationBuilder.CreateIndex(
name: "IX_stock_count_lines_BinId",
table: "stock_count_lines",
column: "BinId");
migrationBuilder.CreateIndex(
name: "IX_stock_count_lines_CountId",
table: "stock_count_lines",
column: "CountId");
migrationBuilder.CreateIndex(
name: "IX_stock_count_lines_ItemId",
table: "stock_count_lines",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_stock_counts_CreatedBy",
table: "stock_counts",
column: "CreatedBy");
migrationBuilder.CreateIndex(
name: "IX_stock_counts_DocNo",
table: "stock_counts",
column: "DocNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_stock_counts_Status",
table: "stock_counts",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_stock_counts_WarehouseId",
table: "stock_counts",
column: "WarehouseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "purchase_return_lines");
migrationBuilder.DropTable(
name: "stock_count_lines");
migrationBuilder.DropTable(
name: "purchase_returns");
migrationBuilder.DropTable(
name: "stock_counts");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAuditAndJournal : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "audit_logs",
columns: table => new
{
AuditId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<long>(type: "bigint", nullable: false),
EntityType = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
EntityId = table.Column<long>(type: "bigint", nullable: false),
Action = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
ChangeSet = table.Column<string>(type: "jsonb", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_audit_logs", x => x.AuditId);
table.ForeignKey(
name: "FK_audit_logs_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "journal_entry_stubs",
columns: table => new
{
JournalId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SourceDocType = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
SourceDocId = table.Column<long>(type: "bigint", nullable: false),
DebitAccount = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CreditAccount = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_journal_entry_stubs", x => x.JournalId);
});
migrationBuilder.CreateIndex(
name: "IX_audit_logs_CreatedAt",
table: "audit_logs",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_audit_logs_EntityType_EntityId",
table: "audit_logs",
columns: new[] { "EntityType", "EntityId" });
migrationBuilder.CreateIndex(
name: "IX_audit_logs_UserId",
table: "audit_logs",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_journal_entry_stubs_SourceDocType_SourceDocId",
table: "journal_entry_stubs",
columns: new[] { "SourceDocType", "SourceDocId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "audit_logs");
migrationBuilder.DropTable(
name: "journal_entry_stubs");
}
}
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -6,6 +6,7 @@ using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi;
@@ -54,6 +55,23 @@ builder.Services.AddScoped<IRequisitionService, RequisitionService>();
builder.Services.AddScoped<IRfqService, RfqService>();
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
// Stock core + goods receipt (docs/11 §45)
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
builder.Services.AddScoped<IStockService, StockService>();
builder.Services.AddScoped<IGrnService, GrnService>();
// Stock transactions + reference data (docs/11 §56)
builder.Services.AddScoped<IStockMutator, StockMutator>();
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
builder.Services.AddScoped<IAdjustmentService, AdjustmentService>();
builder.Services.AddScoped<ITransferService, TransferService>();
builder.Services.AddScoped<ICountService, CountService>();
builder.Services.AddScoped<IReorderService, ReorderService>();
builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
builder.Services.AddScoped<IAuditService, AuditService>();
// Health checks (EF Core DB)
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
@@ -63,6 +81,13 @@ builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title =
var app = builder.Build();
// Seed configurable reference data (reason codes) idempotently at startup.
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
await DataSeeder.SeedAsync(db);
}
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment())
@@ -0,0 +1,104 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a
/// decrease, layer create on an increase) + ledger posting is delegated to
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05).
/// </summary>
public sealed class AdjustmentService : IAdjustmentService
{
private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for an adjustment.", 400);
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Adjustment)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
foreach (var line in request.Lines)
{
if (line.QtyDelta == 0)
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422);
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
}
var now = DateTime.UtcNow;
var deltas = request.Lines
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList();
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var entity = new StockAdjustment
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select(l => new StockAdjustmentLine
{
ItemId = l.ItemId,
BinId = l.BinId,
BatchId = l.BatchId,
QtyDelta = l.QtyDelta
}).ToList()
};
await _adjustments.AddAsync(entity, token);
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
return (entity, refs);
}, ct);
return new AdjustmentDto(
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
adjustment.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList());
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class AuditService : IAuditService
{
private readonly IRepository<AuditLog> _logs;
private readonly IRepository<JournalEntryStub> _journal;
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal)
{
_logs = logs;
_journal = journal;
}
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
string? entityType, long? entityId, long? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
{
var q = _logs.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType);
if (entityId is not null) q = q.Where(l => l.EntityId == entityId);
if (userId is not null) q = q.Where(l => l.UserId == userId);
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.AuditId)
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
var dtos = rows.Select(l => new AuditLogDto(
l.AuditId, l.UserId, l.EntityType, l.EntityId, l.Action,
JsonSerializer.Deserialize<JsonElement>(l.ChangeSet), l.CreatedAt)).ToList();
return PagedResponse<AuditLogDto>.Create(dtos, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
string? sourceDocType, long? sourceDocId, PageQuery query, CancellationToken ct = default)
{
var q = _journal.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(j => j.SourceDocType == sourceDocType);
if (sourceDocId is not null) q = q.Where(j => j.SourceDocId == sourceDocId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(j => j.JournalId)
.Skip(query.Skip).Take(query.PageSize)
.Select(j => new JournalEntryStubDto(j.JournalId, j.SourceDocType, j.SourceDocId, j.DebitAccount, j.CreditAccount, j.Amount))
.ToListAsync(ct);
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
}
}
+181
View File
@@ -0,0 +1,181 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
/// and closes the count — all in one UoW transaction.
/// </summary>
public sealed class CountService : ICountService
{
private const string VarianceReasonCode = "VAR";
private readonly IRepository<StockCount> _counts;
private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IFifoCostingService _fifo;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_counts = counts;
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_fifo = fifo;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<CountDto?> GetAsync(long countId, CancellationToken ct = default)
{
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
.FirstOrDefaultAsync(c => c.CountId == countId, ct);
return count is null ? null : Map(count);
}
public async Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default)
{
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var itemIds = request.ItemIds.Distinct().ToList();
foreach (var id in itemIds)
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {id} does not exist.", 422);
// Snapshot system quantities now (immutable once opened).
var lines = new List<StockCountLine>();
foreach (var id in itemIds)
{
var systemQty = await _fifo.GetOnHandAsync(id, request.WarehouseId, ct);
lines.Add(new StockCountLine { ItemId = id, SystemQty = systemQty });
}
var count = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Count, token);
var entity = new StockCount
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
CountType = request.CountType,
Status = CountStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
Lines = lines
};
await _counts.AddAsync(entity, token);
return entity;
}, ct);
return Map(count);
}
public async Task<CountDto> EnterCountsAsync(long countId, EnterCountsRequest request, CancellationToken ct = default)
{
var count = await _counts.Query().Include(c => c.Lines)
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
?? throw new NotFoundException($"Count {countId} was not found.");
if (count.Status == CountStatus.Posted)
throw new ConflictException($"Count {countId} is already posted.");
foreach (var input in request.Lines)
{
var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId)
?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422);
line.CountedQty = input.CountedQty;
line.Variance = input.CountedQty - line.SystemQty;
}
count.Status = CountStatus.Counted;
await _uow.SaveChangesAsync(ct);
return Map(count);
}
public async Task<CountPostResultDto> PostAsync(long countId, CancellationToken ct = default)
{
var count = await _counts.Query().Include(c => c.Lines)
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
?? throw new NotFoundException($"Count {countId} was not found.");
if (count.Status == CountStatus.Posted)
throw new ConflictException($"Count {countId} is already posted.");
if (count.Status != CountStatus.Counted)
throw new ConflictException($"Count {countId} has no entered counts to post.");
var deltas = count.Lines
.Where(l => l.Variance.HasValue && l.Variance.Value != 0)
.Select(l => new StockDelta(l.ItemId, l.BinId, null, l.Variance!.Value))
.ToList();
// No variances → just close the count, no adjustment.
if (deltas.Count == 0)
{
count.Status = CountStatus.Posted;
await _uow.SaveChangesAsync(ct);
return new CountPostResultDto(count.CountId, count.Status, null, Array.Empty<long>());
}
var reason = await _reasonCodes.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
var now = DateTime.UtcNow;
var (adjustmentId, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var adjustment = new StockAdjustment
{
DocNo = docNo,
WarehouseId = count.WarehouseId,
ReasonCodeId = reason.ReasonCodeId,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = deltas.Select(d => new StockAdjustmentLine
{
ItemId = d.ItemId,
BinId = d.BinId,
QtyDelta = d.QtyDelta
}).ToList()
};
await _adjustments.AddAsync(adjustment, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
count.Status = CountStatus.Posted;
return (adjustment.AdjustmentId, refs);
}, ct);
// Map ledger ids after commit so they are populated.
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, ledgerEntries.Select(r => r.LedgerId).ToList());
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+357
View File
@@ -0,0 +1,357 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Goods-receipt service. Create builds a Draft GRN (cost derived from the PO line
/// for PO-based receipts — 02-SECURITY C.3; over-receipt tolerance enforced).
/// Confirm posts FIFO layers + inbound ledger and updates PO receipts inside one
/// UoW transaction (NFR-02/05). Quantities are converted to the item's base UOM
/// for the ledger/layers (FR-MD-03).
/// </summary>
public sealed class GrnService : IGrnService
{
// Phase 1: block any receipt beyond the PO line's open quantity (configurable later, NFR-10).
private const decimal OverReceiptTolerance = 0m;
private readonly IRepository<Grn> _grns;
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<PoLine> _poLines;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<UomConversion> _conversions;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<UomConversion> conversions, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_grns = grns;
_pos = pos;
_poLines = poLines;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_bins = bins;
_vendors = vendors;
_batches = batches;
_conversions = conversions;
_layers = layers;
_ledger = ledger;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<GrnDto?> GetAsync(long grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn);
}
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
{
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
// Resolve vendor + PO context.
PurchaseOrder? po = null;
long vendorId;
if (request.PoId is not null)
{
po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
vendorId = po.VendorId;
}
else
{
if (request.VendorId is null)
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
vendorId = request.VendorId.Value;
}
var lines = new List<GrnLine>();
var batchCache = new Dictionary<(long ItemId, string BatchNo), Batch>();
foreach (var input in request.Lines)
{
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
// Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct.
decimal unitCost;
if (input.PoLineId is not null)
{
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422);
if (poLine.ItemId != input.ItemId)
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
var openQty = poLine.Qty - poLine.QtyReceived;
if (input.Qty > openQty * (1 + OverReceiptTolerance))
throw new DomainException(ErrorCodes.OverReceiptTolerance,
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
unitCost = poLine.UnitPrice;
}
else
{
unitCost = input.UnitCost;
}
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
lines.Add(new GrnLine
{
PoLineId = input.PoLineId,
ItemId = input.ItemId,
UomId = input.UomId,
BinId = input.BinId,
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty,
UnitCost = unitCost,
ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero),
HoldStatus = input.HoldStatus
});
}
var grn = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Grn, token);
var entity = new Grn
{
DocNo = docNo,
PoId = request.PoId,
VendorId = vendorId,
WarehouseId = request.WarehouseId,
Status = GrnStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
Lines = lines
};
await _grns.AddAsync(entity, token);
return entity;
}, ct);
return Map(grn);
}
public async Task<GrnConfirmResultDto> ConfirmAsync(long grnId, string? idempotencyKey, CancellationToken ct = default)
{
var grn = await _grns.Query()
.Include(g => g.Lines)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
// Idempotent replay: an already-confirmed GRN returns its existing result.
if (grn.Status == GrnStatus.Confirmed)
return await BuildConfirmResultAsync(grn, ct);
if (grn.Status == GrnStatus.Closed)
throw new ConflictException($"GRN {grnId} is closed.");
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var runningBalance = new Dictionary<(long, long), decimal>();
var createdLayers = new List<StockLayer>();
var ledgerRefs = new List<StockLedger>();
await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token);
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
qtyBase, unitCostBase, now, token);
createdLayers.Add(layer);
var key = (line.ItemId, grn.WarehouseId);
if (!runningBalance.TryGetValue(key, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, grn.WarehouseId, token);
bal += qtyBase;
runningBalance[key] = bal;
var entry = await _fifo.PostLedgerAsync(
line.ItemId, grn.WarehouseId, line.BinId, line.BatchId, null, actor,
Direction.In, qtyBase, unitCostBase, bal, DocumentTypes.Grn, grn.GrnId, now, token);
ledgerRefs.Add(entry);
if (line.PoLineId is not null)
{
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
if (poLine is not null) poLine.QtyReceived += line.Qty;
}
}
grn.Status = GrnStatus.Confirmed;
grn.PostedAt = now;
await UpdatePoStatusAsync(grn.PoId, token);
return 0;
}, ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, now,
createdLayers.Select(ToCreatedLayer).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList(),
await GetPoStatusAsync(grn.PoId, ct));
}
public async Task<ReleaseLineResultDto> ReleaseLineAsync(long grnId, long grnLineId, string action, CancellationToken ct = default)
{
var grn = await _grns.Query()
.Include(g => g.Lines)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId)
?? throw new NotFoundException($"GRN line {grnLineId} was not found on GRN {grnId}.");
if (grn.Status != GrnStatus.Confirmed)
throw new ConflictException($"GRN {grnId} must be confirmed before releasing holds.");
if (line.HoldStatus != HoldStatus.OnHold)
throw new ConflictException($"GRN line {grnLineId} is {line.HoldStatus}, not OnHold.");
if (string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase))
{
line.HoldStatus = HoldStatus.Available;
await _uow.SaveChangesAsync(ct);
return new ReleaseLineResultDto(grnLineId, HoldStatus.Available);
}
// Reject: remove the held stock from on-hand and post a reversing ledger entry.
// Linking rejected stock to a formal purchase return is deferred (§3.4).
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
line.HoldStatus = HoldStatus.Rejected;
var layers = await _layers.Query()
.Where(l => l.GrnLineId == grnLineId && l.QtyRemaining > 0).ToListAsync(token);
foreach (var layer in layers)
{
var bal = await _fifo.GetOnHandAsync(layer.ItemId, layer.WarehouseId, token) - layer.QtyRemaining;
await _fifo.PostLedgerAsync(
layer.ItemId, layer.WarehouseId, line.BinId, layer.BatchId, null, actor,
Direction.Out, layer.QtyRemaining, layer.UnitCost, bal, DocumentTypes.Grn, grn.GrnId, now, token);
layer.QtyRemaining = 0;
}
return 0;
}, ct);
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
}
private async Task<Batch?> ResolveBatchAsync(
Item item, BatchInput? batch, Dictionary<(long, string), Batch> cache, CancellationToken ct)
{
if (item.TrackingMode == TrackingMode.Batch && batch is null)
throw new DomainException(ErrorCodes.Validation, $"Item {item.ItemId} is batch-tracked; a batch is required.", 422);
if (batch is null) return null;
var key = (item.ItemId, batch.BatchNo.Trim());
if (cache.TryGetValue(key, out var cached)) return cached;
var existing = await _batches.Query().FirstOrDefaultAsync(b => b.ItemId == item.ItemId && b.BatchNo == key.Item2, ct);
if (existing is not null)
{
cache[key] = existing;
return existing;
}
var created = new Batch { ItemId = item.ItemId, BatchNo = key.Item2, ExpiryDate = batch.ExpiryDate };
await _batches.AddAsync(created, ct);
cache[key] = created;
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, long uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
{
if (uomId == item.BaseUomId)
return (qty, unitCostPerUom);
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
}
private async Task UpdatePoStatusAsync(long? poId, CancellationToken ct)
{
if (poId is null) return;
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
if (po is null) return;
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
? PurchaseOrderStatus.FullyReceived
: PurchaseOrderStatus.PartiallyReceived;
po.UpdatedAt = DateTime.UtcNow;
}
private async Task<PurchaseOrderStatus?> GetPoStatusAsync(long? poId, CancellationToken ct)
{
if (poId is null) return null;
return await _pos.Query().AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct);
}
private async Task<GrnConfirmResultDto> BuildConfirmResultAsync(Grn grn, CancellationToken ct)
{
var lineIds = grn.Lines.Select(l => l.GrnLineId).ToList();
var layers = await _layers.Query().AsNoTracking()
.Where(l => l.GrnLineId != null && lineIds.Contains(l.GrnLineId.Value)).ToListAsync(ct);
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grn.GrnId && l.Direction == Direction.In)
.Select(l => l.LedgerId).ToListAsync(ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt,
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
}
private static CreatedLayerDto ToCreatedLayer(StockLayer l) => new(
l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate);
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList());
}
@@ -0,0 +1,9 @@
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
public interface IAdjustmentService
{
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,17 @@
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
/// 02-SECURITY B.2.3). Both are append-only; no write API.
/// </summary>
public interface IAuditService
{
Task<PagedResponse<AuditLogDto>> ListLogsAsync(
string? entityType, long? entityId, long? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
string? sourceDocType, long? sourceDocId, PageQuery query, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
public interface ICountService
{
Task<CountDto?> GetAsync(long countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
Task<CountDto> EnterCountsAsync(long countId, EnterCountsRequest request, CancellationToken ct = default);
Task<CountPostResultDto> PostAsync(long countId, CancellationToken ct = default);
}
@@ -0,0 +1,46 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// FIFO cost-layer and ledger domain service (00-CORE §4, docs/10 Part A.2).
/// Invoked by stock services **inside** a UoW transaction — never from a
/// controller/repository. This turn covers inbound layer creation, ledger posting
/// and valuation; oldest-first consumption (issues/transfers/adjustments) arrives
/// with §5 and must row-lock the layers it consumes (NFR-02).
/// </summary>
public interface IFifoCostingService
{
/// <summary>Create an inbound FIFO layer (qty and unit cost in the item's base UOM).</summary>
Task<StockLayer> CreateInboundLayerAsync(
long itemId, long warehouseId, long? batchId, long? serialId, long? grnLineId,
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default);
/// <summary>
/// Consume <paramref name="qtyBase"/> from open layers oldest-first, row-locking
/// the affected layers for the transaction (NFR-02). Skips on-hold and expired
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
/// and ledger costing). Must run inside a UoW transaction.
/// </summary>
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
long itemId, long warehouseId, long? batchId, decimal qtyBase, CancellationToken ct = default);
/// <summary>Append an immutable ledger entry (value = qtyBase × unitCost).</summary>
Task<StockLedger> PostLedgerAsync(
long itemId, long warehouseId, long? binId, long? batchId, long? serialId, long userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, long sourceDocId, DateTime createdAt, CancellationToken ct = default);
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
Task<decimal> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default);
/// <summary>Valuation over open layers: Σ(qtyRemaining × unitCost) (FR-STK-04).</summary>
Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default);
}
/// <summary>A quantity consumed from one FIFO layer at that layer's unit cost.</summary>
public sealed record ConsumedSegment(long LayerId, decimal Qty, decimal UnitCost);
@@ -0,0 +1,15 @@
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
public interface IGrnService
{
Task<GrnDto?> GetAsync(long grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically.</summary>
Task<GrnConfirmResultDto> ConfirmAsync(long grnId, string? idempotencyKey, CancellationToken ct = default);
Task<ReleaseLineResultDto> ReleaseLineAsync(long grnId, long grnLineId, string action, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
public interface IPurchaseReturnService
{
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference;
namespace ERPCore.Services.Interfaces;
/// <summary>Reason-code reference data (docs/11 §6; FR-X-04).</summary>
public interface IReasonCodeService
{
Task<PagedResponse<ReasonCodeDto>> ListAsync(ReasonContext? context, PageQuery query, CancellationToken ct = default);
Task<ReasonCodeDto> CreateAsync(CreateReasonCodeRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary>
public interface IReorderService
{
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(long? warehouseId, PageQuery query, CancellationToken ct = default);
Task<RequisitionDto> CreateSuggestedRequisitionAsync(long itemId, long warehouseId, CancellationToken ct = default);
}
@@ -0,0 +1,21 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>A signed base-UOM change to an item's stock at one warehouse.</summary>
public sealed record StockDelta(long ItemId, long? BinId, long? BatchId, decimal QtyDelta);
/// <summary>
/// Shared poster for stock-affecting documents (adjustments, count variances,
/// purchase returns). Applies signed deltas — negative consumes FIFO layers
/// oldest-first (row-locked, negative-stock blocked), positive creates a layer at
/// last cost — and appends the ledger entries. Runs inside the caller's UoW
/// transaction (the caller owns numbering, the header, and the commit); the
/// document must already be saved so its id is a valid ledger <c>sourceDocId</c>.
/// </summary>
public interface IStockMutator
{
Task<IReadOnlyList<StockLedger>> ApplyAsync(
long warehouseId, string sourceDocType, long sourceDocId, DateTime now,
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Read-side stock enquiry, ledger and valuation (docs/11 §5.15.3).</summary>
public interface IStockService
{
Task<StockOnHandDto> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
long? itemId, long? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
public interface ITransferService
{
Task<TransferDto?> GetAsync(long transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
/// <summary>Dispatch: consume source FIFO layers into in-transit (row-locked).</summary>
Task<DispatchResultDto> DispatchAsync(long transferId, CancellationToken ct = default);
/// <summary>Receive: create the destination layer at the inherited (cost-preserving) cost.</summary>
Task<ReceiveResultDto> ReceiveAsync(long transferId, ReceiveTransferRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,115 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason
/// code and generates an outbound stock movement via the shared
/// <see cref="IStockMutator"/> (FIFO consume, row-locked; over-return beyond
/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction.
/// </summary>
public sealed class PurchaseReturnService : IPurchaseReturnService
{
private readonly IRepository<PurchaseReturn> _returns;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<GrnLine> _grnLines;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_vendors = vendors;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_grnLines = grnLines;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a purchase return.", 400);
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Return)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
foreach (var line in request.Lines)
{
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
if (line.GrnLineId is not null)
{
var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422);
if (grnLine.ItemId != line.ItemId)
throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} is for a different item.", 422);
}
}
var now = DateTime.UtcNow;
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseReturn, token);
var ret = new PurchaseReturn
{
DocNo = docNo,
VendorId = request.VendorId,
WarehouseId = request.WarehouseId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = ReturnStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select(l => new PurchaseReturnLine
{
GrnLineId = l.GrnLineId,
ItemId = l.ItemId,
Qty = l.Qty
}).ToList()
};
await _returns.AddAsync(ret, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.PurchaseReturn, ret.ReturnId, now, deltas, token);
return (ret, refs);
}, ct);
// Map ledger ids after commit so they are populated.
return new PurchaseReturnDto(
entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status,
entity.CreatedBy,
entity.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerEntries.Select(r => r.LedgerId).ToList());
}
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class ReasonCodeService : IReasonCodeService
{
private readonly IRepository<ReasonCode> _codes;
private readonly IUnitOfWork _uow;
public ReasonCodeService(IRepository<ReasonCode> codes, IUnitOfWork uow)
{
_codes = codes;
_uow = uow;
}
public async Task<PagedResponse<ReasonCodeDto>> ListAsync(ReasonContext? context, PageQuery query, CancellationToken ct = default)
{
var q = _codes.Query().AsNoTracking();
if (context is not null) q = q.Where(r => r.Context == context);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(r => r.Context).ThenBy(r => r.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new ReasonCodeDto(r.ReasonCodeId, r.Code, r.Description, r.Context))
.ToListAsync(ct);
return PagedResponse<ReasonCodeDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ReasonCodeDto> CreateAsync(CreateReasonCodeRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim().ToUpperInvariant();
if (await _codes.Query().AnyAsync(r => r.Context == request.Context && r.Code == code, ct))
throw new ConflictException($"Reason code '{code}' already exists in context {request.Context}.");
var entity = new ReasonCode { Code = code, Description = request.Description.Trim(), Context = request.Context };
await _codes.AddAsync(entity, ct);
await _uow.SaveChangesAsync(ct);
return new ReasonCodeDto(entity.ReasonCodeId, entity.Code, entity.Description, entity.Context);
}
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Stock;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Reorder alerts are a query, not a stored entity (docs/10 C.9): available stock
/// (from the FIFO layers) is compared to <see cref="ItemReorder"/> policy on read.
/// </summary>
public sealed class ReorderService : IReorderService
{
private readonly IRepository<ItemReorder> _reorders;
private readonly IStockService _stock;
private readonly IRequisitionService _requisitions;
public ReorderService(IRepository<ItemReorder> reorders, IStockService stock, IRequisitionService requisitions)
{
_reorders = reorders;
_stock = stock;
_requisitions = requisitions;
}
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(long? warehouseId, PageQuery query, CancellationToken ct = default)
{
var q = _reorders.Query().AsNoTracking();
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
var policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct);
var alerts = new List<ReorderAlertDto>();
foreach (var p in policies)
{
var available = (await _stock.GetOnHandAsync(p.ItemId, p.WarehouseId, ct)).Available;
if (available <= p.ReorderPoint)
alerts.Add(new ReorderAlertDto(p.ItemId, p.WarehouseId, available, p.ReorderPoint, p.ReorderQty, p.ReorderQty));
}
var page = alerts.Skip(query.Skip).Take(query.PageSize).ToList();
return PagedResponse<ReorderAlertDto>.Create(page, query.Page, query.PageSize, alerts.Count);
}
public async Task<RequisitionDto> CreateSuggestedRequisitionAsync(long itemId, long warehouseId, CancellationToken ct = default)
{
var policy = await _reorders.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.ItemId == itemId && r.WarehouseId == warehouseId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No reorder policy for item {itemId} at warehouse {warehouseId}.", 422);
return await _requisitions.CreateAsync(new CreateRequisitionRequest
{
Lines = [new CreateRequisitionLineInput { ItemId = itemId, Qty = policy.ReorderQty }]
}, ct);
}
}
@@ -0,0 +1,183 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <summary>
/// FIFO cost-layer + ledger writer and valuation reader (docs/10 Part A.2).
/// Writes (layer/ledger creation) are added to the tracked context and persisted
/// by the caller's UoW transaction; they are never saved here.
/// </summary>
public sealed class FifoCostingService : IFifoCostingService
{
public const string BaseCurrency = "LKR";
public const string Method = "FIFO";
// Phase-1 placeholder GL accounts (data only, no posting — FR-STK-13).
private const string InventoryAccount = "1300";
private const string ClearingAccount = "2100";
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<JournalEntryStub> _journal;
private readonly ErpDbContext _db;
public FifoCostingService(
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<JournalEntryStub> journal, ErpDbContext db)
{
_layers = layers;
_ledger = ledger;
_journal = journal;
_db = db;
}
public async Task<StockLayer> CreateInboundLayerAsync(
long itemId, long warehouseId, long? batchId, long? serialId, long? grnLineId,
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default)
{
var layer = new StockLayer
{
ItemId = itemId,
WarehouseId = warehouseId,
BatchId = batchId,
SerialId = serialId,
GrnLineId = grnLineId,
QtyReceived = qtyBase,
QtyRemaining = qtyBase,
UnitCost = unitCost,
ReceiptDate = receiptDate
};
await _layers.AddAsync(layer, ct);
return layer;
}
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
long itemId, long warehouseId, long? batchId, decimal qtyBase, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (batchId is not null)
{
var expiry = await _db.Batches.AsNoTracking()
.Where(b => b.BatchId == batchId).Select(b => b.ExpiryDate).FirstOrDefaultAsync(ct);
if (expiry is not null && expiry < today)
throw new DomainException(ErrorCodes.ExpiredBatchBlocked,
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
}
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
// Excludes on-hold (grn line) and expired-batch stock. No LINQ is composed on
// top of the raw SQL so the FOR UPDATE reaches the database intact.
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
// parameter — without it a null batch filter fails with 42P18.
var layers = await _db.StockLayers
.FromSqlInterpolated($"""
SELECT * FROM stock_layers sl
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" = 'OnHold')
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
ORDER BY sl."ReceiptDate", sl."LayerId"
FOR UPDATE
""")
.ToListAsync(ct);
var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase)
{
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
&& (batchId == null || l.BatchId == batchId)
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold, ct);
if (heldExists)
throw new DomainException(ErrorCodes.OnHoldNotIssuable,
$"Stock for item {itemId} at warehouse {warehouseId} is on inspection hold and cannot be issued.", 409);
throw new DomainException(ErrorCodes.StockNegativeBlocked,
$"Available {available} < requested {qtyBase} for item {itemId} at warehouse {warehouseId}.", 409);
}
var segments = new List<ConsumedSegment>();
var remaining = qtyBase;
foreach (var layer in layers)
{
if (remaining <= 0) break;
var take = Math.Min(remaining, layer.QtyRemaining);
layer.QtyRemaining -= take;
remaining -= take;
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
}
return segments;
}
public async Task<StockLedger> PostLedgerAsync(
long itemId, long warehouseId, long? binId, long? batchId, long? serialId, long userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, long sourceDocId, DateTime createdAt, CancellationToken ct = default)
{
var entry = new StockLedger
{
ItemId = itemId,
WarehouseId = warehouseId,
BinId = binId,
BatchId = batchId,
SerialId = serialId,
UserId = userId,
Direction = direction,
QtyBase = qtyBase,
UnitCost = unitCost,
Value = Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero),
RunningBalance = runningBalance,
SourceDocType = sourceDocType,
SourceDocId = sourceDocId,
CreatedAt = createdAt
};
await _ledger.AddAsync(entry, ct);
// GL-ready journal entry per movement (FR-STK-13; data only, no posting).
// Inbound debits Inventory / credits Clearing; outbound reverses.
var (debit, credit) = direction == Direction.In
? (InventoryAccount, ClearingAccount)
: (ClearingAccount, InventoryAccount);
await _journal.AddAsync(new JournalEntryStub
{
SourceDocType = sourceDocType,
SourceDocId = sourceDocId,
DebitAccount = debit,
CreditAccount = credit,
Amount = entry.Value
}, ct);
return entry;
}
public async Task<decimal> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default)
=> await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
public async Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default)
{
var open = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
.ToListAsync(ct);
var layers = open.Select(l => new StockValuationLayerDto(
l.LayerId, l.QtyRemaining, l.UnitCost,
Math.Round(l.QtyRemaining * l.UnitCost, 4, MidpointRounding.AwayFromZero), l.ReceiptDate)).ToList();
return new StockValuationDto(
itemId, warehouseId, layers,
layers.Sum(l => l.QtyRemaining), layers.Sum(l => l.Value), BaseCurrency, Method);
}
}
@@ -0,0 +1,77 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <summary>
/// Applies signed stock deltas for a document and posts the ledger (see
/// <see cref="IStockMutator"/>). Running balances are tracked in-memory per
/// (item, warehouse), seeded from current on-hand, so multiple lines for the same
/// item chain correctly within the transaction.
/// </summary>
public sealed class StockMutator : IStockMutator
{
private readonly IFifoCostingService _fifo;
private readonly IRepository<StockLayer> _layers;
private readonly ICurrentUser _currentUser;
public StockMutator(IFifoCostingService fifo, IRepository<StockLayer> layers, ICurrentUser currentUser)
{
_fifo = fifo;
_layers = layers;
_currentUser = currentUser;
}
public async Task<IReadOnlyList<StockLedger>> ApplyAsync(
long warehouseId, string sourceDocType, long sourceDocId, DateTime now,
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default)
{
var actor = _currentUser.AuditUserId;
var balances = new Dictionary<long, decimal>();
var entries = new List<StockLedger>();
foreach (var d in deltas)
{
if (d.QtyDelta == 0) continue;
if (!balances.TryGetValue(d.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct);
StockLedger entry;
if (d.QtyDelta < 0)
{
var qty = -d.QtyDelta;
var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct);
var unitCost = segments.Sum(s => s.Qty * s.UnitCost) / qty;
bal -= qty;
entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct);
}
else
{
var unitCost = await LastCostAsync(d.ItemId, warehouseId, ct);
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct);
bal += d.QtyDelta;
entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct);
}
balances[d.ItemId] = bal;
entries.Add(entry);
}
return entries;
}
private async Task<decimal> LastCostAsync(long itemId, long warehouseId, CancellationToken ct)
=> await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId)
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct) ?? 0m;
}
@@ -0,0 +1,76 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
public sealed class StockService : IStockService
{
private readonly IFifoCostingService _fifo;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<StockTransferLine> _transferLines;
public StockService(
IFifoCostingService fifo, IRepository<StockLayer> layers,
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
{
_fifo = fifo;
_layers = layers;
_ledger = ledger;
_transferLines = transferLines;
}
public async Task<StockOnHandDto> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default)
{
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
// On-hold stock is on-hand but not issuable — sourced from GRN lines still OnHold.
var onHold = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
// Outbound in-transit: dispatched from this warehouse, not yet received at the
// destination. Dispatch already consumed the source layers, so this stock has
// left onHand — it is reported for visibility (AR-05) but is NOT re-subtracted
// from available (that would double-count). reserved (sales) stays a stub.
var inTransit = await _transferLines.Query().AsNoTracking()
.Where(l => l.ItemId == itemId
&& l.Transfer!.SrcWarehouseId == warehouseId
&& l.Transfer.Status == TransferStatus.InTransit)
.SumAsync(l => (decimal?)(l.Qty - l.QtyReceived), ct) ?? 0m;
const decimal reserved = 0m;
var available = onHand - onHold - reserved;
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
}
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
long? itemId, long? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
{
var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LedgerId)
.Skip(query.Skip).Take(query.PageSize)
.Select(l => new StockLedgerRowDto(
l.LedgerId, l.ItemId, l.WarehouseId, l.BinId, l.BatchId, l.SerialId,
l.Direction, l.QtyBase, l.UnitCost, l.Value, l.RunningBalance,
l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt))
.ToListAsync(ct);
return PagedResponse<StockLedgerRowDto>.Create(rows, query.Page, query.PageSize, total);
}
public Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default)
=> _fifo.GetValuationAsync(itemId, warehouseId, ct);
}
+196
View File
@@ -0,0 +1,196 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Inter-warehouse transfer service (FR-STK-05/06). Dispatch consumes source FIFO
/// layers (row-locked; negative-stock blocked) and records the value-weighted cost
/// on the line; receive recreates the destination layer at that cost
/// (cost-preserving — no revaluation). Both run in a single UoW transaction.
/// </summary>
public sealed class TransferService : ITransferService
{
private readonly IRepository<StockTransfer> _transfers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_transfers = transfers;
_warehouses = warehouses;
_items = items;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<TransferDto?> GetAsync(long transferId, CancellationToken ct = default)
{
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.TransferId == transferId, ct);
return t is null ? null : Map(t);
}
public async Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default)
{
if (request.SrcWarehouseId == request.DestWarehouseId)
throw new DomainException(ErrorCodes.Validation, "destWarehouseId must differ from srcWarehouseId.", 422);
await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
await EnsureWarehouseAsync(request.DestWarehouseId, ct);
foreach (var line in request.Lines)
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
var actor = _currentUser.AuditUserId;
var transfer = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Transfer, token);
var entity = new StockTransfer
{
DocNo = docNo,
SrcWarehouseId = request.SrcWarehouseId,
DestWarehouseId = request.DestWarehouseId,
Status = TransferStatus.Draft,
CreatedBy = actor,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new StockTransferLine
{
ItemId = l.ItemId,
SrcBinId = l.SrcBinId,
DestBinId = l.DestBinId,
BatchId = l.BatchId,
Qty = l.Qty
}).ToList()
};
await _transfers.AddAsync(entity, token);
return entity;
}, ct);
return Map(transfer);
}
public async Task<DispatchResultDto> DispatchAsync(long transferId, CancellationToken ct = default)
{
var transfer = await _transfers.Query().Include(t => t.Lines)
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
?? throw new NotFoundException($"Transfer {transferId} was not found.");
if (transfer.Status != TransferStatus.Draft)
throw new ConflictException($"Transfer {transferId} is {transfer.Status}; only a Draft can be dispatched.");
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var consumed = new List<ConsumedLayerDto>();
var ledgerRefs = new List<StockLedger>();
var balances = new Dictionary<long, decimal>();
await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId))
{
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token);
var value = segments.Sum(s => s.Qty * s.UnitCost);
line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive
if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token);
bal -= line.Qty;
balances[line.ItemId] = bal;
var entry = await _fifo.PostLedgerAsync(
line.ItemId, transfer.SrcWarehouseId, line.SrcBinId, line.BatchId, null, actor,
Direction.Out, line.Qty, line.UnitCost.Value, bal, DocumentTypes.Transfer, transfer.TransferId, now, token);
ledgerRefs.Add(entry);
consumed.AddRange(segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)));
}
transfer.Status = TransferStatus.InTransit;
return 0;
}, ct);
return new DispatchResultDto(transfer.TransferId, transfer.Status, consumed, ledgerRefs.Select(l => l.LedgerId).ToList());
}
public async Task<ReceiveResultDto> ReceiveAsync(long transferId, ReceiveTransferRequest request, CancellationToken ct = default)
{
var transfer = await _transfers.Query().Include(t => t.Lines)
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
?? throw new NotFoundException($"Transfer {transferId} was not found.");
if (transfer.Status != TransferStatus.InTransit)
throw new ConflictException($"Transfer {transferId} is {transfer.Status}; only an InTransit transfer can be received.");
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var createdLayers = new List<StockLayer>();
var ledgerRefs = new List<StockLedger>();
var balances = new Dictionary<long, decimal>();
await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var input in request.Lines)
{
var line = transfer.Lines.FirstOrDefault(l => l.TransferLineId == input.TransferLineId)
?? throw new DomainException(ErrorCodes.Validation, $"Transfer line {input.TransferLineId} is not on transfer {transferId}.", 422);
var outstanding = line.Qty - line.QtyReceived;
if (input.Qty > outstanding)
throw new DomainException(ErrorCodes.Validation,
$"Receiving {input.Qty} exceeds the outstanding in-transit {outstanding} on line {input.TransferLineId}.", 422);
var unitCost = line.UnitCost ?? 0m;
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token);
createdLayers.Add(layer);
if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.DestWarehouseId, token);
bal += input.Qty;
balances[line.ItemId] = bal;
var entry = await _fifo.PostLedgerAsync(
line.ItemId, transfer.DestWarehouseId, line.DestBinId, line.BatchId, null, actor,
Direction.In, input.Qty, unitCost, bal, DocumentTypes.Transfer, transfer.TransferId, now, token);
ledgerRefs.Add(entry);
line.QtyReceived += input.Qty;
}
if (transfer.Lines.All(l => l.QtyReceived >= l.Qty))
transfer.Status = TransferStatus.Received;
return 0;
}, ct);
// Map after commit so layer ids are populated.
var created = createdLayers
.Select(l => new TransferCreatedLayerDto(l.LayerId, l.WarehouseId, l.QtyReceived, l.UnitCost))
.ToList();
return new ReceiveResultDto(transfer.TransferId, transfer.Status, created, ledgerRefs.Select(l => l.LedgerId).ToList());
}
private async Task EnsureWarehouseAsync(long warehouseId, CancellationToken ct)
{
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
}