feat: Add new services and interfaces for GRN, Purchase Return, Reason Code, Reorder, Stock, and Transfer functionalities #3

Merged
ImanThiyanga merged 1 commits from fix/phase-01 into Dev 2026-07-14 05:12:25 +00:00
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());
}
+50 -18
View File
@@ -29,36 +29,40 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [~] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
- [~] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
- [~] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel
- [ ] Purchase Return (outbound movement, reason code) — **deferred**: needs GRN lines + stock ledger/FIFO (§3/§4). Build with those.
- [~] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)
> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match.
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly.
## 3. Goods Receipt
- [ ] GRN create (against PO / direct), over-receipt tolerance
- [ ] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn, Idempotency-Key)
- [ ] Inspection hold release / reject
> Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate.
- [~] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred.
- [~] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
- [~] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).
## 4. Stock Core
- [ ] StockLayer + StockLedger entities/config (ledger append-only)
- [ ] `FifoCostingService` (consume oldest-first with row lock; valuation)
- [ ] Stock enquiry (onHand / available / onHold / inTransit)
- [ ] Ledger query · Valuation query
> Implemented + **live smoke test PASSED** 2026-07-13: receive→confirm creates FIFO layers + inbound ledger (qtyBase/unitCost/value/runningBalance correct), on-hand/valuation/ledger queries correct, OnHold excluded from `available`, UOM→base conversion applied. Same `[~]` gate (§6 auth+audit).
- [x] StockLayer + StockLedger entities/config — ledger **append-only at the app level** (never updated/deleted); DB-role `UPDATE`/`DELETE` revoke is deferred hardening (02-SECURITY B.3). Layers keyed per item **per warehouse**, base-UOM qty + unit cost; ledger polymorphic source (`sourceDocType`/`sourceDocId`), time-series indexes.
- [~] `FifoCostingService` — inbound layer + ledger posting + valuation **and oldest-first consume with row lock** (`SELECT … FOR UPDATE`, on-hold/expired exclusion, negative-stock block) all implemented + verified 2026-07-13 via §5. Blended cost on multi-layer consume verified (700@10 + 100@12 → 10.25).
- [~] Stock enquiry (onHand / available / onHold / inTransit) — onHand/available/onHold **and inTransit** now live + verified (inTransit = outstanding InTransit-transfer qty out of this warehouse). `reserved` stays a 0 stub until Sales.
- [x] Ledger query · Valuation query — `GET /stock/ledger` (item/warehouse/from/to + paging), `GET /stock/valuation` (open layers, totals, FIFO); both verified.
## 5. Stock Transactions
- [ ] Transfer: create → dispatch (consume, In-Transit) → receive (dest layer, cost-preserving)
- [ ] Adjustment (auto-post, mandatory reason code)
- [ ] Count (cycle/full → enter counts → variance → post)
- [ ] Reorder alerts (query) + suggest requisition
> **All four §5 features implemented + live smoke test PASSED 2026-07-13** (Adjustment, Transfer, Count, Reorder alerts); Purchase Return (§3.4) also done this pass. Same `[~]` gate (§6 auth+audit).
- [~] Transfer: create → dispatch (consume source FIFO row-locked → In-Transit) → receive (dest layer, **cost-preserving**) — verified: dispatch reduces source onHand + reports inTransit; receive creates dest layer at inherited cost (300 @12 → dest value 3600); `destWarehouseId != srcWarehouseId`→422; dispatch short→`409 STOCK_NEGATIVE_BLOCKED`. Partial receive supported (`QtyReceived`).
- [~] Adjustment (auto-post, mandatory reason code) — **highest-risk feature (02-SECURITY C.5)**: `REASON_CODE_REQUIRED`→400, non-Adjustment reason→422, decrease FIFO-consumes (blended cost, negative→409), increase creates a layer at last cost. All verified.
- [~] Count (cycle/full → enter counts → variance → post) — create snapshots systemQty (immutable), enter sets counted+variance→Counted, post emits a variance `StockAdjustment` via shared `StockMutator` + closes the count. Verified: variance 15 (post→on-hand 485) and +10 increase; re-post→409.
- [~] Reorder alerts (query) + suggest requisition — `GET /stock/reorder-alerts` (available ≤ ROP, computed on read) + `POST …/{itemId}/requisition` (draft PR at suggested qty). Verified.
syte
## 6. Cross-cutting
> **Auth-enforcement gap (open):** JWT bearer *validation* is wired, but no token issuer exists yet and controllers are **not** `[Authorize]`-gated, so §1 endpoints are currently open. This is the AR-01/NFR-03 control surface — gate all v1 endpoints (fallback authorization policy) in the same change as `POST /auth/login`, then re-run the 02-SECURITY B.1 checklist and flip §1 items to `[x]`.
- [ ] Audit log on every mutation (who/when/old→new)
> **Status:** audit trail, doc numbering, reason codes, JournalEntryStub, negative-stock block all **done**. The **one** remaining item is authentication. **Auth-enforcement gap (open):** JWT bearer *validation* is wired, but no token issuer exists yet and controllers are **not** `[Authorize]`-gated, so endpoints are currently open. This is the AR-01/NFR-03 control surface — gate all v1 endpoints (fallback authorization policy) in the same change as `POST /auth/login`, then re-run the 02-SECURITY B.1 checklist and flip §1–§5 items to `[x]`. (The AR-01 **audit** compensating control is now in place.)
- [x] Audit log on every mutation (who/when/old→new)`AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred.
- [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO.
- [~] Auth: simple in-app login → JWT (`POST /auth/login`) — foundation only: `User` table + seeded `system` user (id 1) exist and `ICurrentUser.AuditUserId` stamps docs; login endpoint + `[Authorize]` still pending.
- [ ] JournalEntryStub emitted per stock movement (data only)
- [ ] Negative-stock policy enforcement (default block)
- [ ] FEFO picking for perishables; block expired / on-hold issue
- [x] JournalEntryStub emitted per stock movement (data only)`JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70).
- [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync``409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub.
- [~] FEFO picking for perishables; block expired / on-hold issue**issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
- [x] Reason codes (FR-X-04) — `ReasonCode` entity + `GET/POST /reason-codes`; standard set (docs/10 §B.8.3) seeded idempotently at startup (`DataSeeder`). Verified.
## Deferred (Phase 2+ — do NOT build now, hooks only)
- [ ] Vendor invoice + three-way match
@@ -88,3 +92,31 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- **Verified:** build clean; app boots; full procurement smoke green (see §2 note) — requisition→submit, RFQ→quotation→comparison, PO create/get/edit/approve/cancel, numbering increment, error paths 409/412/422.
- **Deferred/next:** Purchase Return (needs GRN+stock), then §3 GRN, §4 Stock Core (FIFO/ledger), §5 stock transactions. Auth/audit (§6) still the gate for flipping §1/§2 to `[x]`.
- Housekeeping: an empty user-created migration `20260709124415_initial` sits between InitialCreate and AddProcurement (applied, harmless no-op; emits a cosmetic CS8981 lowercase-name warning).
### 2026-07-13 — Stock Core (§4) + Goods Receipt (§3, minus purchase return)
- Entities: Batch, Serial, StockLayer (FIFO), StockLedger (append-only), Grn/GrnLine + configs; enums Direction, HoldStatus, GrnStatus. DbSets + migration `AddStockAndGrn` (6 tables) applied.
- Services: `FifoCostingService` (Services/Stock — inbound layer, ledger post, on-hand, valuation), `StockService` (enquiry/ledger/valuation), `GrnService` (create with PO-derived cost + over-receipt + batch resolve + UOM→base; confirm in `ExecuteInTransactionAsync`; release/reject). Controllers `/stock`, `/grns`.
- **Verified against Postgres:** PO→GRN receive (client cost ignored, PO price used) → confirm → 2 FIFO layers + 2 ledger rows (running balance) → PO FullyReceived; on-hand 5000 / valuation 62500 / ledger In; **idempotent re-confirm** (on-hand stayed 5000); over-receipt `422` at the open-qty boundary, exact-fill `201`; batch receive **OnHold** → excluded from `available`**release** → available; **UOM conversion** 10×Box-12 → 120 base units @10 (value 1200).
- **Deferred (next):** §5 stock transactions — Transfer (dispatch/receive, in-transit, cost-preserving), Adjustment (auto-post + reason code), Count, Reorder alerts — which is where the FIFO **oldest-first consume + row lock** lands; then Purchase Return (§3.4) and GRN reject→return linkage. `ReasonCode` (§6) needed for adjustments/returns. Auth/audit (§6) remains the `[x]` gate.
### 2026-07-13 (2) — Stock Transactions (§5: Adjustment, Transfer, Reorder) + FIFO consume + Reason codes
- **FIFO consume engine** in `FifoCostingService.ConsumeAsync`: oldest-first, row-locked via `SELECT … FOR UPDATE` (raw SQL, no LINQ composed on top so the lock reaches PG; `{batchId}::bigint` cast avoids a 42P18 null-param error), excludes on-hold + expired layers, throws `STOCK_NEGATIVE_BLOCKED`/`ONHOLD_NOT_ISSUABLE`/`EXPIRED_BATCH_BLOCKED`, returns consumed segments for cost-preserving moves.
- Entities: ReasonCode, StockAdjustment/Line, StockTransfer/Line (+ `UnitCost`/`QtyReceived` extension on the transfer line for cost-preservation) + configs; enums TransferStatus, AdjustmentStatus, ReasonContext. Migration `AddStockTransactions` (5 tables) applied.
- Services/controllers: `ReasonCodeService` (`/reason-codes`, startup seed via `DataSeeder`), `AdjustmentService` (`/stock-adjustments`), `TransferService` (`/stock-transfers` create/dispatch/receive), `ReorderService` (`/stock/reorder-alerts` + suggest-requisition). `StockService.GetOnHandAsync` now computes real inTransit.
- **Verified against Postgres:** adjustment decrease FIFO-consume with blended cost 10.25 across two layers, negative→409, `REASON_CODE_REQUIRED`→400, wrong-context reason→422, increase-at-last-cost; transfer create→dispatch (source onHand↓, inTransit↑, consumedLayers)→receive (dest layer cost-preserved @12, value 3600), dest==src→422, dispatch-short→409; reorder alerts list + draft-PR suggestion; reason codes seeded (5 Adjustment + 4 Return).
- **Deferred (next):** §5 **Count** (create snapshot → enter counts → post variance via the Adjustment engine), **Purchase Return** (§3.4, + GRN reject→return linkage), FEFO pick ordering, `JournalEntryStub`. Auth/audit (§6) remains the `[x]` gate.
### 2026-07-13 (3) — Count (§5.6) + Purchase Return (§3.4) + shared StockMutator
- **`StockMutator`** (Services/Stock): shared signed-delta poster (negative → FIFO consume; positive → layer at last cost) + ledger, run inside the caller's txn. Adjustment/Count-post/Return all delegate to it → one code path for stock-affecting postings.
- Refactored `AdjustmentService` onto `StockMutator` and added an **intermediate `SaveChanges`** so the header id is flushed before ledger posting — fixes a latent bug where new-in-txn documents wrote `sourceDocId=0` (verified: `ADJ` ledger now `sourceDocId=5`). Also fixed `ledgerRefs:[0]` by mapping ledger ids **after** commit (Count + Return).
- Entities: StockCount/Line (+ CountType, CountStatus), PurchaseReturn/Line (+ ReturnStatus) + configs. Migration `AddCountsAndReturns` (4 tables) applied.
- Services/controllers: `CountService` (`/stock-counts` create/enter/post), `PurchaseReturnService` (`/purchase-returns`).
- **Verified against Postgres:** count snapshot 500 → counted 485 → post variance 15 (on-hand→485, adjustmentId+ledgerRefs), positive variance +10, re-post→409; return 100 outbound (on-hand→385), `REASON_CODE_REQUIRED`→400, non-Return reason→422, over-return→409 `STOCK_NEGATIVE_BLOCKED`.
- **§5 is now complete.** Remaining Phase-1 backend: §6 (auth/login + `[Authorize]`, audit trail, `JournalEntryStub`, negative-stock per-item override, FEFO pick ordering). Auth/audit is still the gate for flipping §1–§5 `[~]``[x]`.
### 2026-07-13 (4) — §6 audit trail + JournalEntryStub (auth deferred by request)
- **Audit trail (FR-X-02):** `AuditLog` (jsonb `changeSet`) written by an `ErpDbContext.SaveChanges/Async` override via `AuditScribe` — captures before save (accurate old→new), writes rows after inserts get their keys. Create = field set, Update = only changed fields `{old,new}`, Delete = prior row; excludes PK/RowVersion and the ledger/layer/sequence/journal/self tables. Actor from `ICurrentUser` (system=1). `ErpDbContext` now takes `ICurrentUser` (design-time migration still works via DI).
- **JournalEntryStub (FR-STK-13):** emitted for every ledger entry in `FifoCostingService.PostLedgerAsync` (In → Dr `1300`/Cr `2100`; Out reverses; amount = value). Placeholder GL accounts.
- Read endpoints (auditor role, beyond documented §11): `GET /audit-logs` (entityType/entityId/userId/from/to), `GET /journal-entries` (sourceDocType/sourceDocId). `AuditService`. Migration `AddAuditAndJournal` (2 tables, jsonb) applied.
- **Verified against Postgres:** item Create logged full field set (userId 1); item Update logged only `Name` + `UpdatedAt` as `{old,new}`; GRN confirm → journal In Dr1300/Cr2100 amount 700; adjustment decrease → journal Out Dr2100/Cr1300 amount 70; StockAdjustment Create audited.
- **Only auth remains for Phase 1.** Everything else in §6 is done. Auth (`POST /auth/login` + global `[Authorize]`) is intentionally deferred per request; wiring it is what flips §1–§5 `[~]``[x]`. FEFO pick-ordering left as a documented deferral (would conflict with FIFO-costing integrity without a physical/cost layer split); negative-stock stays the resolved global block (open-decision #2).