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

- Implemented IGrnService for managing goods receipts including retrieval, creation, and confirmation.
- Created IPurchaseReturnService for handling purchase return operations.
- Added IReasonCodeService for managing reason codes with listing and creation capabilities.
- Developed IReorderService for fetching reorder alerts and creating suggested requisitions.
- Introduced IStockMutator for applying stock changes and posting ledger entries.
- Established IStockService for stock inquiries, ledger retrieval, and valuation.
- Created ITransferService for managing inter-warehouse transfers including dispatch and receiving operations.
- Implemented PurchaseReturnService to handle purchase return logic and stock adjustments.
- Developed ReasonCodeService for listing and creating reason codes.
- Created ReorderService for fetching reorder alerts and generating requisitions.
- Implemented FifoCostingService for FIFO cost-layer management and ledger writing.
- Developed StockMutator for applying stock deltas and posting ledger entries.
- Created StockService for stock inquiries and ledger management.
- Implemented TransferService for managing inter-warehouse transfers with dispatch and receive functionalities.
This commit is contained in:
2026-07-14 10:20:38 +05:30
parent 4e84a15db7
commit 22f86451e3
90 changed files with 13496 additions and 19 deletions
@@ -0,0 +1,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
}