diff --git a/Backend/ERPCore/Controllers/ApiControllerBase.cs b/Backend/ERPCore/Controllers/ApiControllerBase.cs
index ad4d1e3..e00e8b2 100644
--- a/Backend/ERPCore/Controllers/ApiControllerBase.cs
+++ b/Backend/ERPCore/Controllers/ApiControllerBase.cs
@@ -1,5 +1,7 @@
using ERPCore.Common.Http;
+using ERPCore.Infra.Auth;
using ERPCore.System.Errors;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
@@ -8,10 +10,12 @@ namespace ERPCore.Controllers;
/// Base for the v1 API controllers. Centralises ETag / If-Match handling
/// (docs/11-BACKEND-PHASE1.md §1.6) so concurrency behaviour is uniform.
/// Each controller declares its own explicit lowercase [Route] to match
-/// the API contract paths (docs/11 §1.1).
+/// the API contract paths (docs/11 §1.1). Every v1 endpoint requires a valid
+/// AuthHex token satisfying the ERP door policy (docs/10 A.4).
///
[ApiController]
[Produces("application/json")]
+[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
public abstract class ApiControllerBase : ControllerBase
{
/// Parse a mandatory If-Match header, or 428 if absent/malformed.
diff --git a/Backend/ERPCore/Controllers/AuditLogsController.cs b/Backend/ERPCore/Controllers/AuditLogsController.cs
new file mode 100644
index 0000000..6f66cf4
--- /dev/null
+++ b/Backend/ERPCore/Controllers/AuditLogsController.cs
@@ -0,0 +1,26 @@
+using ERPCore.Dtos.Audit;
+using ERPCore.Dtos.Common;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// 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.
+///
+[Route("api/v1/audit-logs")]
+public sealed class AuditLogsController : ApiControllerBase
+{
+ private readonly IAuditService _audit;
+
+ public AuditLogsController(IAuditService audit) => _audit = audit;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] string? entityType, [FromQuery] int? entityId, [FromQuery] int? userId,
+ [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _audit.ListLogsAsync(entityType, entityId, userId, from, to, query, ct));
+}
diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs
new file mode 100644
index 0000000..20ba722
--- /dev/null
+++ b/Backend/ERPCore/Controllers/GrnsController.cs
@@ -0,0 +1,52 @@
+using ERPCore.Dtos.Grn;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Goods-receipt endpoints (docs/11 §4).
+[Route("api/v1/grns")]
+public sealed class GrnsController : ApiControllerBase
+{
+ private readonly IGrnService _grns;
+
+ public GrnsController(IGrnService grns) => _grns = grns;
+
+ [HttpGet("{grnId:int}")]
+ [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int grnId, CancellationToken ct)
+ {
+ var dto = await _grns.GetAsync(grnId, ct);
+ return dto is null ? NotFound() : Ok(dto);
+ }
+
+ /// Create a Draft GRN against a PO or direct. Cost is PO-derived for PO lines.
+ [HttpPost]
+ [ProducesResponseType(typeof(GrnDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create([FromBody] CreateGrnRequest request, CancellationToken ct)
+ {
+ var dto = await _grns.CreateAsync(request, ct);
+ return Created($"/api/v1/grns/{dto.GrnId}", dto);
+ }
+
+ /// Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).
+ [HttpPost("{grnId:int}/confirm")]
+ [ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Confirm(
+ int grnId, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken ct)
+ => Ok(await _grns.ConfirmAsync(grnId, idempotencyKey, ct));
+
+ /// Release or reject an inspection-hold line (FR-GRN-05).
+ [HttpPost("{grnId:int}/lines/{grnLineId:int}/release")]
+ [ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Release(
+ int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
+ => Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
+}
diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs
index eab6c79..c98493a 100644
--- a/Backend/ERPCore/Controllers/ItemsController.cs
+++ b/Backend/ERPCore/Controllers/ItemsController.cs
@@ -20,16 +20,16 @@ public sealed class ItemsController : ApiControllerBase
public async Task>> List(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
- [FromQuery] long? categoryId,
+ [FromQuery] int? categoryId,
[FromQuery] TrackingMode? trackingMode,
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
/// Get a single item; returns an ETag for optimistic concurrency.
- [HttpGet("{itemId:long}")]
+ [HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> GetById(long itemId, CancellationToken ct)
+ public async Task> GetById(int itemId, CancellationToken ct)
{
var result = await _items.GetAsync(itemId, ct);
if (result is null) return NotFound();
@@ -51,11 +51,11 @@ public sealed class ItemsController : ApiControllerBase
}
/// Full update; requires If-Match (412 on stale ETag).
- [HttpPut("{itemId:long}")]
+ [HttpPut("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
- public async Task> Update(long itemId, [FromBody] UpdateItemRequest request, CancellationToken ct)
+ public async Task> Update(int itemId, [FromBody] UpdateItemRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _items.UpdateAsync(itemId, request, expected, ct);
@@ -64,26 +64,26 @@ public sealed class ItemsController : ApiControllerBase
}
/// Activate / deactivate the item (FR-MD-08 — deactivate, not delete).
- [HttpPatch("{itemId:long}/status")]
+ [HttpPatch("{itemId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task SetStatus(long itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct)
+ public async Task SetStatus(int itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct)
{
await _items.SetStatusAsync(itemId, request.Status, ct);
return NoContent();
}
/// Replace the item's per-warehouse reorder settings (FR-MD-05).
- [HttpPut("{itemId:long}/reorder")]
+ [HttpPut("{itemId:int}/reorder")]
[ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> UpdateReorder(long itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
+ public async Task> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
/// Replace the item's UOM conversions (FR-MD-02).
- [HttpPut("{itemId:long}/uom-conversions")]
+ [HttpPut("{itemId:int}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> UpdateUomConversions(long itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
+ public async Task> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
}
diff --git a/Backend/ERPCore/Controllers/JournalEntriesController.cs b/Backend/ERPCore/Controllers/JournalEntriesController.cs
new file mode 100644
index 0000000..54789c9
--- /dev/null
+++ b/Backend/ERPCore/Controllers/JournalEntriesController.cs
@@ -0,0 +1,24 @@
+using ERPCore.Dtos.Audit;
+using ERPCore.Dtos.Common;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Read-only GL-ready journal stubs (FR-STK-13; consumed by the Accounting phase).
+/// Data only — no posting in Phase 1.
+///
+[Route("api/v1/journal-entries")]
+public sealed class JournalEntriesController : ApiControllerBase
+{
+ private readonly IAuditService _audit;
+
+ public JournalEntriesController(IAuditService audit) => _audit = audit;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _audit.ListJournalAsync(sourceDocType, sourceDocId, query, ct));
+}
diff --git a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
index 03c9524..a1bf9b9 100644
--- a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
+++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
@@ -17,13 +17,13 @@ public sealed class PurchaseOrdersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> List(
- [FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] long? vendorId, CancellationToken ct)
+ [FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
- [HttpGet("{poId:long}")]
+ [HttpGet("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> GetById(long poId, CancellationToken ct)
+ public async Task> GetById(int poId, CancellationToken ct)
{
var result = await _pos.GetAsync(poId, ct);
if (result is null) return NotFound();
@@ -44,12 +44,12 @@ public sealed class PurchaseOrdersController : ApiControllerBase
}
/// Edit while open (FR-PROC-05); requires If-Match. 409 PO_NOT_EDITABLE if closed.
- [HttpPut("{poId:long}")]
+ [HttpPut("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
- public async Task> Update(long poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct)
+ public async Task> Update(int poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _pos.UpdateAsync(poId, request, expected, ct);
@@ -58,17 +58,17 @@ public sealed class PurchaseOrdersController : ApiControllerBase
}
/// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.
- [HttpPost("{poId:long}/approve")]
+ [HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> Approve(long poId, CancellationToken ct)
+ public async Task> Approve(int poId, CancellationToken ct)
=> Ok(await _pos.ApproveAsync(poId, ct));
/// Cancel — 409 if any goods have been received against the PO.
- [HttpPost("{poId:long}/cancel")]
+ [HttpPost("{poId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
- public async Task> Cancel(long poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct)
+ public async Task> Cancel(int poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct)
=> Ok(await _pos.CancelAsync(poId, request.Reason, ct));
}
diff --git a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs
new file mode 100644
index 0000000..9f59a56
--- /dev/null
+++ b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs
@@ -0,0 +1,26 @@
+using ERPCore.Dtos.Procurement;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Purchase-return endpoints (docs/11 §3.4).
+[Route("api/v1/purchase-returns")]
+public sealed class PurchaseReturnsController : ApiControllerBase
+{
+ private readonly IPurchaseReturnService _returns;
+
+ public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
+
+ /// Create + auto-post a return (outbound movement). 409 if return exceeds available stock.
+ [HttpPost]
+ [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create([FromBody] CreatePurchaseReturnRequest request, CancellationToken ct)
+ {
+ var dto = await _returns.CreateAsync(request, ct);
+ return Created($"/api/v1/purchase-returns/{dto.ReturnId}", dto);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/ReasonCodesController.cs b/Backend/ERPCore/Controllers/ReasonCodesController.cs
new file mode 100644
index 0000000..e67aa32
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ReasonCodesController.cs
@@ -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;
+
+/// Reason-code reference endpoints (docs/11 §6).
+[Route("api/v1/reason-codes")]
+public sealed class ReasonCodesController : ApiControllerBase
+{
+ private readonly IReasonCodeService _codes;
+
+ public ReasonCodesController(IReasonCodeService codes) => _codes = codes;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> Create([FromBody] CreateReasonCodeRequest request, CancellationToken ct)
+ {
+ var dto = await _codes.CreateAsync(request, ct);
+ return Created($"/api/v1/reason-codes/{dto.ReasonCodeId}", dto);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/RequisitionsController.cs b/Backend/ERPCore/Controllers/RequisitionsController.cs
index 10d4c08..ab849b9 100644
--- a/Backend/ERPCore/Controllers/RequisitionsController.cs
+++ b/Backend/ERPCore/Controllers/RequisitionsController.cs
@@ -18,10 +18,10 @@ public sealed class RequisitionsController : ApiControllerBase
public async Task>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, ct));
- [HttpGet("{requisitionId:long}")]
+ [HttpGet("{requisitionId:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> GetById(long requisitionId, CancellationToken ct)
+ public async Task> GetById(int requisitionId, CancellationToken ct)
{
var dto = await _requisitions.GetAsync(requisitionId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -36,9 +36,9 @@ public sealed class RequisitionsController : ApiControllerBase
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
}
- [HttpPost("{requisitionId:long}/submit")]
+ [HttpPost("{requisitionId:int}/submit")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> Submit(long requisitionId, CancellationToken ct)
+ public async Task> Submit(int requisitionId, CancellationToken ct)
=> Ok(await _requisitions.SubmitAsync(requisitionId, ct));
}
diff --git a/Backend/ERPCore/Controllers/RfqsController.cs b/Backend/ERPCore/Controllers/RfqsController.cs
index 736ddbc..7cc0e1f 100644
--- a/Backend/ERPCore/Controllers/RfqsController.cs
+++ b/Backend/ERPCore/Controllers/RfqsController.cs
@@ -12,10 +12,10 @@ public sealed class RfqsController : ApiControllerBase
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
- [HttpGet("{rfqId:long}")]
+ [HttpGet("{rfqId:int}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> GetById(long rfqId, CancellationToken ct)
+ public async Task> GetById(int rfqId, CancellationToken ct)
{
var dto = await _rfqs.GetAsync(rfqId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -30,20 +30,20 @@ public sealed class RfqsController : ApiControllerBase
return Created($"/api/v1/rfqs/{dto.RfqId}", dto);
}
- [HttpPost("{rfqId:long}/quotations")]
+ [HttpPost("{rfqId:int}/quotations")]
[ProducesResponseType(typeof(VendorQuotationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
- public async Task> AddQuotation(long rfqId, [FromBody] CreateQuotationRequest request, CancellationToken ct)
+ public async Task> AddQuotation(int rfqId, [FromBody] CreateQuotationRequest request, CancellationToken ct)
{
var dto = await _rfqs.AddQuotationAsync(rfqId, request, ct);
return Created($"/api/v1/rfqs/{rfqId}/quotations/{dto.QuotationId}", dto);
}
- [HttpGet("{rfqId:long}/comparison")]
+ [HttpGet("{rfqId:int}/comparison")]
[ProducesResponseType(typeof(RfqComparisonDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> Comparison(long rfqId, CancellationToken ct)
+ public async Task> Comparison(int rfqId, CancellationToken ct)
=> Ok(await _rfqs.GetComparisonAsync(rfqId, ct));
}
diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
new file mode 100644
index 0000000..7952ba6
--- /dev/null
+++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
@@ -0,0 +1,26 @@
+using ERPCore.Dtos.Stock;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Stock-adjustment endpoints (docs/11 §5.5).
+[Route("api/v1/stock-adjustments")]
+public sealed class StockAdjustmentsController : ApiControllerBase
+{
+ private readonly IAdjustmentService _adjustments;
+
+ public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
+
+ /// Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).
+ [HttpPost]
+ [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create([FromBody] CreateAdjustmentRequest request, CancellationToken ct)
+ {
+ var dto = await _adjustments.CreateAsync(request, ct);
+ return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs
new file mode 100644
index 0000000..652942a
--- /dev/null
+++ b/Backend/ERPCore/Controllers/StockController.cs
@@ -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;
+
+/// Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).
+[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> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
+ => Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
+
+ [HttpGet("ledger")]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> Ledger(
+ [FromQuery] int? itemId, [FromQuery] int? 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> Valuation([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
+ => Ok(await _stock.GetValuationAsync(itemId, warehouseId, ct));
+
+ /// Items at/below their reorder point (FR-STK-10), computed on read.
+ [HttpGet("reorder-alerts")]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> ReorderAlerts(
+ [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
+
+ /// Create a draft requisition for an item's suggested reorder quantity.
+ [HttpPost("reorder-alerts/{itemId:int}/requisition")]
+ [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> SuggestRequisition(
+ int itemId, [FromQuery] int warehouseId, CancellationToken ct)
+ {
+ var dto = await _reorder.CreateSuggestedRequisitionAsync(itemId, warehouseId, ct);
+ return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs
new file mode 100644
index 0000000..008ca79
--- /dev/null
+++ b/Backend/ERPCore/Controllers/StockCountsController.cs
@@ -0,0 +1,49 @@
+using ERPCore.Dtos.Stock;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Stock-count endpoints (docs/11 §5.6).
+[Route("api/v1/stock-counts")]
+public sealed class StockCountsController : ApiControllerBase
+{
+ private readonly ICountService _counts;
+
+ public StockCountsController(ICountService counts) => _counts = counts;
+
+ [HttpGet("{countId:int}")]
+ [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int countId, CancellationToken ct)
+ {
+ var dto = await _counts.GetAsync(countId, ct);
+ return dto is null ? NotFound() : Ok(dto);
+ }
+
+ /// Create a count with system quantities snapshotted (immutable).
+ [HttpPost]
+ [ProducesResponseType(typeof(CountDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create([FromBody] CreateCountRequest request, CancellationToken ct)
+ {
+ var dto = await _counts.CreateAsync(request, ct);
+ return Created($"/api/v1/stock-counts/{dto.CountId}", dto);
+ }
+
+ /// Enter counted quantities; variance = counted − system.
+ [HttpPut("{countId:int}/counts")]
+ [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> EnterCounts(int countId, [FromBody] EnterCountsRequest request, CancellationToken ct)
+ => Ok(await _counts.EnterCountsAsync(countId, request, ct));
+
+ /// Post: emit a variance adjustment and close the count.
+ [HttpPost("{countId:int}/post")]
+ [ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Post(int countId, CancellationToken ct)
+ => Ok(await _counts.PostAsync(countId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs
new file mode 100644
index 0000000..fd5f52b
--- /dev/null
+++ b/Backend/ERPCore/Controllers/StockTransfersController.cs
@@ -0,0 +1,49 @@
+using ERPCore.Dtos.Stock;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Stock-transfer endpoints (docs/11 §5.4).
+[Route("api/v1/stock-transfers")]
+public sealed class StockTransfersController : ApiControllerBase
+{
+ private readonly ITransferService _transfers;
+
+ public StockTransfersController(ITransferService transfers) => _transfers = transfers;
+
+ [HttpGet("{transferId:int}")]
+ [ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int 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> Create([FromBody] CreateTransferRequest request, CancellationToken ct)
+ {
+ var dto = await _transfers.CreateAsync(request, ct);
+ return Created($"/api/v1/stock-transfers/{dto.TransferId}", dto);
+ }
+
+ /// Dispatch: consume source FIFO layers into in-transit. 409 STOCK_NEGATIVE_BLOCKED if short.
+ [HttpPost("{transferId:int}/dispatch")]
+ [ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Dispatch(int transferId, CancellationToken ct)
+ => Ok(await _transfers.DispatchAsync(transferId, ct));
+
+ /// Receive: create the destination layer at the inherited cost (cost-preserving).
+ [HttpPost("{transferId:int}/receive")]
+ [ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Receive(int transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct)
+ => Ok(await _transfers.ReceiveAsync(transferId, request, ct));
+}
diff --git a/Backend/ERPCore/Controllers/VendorsController.cs b/Backend/ERPCore/Controllers/VendorsController.cs
index 40b17b3..ee1021d 100644
--- a/Backend/ERPCore/Controllers/VendorsController.cs
+++ b/Backend/ERPCore/Controllers/VendorsController.cs
@@ -20,10 +20,10 @@ public sealed class VendorsController : ApiControllerBase
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _vendors.ListAsync(query, status, ct));
- [HttpGet("{vendorId:long}")]
+ [HttpGet("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> GetById(long vendorId, CancellationToken ct)
+ public async Task> GetById(int vendorId, CancellationToken ct)
{
var result = await _vendors.GetAsync(vendorId, ct);
if (result is null) return NotFound();
@@ -42,11 +42,11 @@ public sealed class VendorsController : ApiControllerBase
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
}
- [HttpPut("{vendorId:long}")]
+ [HttpPut("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
- public async Task> Update(long vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct)
+ public async Task> Update(int vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _vendors.UpdateAsync(vendorId, request, expected, ct);
@@ -54,10 +54,10 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value);
}
- [HttpPatch("{vendorId:long}/status")]
+ [HttpPatch("{vendorId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task SetStatus(long vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct)
+ public async Task SetStatus(int vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct)
{
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
return NoContent();
diff --git a/Backend/ERPCore/Controllers/WarehousesController.cs b/Backend/ERPCore/Controllers/WarehousesController.cs
index 4c2eb5c..ff3304b 100644
--- a/Backend/ERPCore/Controllers/WarehousesController.cs
+++ b/Backend/ERPCore/Controllers/WarehousesController.cs
@@ -18,10 +18,10 @@ public sealed class WarehousesController : ApiControllerBase
public async Task>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, ct));
- [HttpGet("{warehouseId:long}")]
+ [HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> GetById(long warehouseId, CancellationToken ct)
+ public async Task> GetById(int warehouseId, CancellationToken ct)
{
var dto = await _warehouses.GetAsync(warehouseId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -36,17 +36,17 @@ public sealed class WarehousesController : ApiControllerBase
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
}
- [HttpGet("{warehouseId:long}/bins")]
+ [HttpGet("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> ListBins(long warehouseId, CancellationToken ct)
+ public async Task>> ListBins(int warehouseId, CancellationToken ct)
=> Ok(await _warehouses.ListBinsAsync(warehouseId, ct));
- [HttpPost("{warehouseId:long}/bins")]
+ [HttpPost("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(BinDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
- public async Task> CreateBin(long warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct)
+ public async Task> CreateBin(int warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct)
{
var dto = await _warehouses.CreateBinAsync(warehouseId, request, ct);
return Created($"/api/v1/warehouses/{warehouseId}/bins/{dto.BinId}", dto);
diff --git a/Backend/ERPCore/Domain/Entities/AuditLog.cs b/Backend/ERPCore/Domain/Entities/AuditLog.cs
new file mode 100644
index 0000000..c139a8c
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/AuditLog.cs
@@ -0,0 +1,23 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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 ).
+/// Written automatically by ErpDbContext.SaveChangesAsync. Append-only at the
+/// app level; DB-role revocation of UPDATE/DELETE is deferred hardening (B.3).
+/// Model: docs/10 Part C.7.
+///
+public class AuditLog
+{
+ public int AuditId { get; set; }
+ public int UserId { get; set; }
+ public string EntityType { get; set; } = string.Empty;
+ public int EntityId { get; set; }
+ public AuditAction Action { get; set; }
+ /// JSON change set: field→value (create/delete) or field→{old,new} (update).
+ public string ChangeSet { get; set; } = "{}";
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Batch.cs b/Backend/ERPCore/Domain/Entities/Batch.cs
new file mode 100644
index 0000000..03ba570
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Batch.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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.
+///
+public class Batch
+{
+ public int BatchId { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public string BatchNo { get; set; } = string.Empty;
+ public DateOnly? ExpiryDate { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Bin.cs b/Backend/ERPCore/Domain/Entities/Bin.cs
index 331fce1..1d252e3 100644
--- a/Backend/ERPCore/Domain/Entities/Bin.cs
+++ b/Backend/ERPCore/Domain/Entities/Bin.cs
@@ -6,9 +6,9 @@ namespace ERPCore.Domain.Entities;
///
public class Bin
{
- public long BinId { get; set; }
+ public int BinId { get; set; }
- public long WarehouseId { get; set; }
+ public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public string Code { get; set; } = string.Empty;
diff --git a/Backend/ERPCore/Domain/Entities/Category.cs b/Backend/ERPCore/Domain/Entities/Category.cs
index 5285373..6c34b87 100644
--- a/Backend/ERPCore/Domain/Entities/Category.cs
+++ b/Backend/ERPCore/Domain/Entities/Category.cs
@@ -6,10 +6,10 @@ namespace ERPCore.Domain.Entities;
///
public class Category
{
- public long CategoryId { get; set; }
+ public int CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
- public long? ParentId { get; set; }
+ public int? ParentId { get; set; }
public Category? Parent { get; set; }
public ICollection Children { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/Grn.cs b/Backend/ERPCore/Domain/Entities/Grn.cs
new file mode 100644
index 0000000..5642f1b
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Grn.cs
@@ -0,0 +1,37 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Goods Receipt Note header (FR-GRN-01/02). Raised against a PO or direct
+/// ( null). On confirm each line creates a FIFO layer and posts
+/// an inbound ledger entry. Mutable aggregate with an
+/// concurrency token (docs/10 C.10). Model: docs/10 Part C.3.
+///
+public class Grn
+{
+ public int GrnId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int? PoId { get; set; }
+ public PurchaseOrder? PurchaseOrder { get; set; }
+
+ public int VendorId { get; set; }
+ public Vendor? Vendor { get; set; }
+
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public GrnStatus Status { get; set; } = GrnStatus.Draft;
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public DateTime? PostedAt { get; set; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token.
+ public uint RowVersion { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs
new file mode 100644
index 0000000..5805127
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs
@@ -0,0 +1,37 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// GRN line (FR-GRN-04..08). is the PO-derived cost for
+/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
+/// for direct receipts. = qty × unitCost.
+/// gates issuability. Model: docs/10 Part C.3.
+///
+public class GrnLine
+{
+ public int GrnLineId { get; set; }
+
+ public int GrnId { get; set; }
+ public Grn? Grn { get; set; }
+
+ public int? PoLineId { get; set; }
+ public PoLine? PoLine { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ public int? BinId { get; set; }
+ public Bin? Bin { get; set; }
+
+ public int? 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;
+}
diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs
index 4083f57..f308dd3 100644
--- a/Backend/ERPCore/Domain/Entities/Item.cs
+++ b/Backend/ERPCore/Domain/Entities/Item.cs
@@ -9,18 +9,18 @@ namespace ERPCore.Domain.Entities;
///
public class Item
{
- public long ItemId { get; set; }
+ public int ItemId { get; set; }
public string Sku { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
- public long CategoryId { get; set; }
+ public int CategoryId { get; set; }
public Category? Category { get; set; }
- public long BaseUomId { get; set; }
+ public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
- public long? DefaultVendorId { get; set; }
+ public int? DefaultVendorId { get; set; }
public Vendor? DefaultVendor { get; set; }
public ItemType ItemType { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/ItemReorder.cs b/Backend/ERPCore/Domain/Entities/ItemReorder.cs
index 8a3ecfe..65350c1 100644
--- a/Backend/ERPCore/Domain/Entities/ItemReorder.cs
+++ b/Backend/ERPCore/Domain/Entities/ItemReorder.cs
@@ -7,12 +7,12 @@ namespace ERPCore.Domain.Entities;
///
public class ItemReorder
{
- public long ReorderId { get; set; }
+ public int ReorderId { get; set; }
- public long ItemId { get; set; }
+ public int ItemId { get; set; }
public Item? Item { get; set; }
- public long WarehouseId { get; set; }
+ public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal ReorderPoint { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/JournalEntryStub.cs b/Backend/ERPCore/Domain/Entities/JournalEntryStub.cs
new file mode 100644
index 0000000..b9dc74f
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/JournalEntryStub.cs
@@ -0,0 +1,18 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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.
+///
+public class JournalEntryStub
+{
+ public int JournalId { get; set; }
+ public string SourceDocType { get; set; } = string.Empty;
+ public int SourceDocId { get; set; }
+ public string DebitAccount { get; set; } = string.Empty;
+ public string CreditAccount { get; set; } = string.Empty;
+ public decimal Amount { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/NumberSequence.cs b/Backend/ERPCore/Domain/Entities/NumberSequence.cs
index 6db8de6..c4f7ffc 100644
--- a/Backend/ERPCore/Domain/Entities/NumberSequence.cs
+++ b/Backend/ERPCore/Domain/Entities/NumberSequence.cs
@@ -8,8 +8,8 @@ namespace ERPCore.Domain.Entities;
///
public class NumberSequence
{
- public long SequenceId { get; set; }
+ public int SequenceId { get; set; }
public string DocType { get; set; } = string.Empty;
public int Year { get; set; }
- public long LastNumber { get; set; }
+ public int LastNumber { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs
index b564499..1f38169 100644
--- a/Backend/ERPCore/Domain/Entities/PoLine.cs
+++ b/Backend/ERPCore/Domain/Entities/PoLine.cs
@@ -7,18 +7,18 @@ namespace ERPCore.Domain.Entities;
///
public class PoLine
{
- public long PoLineId { get; set; }
+ public int PoLineId { get; set; }
- public long PoId { get; set; }
+ public int PoId { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
- public long ItemId { get; set; }
+ public int ItemId { get; set; }
public Item? Item { get; set; }
- public long UomId { get; set; }
+ public int UomId { get; set; }
public Uom? Uom { get; set; }
- public long WarehouseId { get; set; }
+ public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs
index 257f67d..a94c0a1 100644
--- a/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs
+++ b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs
@@ -11,19 +11,19 @@ namespace ERPCore.Domain.Entities;
///
public class PurchaseOrder
{
- public long PoId { get; set; }
+ public int PoId { get; set; }
public string DocNo { get; set; } = string.Empty;
- public long VendorId { get; set; }
+ public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
- public long? RequisitionId { get; set; }
+ public int? RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
public bool ApprovalRequired { get; set; }
- public long CreatedBy { get; set; }
+ public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs b/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs
new file mode 100644
index 0000000..a663950
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs
@@ -0,0 +1,32 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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.
+///
+public class PurchaseReturn
+{
+ public int ReturnId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int VendorId { get; set; }
+ public Vendor? Vendor { get; set; }
+
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public int ReasonCodeId { get; set; }
+ public ReasonCode? ReasonCode { get; set; }
+
+ public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/PurchaseReturnLine.cs b/Backend/ERPCore/Domain/Entities/PurchaseReturnLine.cs
new file mode 100644
index 0000000..9fa5f98
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PurchaseReturnLine.cs
@@ -0,0 +1,21 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Purchase-return line (FR-PROC-08) referencing the original GRN line for
+/// traceability. is in base UOM. Model: docs/10 Part C.2.
+///
+public class PurchaseReturnLine
+{
+ public int ReturnLineId { get; set; }
+
+ public int ReturnId { get; set; }
+ public PurchaseReturn? Return { get; set; }
+
+ public int? GrnLineId { get; set; }
+ public GrnLine? GrnLine { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public decimal Qty { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/ReasonCode.cs b/Backend/ERPCore/Domain/Entities/ReasonCode.cs
new file mode 100644
index 0000000..7bb34e0
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ReasonCode.cs
@@ -0,0 +1,15 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Configurable reason code for adjustments, returns and count variances
+/// (FR-X-04). Model: docs/10 Part C.7.
+///
+public class ReasonCode
+{
+ public int ReasonCodeId { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public ReasonContext Context { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Requisition.cs b/Backend/ERPCore/Domain/Entities/Requisition.cs
index e6cfda6..29103ca 100644
--- a/Backend/ERPCore/Domain/Entities/Requisition.cs
+++ b/Backend/ERPCore/Domain/Entities/Requisition.cs
@@ -8,10 +8,10 @@ namespace ERPCore.Domain.Entities;
///
public class Requisition
{
- public long RequisitionId { get; set; }
+ public int RequisitionId { get; set; }
public string DocNo { get; set; } = string.Empty;
- public long RequestedBy { get; set; }
+ public int RequestedBy { get; set; }
public User? Requester { get; set; }
public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft;
diff --git a/Backend/ERPCore/Domain/Entities/RequisitionLine.cs b/Backend/ERPCore/Domain/Entities/RequisitionLine.cs
index 13afc08..ff1bd80 100644
--- a/Backend/ERPCore/Domain/Entities/RequisitionLine.cs
+++ b/Backend/ERPCore/Domain/Entities/RequisitionLine.cs
@@ -3,12 +3,12 @@ namespace ERPCore.Domain.Entities;
/// Requisition line (FR-PROC-01). Model: docs/10 Part C.2.
public class RequisitionLine
{
- public long ReqLineId { get; set; }
+ public int ReqLineId { get; set; }
- public long RequisitionId { get; set; }
+ public int RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
- public long ItemId { get; set; }
+ public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Rfq.cs b/Backend/ERPCore/Domain/Entities/Rfq.cs
index e7177fd..5a4e5c1 100644
--- a/Backend/ERPCore/Domain/Entities/Rfq.cs
+++ b/Backend/ERPCore/Domain/Entities/Rfq.cs
@@ -8,10 +8,10 @@ namespace ERPCore.Domain.Entities;
///
public class Rfq
{
- public long RfqId { get; set; }
+ public int RfqId { get; set; }
public string DocNo { get; set; } = string.Empty;
- public long RequisitionId { get; set; }
+ public int RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public RfqStatus Status { get; set; } = RfqStatus.Open;
diff --git a/Backend/ERPCore/Domain/Entities/RfqLine.cs b/Backend/ERPCore/Domain/Entities/RfqLine.cs
index a91191b..9b73ca5 100644
--- a/Backend/ERPCore/Domain/Entities/RfqLine.cs
+++ b/Backend/ERPCore/Domain/Entities/RfqLine.cs
@@ -3,12 +3,12 @@ namespace ERPCore.Domain.Entities;
/// RFQ line — an item + quantity being quoted (FR-PROC-02). Model: docs/10 Part C.2.
public class RfqLine
{
- public long RfqLineId { get; set; }
+ public int RfqLineId { get; set; }
- public long RfqId { get; set; }
+ public int RfqId { get; set; }
public Rfq? Rfq { get; set; }
- public long ItemId { get; set; }
+ public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Serial.cs b/Backend/ERPCore/Domain/Entities/Serial.cs
new file mode 100644
index 0000000..a4d1876
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Serial.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04).
+/// Model: docs/10 Part C.4.
+///
+public class Serial
+{
+ public int SerialId { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public string SerialNo { get; set; } = string.Empty;
+ public string Status { get; set; } = "InStock";
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustment.cs b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs
new file mode 100644
index 0000000..be583d4
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs
@@ -0,0 +1,31 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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 token (docs/10 C.10).
+/// Model: docs/10 Part C.6.
+///
+public class StockAdjustment
+{
+ public int AdjustmentId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public int ReasonCodeId { get; set; }
+ public ReasonCode? ReasonCode { get; set; }
+
+ public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public uint RowVersion { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs
new file mode 100644
index 0000000..8bbc1ad
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs
@@ -0,0 +1,23 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Adjustment line (FR-STK-07). is a signed base-UOM
+/// quantity: negative consumes FIFO layers, positive creates a layer at last cost.
+/// Model: docs/10 Part C.6.
+///
+public class StockAdjustmentLine
+{
+ public int AdjLineId { get; set; }
+
+ public int AdjustmentId { get; set; }
+ public StockAdjustment? Adjustment { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public int? BinId { get; set; }
+ public int? BatchId { get; set; }
+ public int? SerialId { get; set; }
+
+ public decimal QtyDelta { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockCount.cs b/Backend/ERPCore/Domain/Entities/StockCount.cs
new file mode 100644
index 0000000..2a2a118
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StockCount.cs
@@ -0,0 +1,29 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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 token.
+/// Model: docs/10 Part C.6.
+///
+public class StockCount
+{
+ public int CountId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public CountType CountType { get; set; }
+ public CountStatus Status { get; set; } = CountStatus.Draft;
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public uint RowVersion { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockCountLine.cs b/Backend/ERPCore/Domain/Entities/StockCountLine.cs
new file mode 100644
index 0000000..debcfb7
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StockCountLine.cs
@@ -0,0 +1,22 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Count line (FR-STK-08). is the immutable snapshot;
+/// = counted − system (in base UOM). Model: docs/10 Part C.6.
+///
+public class StockCountLine
+{
+ public int CountLineId { get; set; }
+
+ public int CountId { get; set; }
+ public StockCount? Count { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public int? BinId { get; set; }
+
+ public decimal SystemQty { get; set; }
+ public decimal? CountedQty { get; set; }
+ public decimal? Variance { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockLayer.cs b/Backend/ERPCore/Domain/Entities/StockLayer.cs
new file mode 100644
index 0000000..b4d1100
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StockLayer.cs
@@ -0,0 +1,33 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// FIFO cost layer — a quantity received at a specific unit cost, consumed
+/// oldest-first (FR-STK-02). Keyed per item **per warehouse**; quantities and
+/// are in the item's base UOM. Answers valuation
+/// ("what's on hand and at what cost"). Model: docs/10 Part C.5.
+///
+public class StockLayer
+{
+ public int LayerId { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ public int? BatchId { get; set; }
+ public Batch? Batch { get; set; }
+
+ public int? SerialId { get; set; }
+ public Serial? Serial { get; set; }
+
+ /// Originating GRN line — carries the inspection hold status for this stock.
+ public int? 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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockLedger.cs b/Backend/ERPCore/Domain/Entities/StockLedger.cs
new file mode 100644
index 0000000..88d3997
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StockLedger.cs
@@ -0,0 +1,32 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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
+/// / (no hard FK per type) so
+/// new transaction types write here without a schema change. Model: docs/10 Part C.5.
+///
+public class StockLedger
+{
+ public int LedgerId { get; set; }
+
+ public int ItemId { get; set; }
+ public int WarehouseId { get; set; }
+ public int? BinId { get; set; }
+ public int? BatchId { get; set; }
+ public int? SerialId { get; set; }
+ public int 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 int SourceDocId { get; set; }
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockTransfer.cs b/Backend/ERPCore/Domain/Entities/StockTransfer.cs
new file mode 100644
index 0000000..0a17c89
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StockTransfer.cs
@@ -0,0 +1,31 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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
+/// token (docs/10 C.10). Model: docs/10 Part C.6.
+///
+public class StockTransfer
+{
+ public int TransferId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int SrcWarehouseId { get; set; }
+ public Warehouse? SrcWarehouse { get; set; }
+
+ public int DestWarehouseId { get; set; }
+ public Warehouse? DestWarehouse { get; set; }
+
+ public TransferStatus Status { get; set; } = TransferStatus.Draft;
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public uint RowVersion { get; set; }
+
+ public ICollection Lines { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/StockTransferLine.cs b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs
new file mode 100644
index 0000000..240d815
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs
@@ -0,0 +1,35 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Transfer line (FR-STK-05/06). is in base UOM.
+///
+/// Deviation note: and extend
+/// docs/10 Part C.6's STOCK_TRANSFER_LINE 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 ).
+///
+///
+public class StockTransferLine
+{
+ public int TransferLineId { get; set; }
+
+ public int TransferId { get; set; }
+ public StockTransfer? Transfer { get; set; }
+
+ public int ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public int? SrcBinId { get; set; }
+ public int? DestBinId { get; set; }
+ public int? BatchId { get; set; }
+ public int? SerialId { get; set; }
+
+ public decimal Qty { get; set; }
+
+ /// Value-weighted unit cost of the consumed source layers (set at dispatch).
+ public decimal? UnitCost { get; set; }
+
+ /// Quantity already received at the destination (partial-receive support).
+ public decimal QtyReceived { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Uom.cs b/Backend/ERPCore/Domain/Entities/Uom.cs
index 2200e55..485b16a 100644
--- a/Backend/ERPCore/Domain/Entities/Uom.cs
+++ b/Backend/ERPCore/Domain/Entities/Uom.cs
@@ -6,6 +6,6 @@ namespace ERPCore.Domain.Entities;
///
public class Uom
{
- public long UomId { get; set; }
+ public int UomId { get; set; }
public string Name { get; set; } = string.Empty;
}
diff --git a/Backend/ERPCore/Domain/Entities/UomConversion.cs b/Backend/ERPCore/Domain/Entities/UomConversion.cs
index fa76ef4..f44a99d 100644
--- a/Backend/ERPCore/Domain/Entities/UomConversion.cs
+++ b/Backend/ERPCore/Domain/Entities/UomConversion.cs
@@ -7,15 +7,15 @@ namespace ERPCore.Domain.Entities;
///
public class UomConversion
{
- public long ConversionId { get; set; }
+ public int ConversionId { get; set; }
- public long ItemId { get; set; }
+ public int ItemId { get; set; }
public Item? Item { get; set; }
- public long FromUomId { get; set; }
+ public int FromUomId { get; set; }
public Uom? FromUom { get; set; }
- public long ToUomId { get; set; }
+ public int ToUomId { get; set; }
public Uom? ToUom { get; set; }
public decimal Factor { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/User.cs b/Backend/ERPCore/Domain/Entities/User.cs
index 24b58db..ddd0048 100644
--- a/Backend/ERPCore/Domain/Entities/User.cs
+++ b/Backend/ERPCore/Domain/Entities/User.cs
@@ -3,18 +3,23 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
///
-/// Application user (FR-X-01). In Phase 1 authentication/RBAC are deferred; this
-/// table exists so mutations can be stamped with an audit actor and documents can
-/// carry a `createdBy`/`requestedBy` FK. A seeded system user (id 1) is the
-/// fallback actor until `/auth/login` lands (§6). Model: docs/10 Part C.7.
+/// Application user (FR-X-01) — a **local shadow/projection** of an AuthHex identity.
+/// The local (int) is what every `createdBy`/`requestedBy`/
+/// audit/ledger FK references; maps it to the AuthHex
+/// UserId (GUID) and is JIT-provisioned on first authenticated request
+/// (docs/10 A.4/C.7). A seeded system user (id 1, null AuthUserId) is the
+/// fallback actor for unauthenticated/system operations. Model: docs/10 Part C.7.
///
public class User
{
- /// Seeded fallback actor used while auth is deferred.
- public const long SystemUserId = 1;
+ /// Seeded fallback actor for unauthenticated/system operations.
+ public const int SystemUserId = 1;
- public long UserId { get; set; }
+ public int UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
+
+ /// AuthHex identity (token UserId GUID); null for the seeded system user.
+ public Guid? AuthUserId { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Entities/Vendor.cs b/Backend/ERPCore/Domain/Entities/Vendor.cs
index 22d1e09..05a85d3 100644
--- a/Backend/ERPCore/Domain/Entities/Vendor.cs
+++ b/Backend/ERPCore/Domain/Entities/Vendor.cs
@@ -9,7 +9,7 @@ namespace ERPCore.Domain.Entities;
///
public class Vendor
{
- public long VendorId { get; set; }
+ public int VendorId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Terms { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/VendorQuotation.cs b/Backend/ERPCore/Domain/Entities/VendorQuotation.cs
index 5eab1f4..f8ba021 100644
--- a/Backend/ERPCore/Domain/Entities/VendorQuotation.cs
+++ b/Backend/ERPCore/Domain/Entities/VendorQuotation.cs
@@ -12,12 +12,12 @@ namespace ERPCore.Domain.Entities;
///
public class VendorQuotation
{
- public long QuotationId { get; set; }
+ public int QuotationId { get; set; }
- public long RfqId { get; set; }
+ public int RfqId { get; set; }
public Rfq? Rfq { get; set; }
- public long VendorId { get; set; }
+ public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public DateTime CreatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs b/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs
index 6880524..20e6a1f 100644
--- a/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs
+++ b/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs
@@ -3,12 +3,12 @@ namespace ERPCore.Domain.Entities;
/// Per-item quoted price and lead time within a (docs/11 §3.2).
public class VendorQuotationLine
{
- public long QuotationLineId { get; set; }
+ public int QuotationLineId { get; set; }
- public long QuotationId { get; set; }
+ public int QuotationId { get; set; }
public VendorQuotation? Quotation { get; set; }
- public long ItemId { get; set; }
+ public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal UnitPrice { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/Warehouse.cs b/Backend/ERPCore/Domain/Entities/Warehouse.cs
index fe7a402..bbcd8bf 100644
--- a/Backend/ERPCore/Domain/Entities/Warehouse.cs
+++ b/Backend/ERPCore/Domain/Entities/Warehouse.cs
@@ -6,7 +6,7 @@ namespace ERPCore.Domain.Entities;
///
public class Warehouse
{
- public long WarehouseId { get; set; }
+ public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
diff --git a/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs b/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs
new file mode 100644
index 0000000..7d56708
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs
@@ -0,0 +1,13 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so
+/// is reserved for the future threshold-approval
+/// workflow (FR-STK-07). Stored as a string.
+///
+public enum AdjustmentStatus
+{
+ Draft,
+ PendingApproval,
+ Posted
+}
diff --git a/Backend/ERPCore/Domain/Enums/AuditAction.cs b/Backend/ERPCore/Domain/Enums/AuditAction.cs
new file mode 100644
index 0000000..f7644e8
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/AuditAction.cs
@@ -0,0 +1,9 @@
+namespace ERPCore.Domain.Enums;
+
+/// Kind of mutation recorded in the audit trail (FR-X-02). Stored as a string.
+public enum AuditAction
+{
+ Create,
+ Update,
+ Delete
+}
diff --git a/Backend/ERPCore/Domain/Enums/CountStatus.cs b/Backend/ERPCore/Domain/Enums/CountStatus.cs
new file mode 100644
index 0000000..8e3e958
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/CountStatus.cs
@@ -0,0 +1,9 @@
+namespace ERPCore.Domain.Enums;
+
+/// Stock-count lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.
+public enum CountStatus
+{
+ Draft,
+ Counted,
+ Posted
+}
diff --git a/Backend/ERPCore/Domain/Enums/CountType.cs b/Backend/ERPCore/Domain/Enums/CountType.cs
new file mode 100644
index 0000000..29015d8
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/CountType.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+/// Physical-count scope (docs/11 §8; FR-STK-08). Stored as a string.
+public enum CountType
+{
+ Cycle,
+ Full
+}
diff --git a/Backend/ERPCore/Domain/Enums/Direction.cs b/Backend/ERPCore/Domain/Enums/Direction.cs
new file mode 100644
index 0000000..afee991
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/Direction.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+/// Stock-ledger movement direction (docs/11 §8). Stored as a string.
+public enum Direction
+{
+ In,
+ Out
+}
diff --git a/Backend/ERPCore/Domain/Enums/GrnStatus.cs b/Backend/ERPCore/Domain/Enums/GrnStatus.cs
new file mode 100644
index 0000000..550cca2
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/GrnStatus.cs
@@ -0,0 +1,9 @@
+namespace ERPCore.Domain.Enums;
+
+/// Goods-receipt-note lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.
+public enum GrnStatus
+{
+ Draft,
+ Confirmed,
+ Closed
+}
diff --git a/Backend/ERPCore/Domain/Enums/HoldStatus.cs b/Backend/ERPCore/Domain/Enums/HoldStatus.cs
new file mode 100644
index 0000000..e38dd1d
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/HoldStatus.cs
@@ -0,0 +1,12 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Inspection-hold state of received stock (docs/11 §8; FR-GRN-05).
+/// stock is on-hand but not issuable until released (FR-WH-07). Stored as a string.
+///
+public enum HoldStatus
+{
+ Available,
+ OnHold,
+ Rejected
+}
diff --git a/Backend/ERPCore/Domain/Enums/ReasonContext.cs b/Backend/ERPCore/Domain/Enums/ReasonContext.cs
new file mode 100644
index 0000000..b569040
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/ReasonContext.cs
@@ -0,0 +1,9 @@
+namespace ERPCore.Domain.Enums;
+
+/// Where a reason code applies (FR-X-04; docs/10 §B.8.3). Stored as a string.
+public enum ReasonContext
+{
+ Adjustment,
+ Return,
+ Count
+}
diff --git a/Backend/ERPCore/Domain/Enums/ReturnStatus.cs b/Backend/ERPCore/Domain/Enums/ReturnStatus.cs
new file mode 100644
index 0000000..6214b0b
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/ReturnStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+/// Purchase-return lifecycle (docs/11 §3.4). Auto-posts in Phase 1. Stored as a string.
+public enum ReturnStatus
+{
+ Draft,
+ Posted
+}
diff --git a/Backend/ERPCore/Domain/Enums/TransferStatus.cs b/Backend/ERPCore/Domain/Enums/TransferStatus.cs
new file mode 100644
index 0000000..0f96724
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/TransferStatus.cs
@@ -0,0 +1,10 @@
+namespace ERPCore.Domain.Enums;
+
+/// Stock-transfer lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.
+public enum TransferStatus
+{
+ Draft,
+ InTransit,
+ Received,
+ Closed
+}
diff --git a/Backend/ERPCore/Dtos/Audit/AuditDtos.cs b/Backend/ERPCore/Dtos/Audit/AuditDtos.cs
new file mode 100644
index 0000000..1691243
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Audit/AuditDtos.cs
@@ -0,0 +1,12 @@
+using System.Text.Json;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Audit;
+
+/// An audit-trail entry (FR-X-02). ChangeSet is the stored JSON, inlined.
+public sealed record AuditLogDto(
+ int AuditId, int UserId, string EntityType, int EntityId, AuditAction Action, JsonElement ChangeSet, DateTime CreatedAt);
+
+/// A GL-ready journal stub emitted per stock movement (FR-STK-13).
+public sealed record JournalEntryStubDto(
+ int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
diff --git a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs
index 856e23b..744026e 100644
--- a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs
+++ b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs
@@ -3,13 +3,13 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Categories;
/// Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).
-public sealed record CategoryDto(long CategoryId, string Name, long? ParentId);
+public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
/// Nested category node for GET /categories?tree=true.
-public sealed record CategoryTreeDto(long CategoryId, string Name, long? ParentId, IReadOnlyList Children);
+public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList Children);
public sealed class CreateCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
- public long? ParentId { get; set; }
+ public int? ParentId { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
new file mode 100644
index 0000000..bdcff2b
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
@@ -0,0 +1,63 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Grn;
+
+// Responses (docs/11 §4) --------------------------------------------------------
+
+public sealed record GrnLineDto(
+ int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
+ decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
+
+public sealed record GrnDto(
+ int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
+ int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList Lines);
+
+public sealed record CreatedLayerDto(
+ int LayerId, int ItemId, int WarehouseId, int? BatchId,
+ decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
+
+public sealed record GrnConfirmResultDto(
+ int GrnId, GrnStatus Status, DateTime PostedAt,
+ IReadOnlyList CreatedLayers, IReadOnlyList LedgerRefs, PurchaseOrderStatus? PoStatus);
+
+public sealed record ReleaseLineResultDto(int 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
+{
+ /// Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).
+ public int? PoLineId { get; set; }
+ [Required] public int ItemId { get; set; }
+ [Required] public int UomId { get; set; }
+ public int? BinId { get; set; }
+ [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
+ /// Used only for direct (no-PO) receipts; ignored when is set.
+ [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
+{
+ /// PO to receive against; null for a direct/emergency receipt (FR-GRN-02).
+ public int? PoId { get; set; }
+ /// Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).
+ public int? VendorId { get; set; }
+ [Required] public int WarehouseId { get; set; }
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
+
+public sealed class ReleaseLineRequest
+{
+ /// Release makes the stock available; Reject removes it from on-hand.
+ [Required, RegularExpression("Release|Reject")]
+ public string Action { get; set; } = "Release";
+}
diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
index c372cad..7bb11e7 100644
--- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs
+++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
@@ -7,25 +7,25 @@ namespace ERPCore.Dtos.Items;
/// Row shape for GET /items.
public sealed record ItemListItemDto(
- long ItemId, string Sku, string Name, long CategoryId, long BaseUomId,
- long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
+ int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
+ int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status);
/// A single per-warehouse reorder policy row.
-public sealed record ItemReorderDto(long WarehouseId, decimal ReorderPoint, decimal ReorderQty);
+public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
/// Full item resource for GET /items/{id} and create/update responses.
public sealed record ItemDetailDto(
- long ItemId, string Sku, string Name, string? Description, long CategoryId,
- long BaseUomId, long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
+ int ItemId, string Sku, string Name, string? Description, int CategoryId,
+ int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList Reorder,
DateTime CreatedAt, DateTime? UpdatedAt);
/// UOM conversion row (docs/11 §2.2).
-public sealed record UomConversionDto(long ConversionId, long FromUom, long ToUom, decimal Factor);
+public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
/// Response body for PUT /items/{id}/uom-conversions.
-public sealed record ItemUomConversionsDto(long ItemId, long BaseUomId, IReadOnlyList Conversions);
+public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList Conversions);
/// Response body for PUT /items/{id}/reorder.
public sealed record ItemReorderSettingsDto(IReadOnlyList Settings);
@@ -38,9 +38,9 @@ public sealed class CreateItemRequest
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; }
- [Required] public long CategoryId { get; set; }
- [Required] public long BaseUomId { get; set; }
- public long? DefaultVendorId { get; set; }
+ [Required] public int CategoryId { get; set; }
+ [Required] public int BaseUomId { get; set; }
+ public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
@@ -51,9 +51,9 @@ public sealed class UpdateItemRequest
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { get; set; }
- [Required] public long CategoryId { get; set; }
- [Required] public long BaseUomId { get; set; }
- public long? DefaultVendorId { get; set; }
+ [Required] public int CategoryId { get; set; }
+ [Required] public int BaseUomId { get; set; }
+ public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
@@ -66,7 +66,7 @@ public sealed class UpdateItemStatusRequest
public sealed class ReorderSettingInput
{
- [Required] public long WarehouseId { get; set; }
+ [Required] public int WarehouseId { get; set; }
[Range(0, double.MaxValue)] public decimal ReorderPoint { get; set; }
[Range(0, double.MaxValue)] public decimal ReorderQty { get; set; }
}
@@ -78,8 +78,8 @@ public sealed class UpdateReorderRequest
public sealed class UomConversionInput
{
- [Required] public long FromUom { get; set; }
- [Required] public long ToUom { get; set; }
+ [Required] public int FromUom { get; set; }
+ [Required] public int ToUom { get; set; }
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
index 2c372c8..c70e9a7 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
@@ -6,27 +6,27 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
public sealed record PoLineDto(
- long PoLineId, long ItemId, long UomId, long WarehouseId,
+ int PoLineId, int ItemId, int UomId, int WarehouseId,
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
public sealed record PurchaseOrderDto(
- long PoId, string DocNo, long VendorId, long? RequisitionId, PurchaseOrderStatus Status,
- bool ApprovalRequired, long CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
+ int PoId, string DocNo, int VendorId, int? RequisitionId, PurchaseOrderStatus Status,
+ bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
PoTotalsDto Totals, IReadOnlyList Lines);
public sealed record PurchaseOrderSummaryDto(
- long PoId, string DocNo, long VendorId, PurchaseOrderStatus Status,
+ int PoId, string DocNo, int VendorId, PurchaseOrderStatus Status,
bool ApprovalRequired, DateTime CreatedAt, PoTotalsDto Totals);
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
public sealed class CreatePoLineInput
{
- [Required] public long ItemId { get; set; }
- [Required] public long UomId { get; set; }
- [Required] public long WarehouseId { get; set; }
+ [Required] public int ItemId { get; set; }
+ [Required] public int UomId { get; set; }
+ [Required] public int WarehouseId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
[Range(0, 1)] public decimal Tax { get; set; }
@@ -34,15 +34,15 @@ public sealed class CreatePoLineInput
public sealed class CreatePurchaseOrderRequest
{
- [Required] public long VendorId { get; set; }
- public long? RequisitionId { get; set; }
+ [Required] public int VendorId { get; set; }
+ public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
public sealed class UpdatePurchaseOrderRequest
{
- [Required] public long VendorId { get; set; }
- public long? RequisitionId { get; set; }
+ [Required] public int VendorId { get; set; }
+ public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
new file mode 100644
index 0000000..f9deb75
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
@@ -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(int ReturnLineId, int? GrnLineId, int ItemId, decimal Qty);
+
+public sealed record PurchaseReturnDto(
+ int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
+ int CreatedBy, IReadOnlyList Lines, IReadOnlyList LedgerRefs);
+
+// Requests ----------------------------------------------------------------------
+
+public sealed class CreatePurchaseReturnLineInput
+{
+ /// Original GRN line, for traceability against the receipt.
+ public int? GrnLineId { get; set; }
+ [Required] public int ItemId { get; set; }
+ [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
+}
+
+public sealed class CreatePurchaseReturnRequest
+{
+ [Required] public int VendorId { get; set; }
+ [Required] public int WarehouseId { get; set; }
+ /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error.
+ public int? ReasonCodeId { get; set; }
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
index d9af44f..63b5e0a 100644
--- a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
@@ -5,20 +5,20 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.1) ------------------------------------------------------
-public sealed record RequisitionLineDto(long ReqLineId, long ItemId, decimal Qty, DateOnly? RequiredBy);
+public sealed record RequisitionLineDto(int ReqLineId, int ItemId, decimal Qty, DateOnly? RequiredBy);
public sealed record RequisitionDto(
- long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy,
+ int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy,
DateTime CreatedAt, IReadOnlyList Lines);
public sealed record RequisitionSummaryDto(
- long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy, DateTime CreatedAt);
+ int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
public sealed class CreateRequisitionLineInput
{
- [Required] public long ItemId { get; set; }
+ [Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
public DateOnly? RequiredBy { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
index 8109941..d54ab90 100644
--- a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
@@ -5,46 +5,46 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.2) ------------------------------------------------------
-public sealed record RfqLineDto(long RfqLineId, long ItemId, decimal Qty);
+public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
public sealed record RfqDto(
- long RfqId, string DocNo, long RequisitionId, RfqStatus Status, IReadOnlyList Lines);
+ int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList Lines);
-public sealed record QuotationLineDto(long ItemId, decimal UnitPrice, int LeadDays);
+public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
public sealed record VendorQuotationDto(
- long QuotationId, long RfqId, long VendorId, IReadOnlyList Lines);
+ int QuotationId, int RfqId, int VendorId, IReadOnlyList Lines);
/// Per-item, per-vendor price matrix for GET /rfqs/{id}/comparison.
-public sealed record RfqComparisonCellDto(long VendorId, long QuotationId, decimal UnitPrice, int LeadDays);
-public sealed record RfqComparisonRowDto(long ItemId, decimal Qty, IReadOnlyList Quotes);
-public sealed record RfqComparisonDto(long RfqId, IReadOnlyList VendorIds, IReadOnlyList Rows);
+public sealed record RfqComparisonCellDto(int VendorId, int QuotationId, decimal UnitPrice, int LeadDays);
+public sealed record RfqComparisonRowDto(int ItemId, decimal Qty, IReadOnlyList Quotes);
+public sealed record RfqComparisonDto(int RfqId, IReadOnlyList VendorIds, IReadOnlyList Rows);
// Requests ----------------------------------------------------------------------
public sealed class CreateRfqLineInput
{
- [Required] public long ItemId { get; set; }
+ [Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateRfqRequest
{
- [Required] public long RequisitionId { get; set; }
+ [Required] public int RequisitionId { get; set; }
/// Vendors the RFQ is issued to (validated for existence; quotations reference them).
- public List VendorIds { get; set; } = new();
+ public List VendorIds { get; set; } = new();
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
public sealed class CreateQuotationLineInput
{
- [Required] public long ItemId { get; set; }
+ [Required] public int ItemId { get; set; }
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
[Range(0, int.MaxValue)] public int LeadDays { get; set; }
}
public sealed class CreateQuotationRequest
{
- [Required] public long VendorId { get; set; }
+ [Required] public int VendorId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
}
diff --git a/Backend/ERPCore/Dtos/Reference/ReasonCodeDtos.cs b/Backend/ERPCore/Dtos/Reference/ReasonCodeDtos.cs
new file mode 100644
index 0000000..8287289
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Reference/ReasonCodeDtos.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Reference;
+
+/// Reason code (docs/11 §6).
+public sealed record ReasonCodeDto(int 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; }
+}
diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs
new file mode 100644
index 0000000..a8e9696
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs
@@ -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(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
+
+public sealed record AdjustmentDto(
+ int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
+ int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs);
+
+// Requests ----------------------------------------------------------------------
+
+public sealed class CreateAdjustmentLineInput
+{
+ [Required] public int ItemId { get; set; }
+ public int? BinId { get; set; }
+ public int? BatchId { get; set; }
+ /// Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.
+ public decimal QtyDelta { get; set; }
+}
+
+public sealed class CreateAdjustmentRequest
+{
+ [Required] public int WarehouseId { get; set; }
+ /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error, not 0.
+ public int? ReasonCodeId { get; set; }
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Stock/CountDtos.cs b/Backend/ERPCore/Dtos/Stock/CountDtos.cs
new file mode 100644
index 0000000..44a67ec
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Stock/CountDtos.cs
@@ -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(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
+
+public sealed record CountDto(
+ int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList Lines);
+
+public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList LedgerRefs);
+
+// Requests ----------------------------------------------------------------------
+
+public sealed class CreateCountRequest
+{
+ [Required] public int WarehouseId { get; set; }
+ [Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
+ [Required, MinLength(1)] public List ItemIds { get; set; } = new();
+}
+
+public sealed class EnterCountLineInput
+{
+ [Required] public int CountLineId { get; set; }
+ [Range(0, double.MaxValue)] public decimal CountedQty { get; set; }
+}
+
+public sealed class EnterCountsRequest
+{
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Stock/ReorderDtos.cs b/Backend/ERPCore/Dtos/Stock/ReorderDtos.cs
new file mode 100644
index 0000000..d325850
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Stock/ReorderDtos.cs
@@ -0,0 +1,6 @@
+namespace ERPCore.Dtos.Stock;
+
+/// An item at/below its reorder point (docs/11 §5.7; FR-STK-10). Computed on read.
+public sealed record ReorderAlertDto(
+ int ItemId, int WarehouseId, decimal Available,
+ decimal ReorderPoint, decimal ReorderQty, decimal SuggestedRequisitionQty);
diff --git a/Backend/ERPCore/Dtos/Stock/StockDtos.cs b/Backend/ERPCore/Dtos/Stock/StockDtos.cs
new file mode 100644
index 0000000..9a1b3b2
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Stock/StockDtos.cs
@@ -0,0 +1,22 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Stock;
+
+/// Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out).
+public sealed record StockOnHandDto(
+ int ItemId, int WarehouseId, decimal OnHand, decimal Available,
+ decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
+
+/// A stock-ledger row (docs/11 §5.2).
+public sealed record StockLedgerRowDto(
+ int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
+ Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
+ string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
+
+/// An open FIFO layer in a valuation (docs/11 §5.3).
+public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
+
+/// Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).
+public sealed record StockValuationDto(
+ int ItemId, int WarehouseId, IReadOnlyList Layers,
+ decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs
new file mode 100644
index 0000000..dd91237
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs
@@ -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(
+ int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
+
+public sealed record TransferDto(
+ int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
+ TransferStatus Status, IReadOnlyList Lines);
+
+public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
+
+public sealed record DispatchResultDto(
+ int TransferId, TransferStatus Status, IReadOnlyList ConsumedLayers, IReadOnlyList LedgerRefs);
+
+public sealed record TransferCreatedLayerDto(int LayerId, int WarehouseId, decimal QtyReceived, decimal UnitCost);
+
+public sealed record ReceiveResultDto(
+ int TransferId, TransferStatus Status, IReadOnlyList CreatedLayers, IReadOnlyList LedgerRefs);
+
+// Requests ----------------------------------------------------------------------
+
+public sealed class CreateTransferLineInput
+{
+ [Required] public int ItemId { get; set; }
+ public int? SrcBinId { get; set; }
+ public int? DestBinId { get; set; }
+ public int? BatchId { get; set; }
+ [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
+}
+
+public sealed class CreateTransferRequest
+{
+ [Required] public int SrcWarehouseId { get; set; }
+ [Required] public int DestWarehouseId { get; set; }
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
+
+public sealed class ReceiveTransferLineInput
+{
+ [Required] public int TransferLineId { get; set; }
+ [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
+}
+
+public sealed class ReceiveTransferRequest
+{
+ [Required, MinLength(1)] public List Lines { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Uoms/UomDtos.cs b/Backend/ERPCore/Dtos/Uoms/UomDtos.cs
index 90f5259..655fd06 100644
--- a/Backend/ERPCore/Dtos/Uoms/UomDtos.cs
+++ b/Backend/ERPCore/Dtos/Uoms/UomDtos.cs
@@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Uoms;
/// UOM resource (docs/11-BACKEND-PHASE1.md §2.2).
-public sealed record UomDto(long UomId, string Name);
+public sealed record UomDto(int UomId, string Name);
public sealed class CreateUomRequest
{
diff --git a/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs b/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs
index 731099c..36c0145 100644
--- a/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs
+++ b/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Vendors;
/// Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).
public sealed record VendorDto(
- long VendorId, string Code, string Name, string? Terms, string? TaxReg,
+ int VendorId, string Code, string Name, string? Terms, string? TaxReg,
string Currency, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest
diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
index ad91678..37dea5c 100644
--- a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
+++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs
@@ -3,10 +3,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses;
/// Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).
-public sealed record WarehouseDto(long WarehouseId, string Code, string Name);
+public sealed record WarehouseDto(int WarehouseId, string Code, string Name);
/// Bin/location resource (docs/11 §2.5).
-public sealed record BinDto(long BinId, long WarehouseId, string Code, string? BinType);
+public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
public sealed class CreateWarehouseRequest
{
diff --git a/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs b/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs
new file mode 100644
index 0000000..e35400a
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs
@@ -0,0 +1,17 @@
+namespace ERPCore.Infra.Auth;
+
+///
+/// Claim type names emitted by the AuthHex IdP (see its JwtTokenHelper).
+/// AuthHex uses no standard sub/nameid; identity is the custom
+/// (GUID). These are read verbatim (JWT bearer is configured
+/// with MapInboundClaims = false).
+///
+public static class AuthHexClaims
+{
+ public const string UserId = "UserId";
+ public const string UserTypeId = "UserTypeId";
+ public const string UserTypeCode = "UserTypeCode";
+ public const string RoleId = "RoleId";
+ public const string RoleCode = "RoleCode";
+ public const string Nic = "NIC";
+}
diff --git a/Backend/ERPCore/Infra/Auth/CurrentUser.cs b/Backend/ERPCore/Infra/Auth/CurrentUser.cs
index 06ff7ba..9e0a0b1 100644
--- a/Backend/ERPCore/Infra/Auth/CurrentUser.cs
+++ b/Backend/ERPCore/Infra/Auth/CurrentUser.cs
@@ -30,5 +30,5 @@ public sealed class CurrentUser : ICurrentUser
}
}
- public long AuditUserId => long.TryParse(UserId, out var id) ? id : User.SystemUserId;
+ public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId;
}
diff --git a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
index be7317f..1c103a2 100644
--- a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
+++ b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs
@@ -15,7 +15,7 @@ public interface ICurrentUser
/// Resolves the token sub to a user id; falls back to the seeded system
/// user () while auth is deferred (§6).
///
- long AuditUserId { get; }
+ int AuditUserId { get; }
/// True when the request carries an authenticated principal.
bool IsAuthenticated { get; }
diff --git a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
index 10ca70f..ea879ca 100644
--- a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
+++ b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
@@ -1,25 +1,42 @@
-using System.Text;
+using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace ERPCore.Infra.Auth;
///
-/// JWT bearer wiring. Authentication only — RBAC/authorization policies are
-/// deferred for Phase 1; the validated principal exists solely so that
-/// can stamp the audit actor.
+/// Auth wiring for ERPCore as a **resource server** for the external AuthHex IdP
+/// (docs/10 A.4). Validates AuthHex's **RS256** tokens against AuthHex's RSA public
+/// key (configured statically — no JWKS), issuer AuthHex, audience
+/// AuthHexClient. A single door policy () admits
+/// only ERP UserType/Role holders when those codes are configured;
+/// per-endpoint RBAC stays deferred. Identity → audit actor is resolved by
+/// + .
///
public static class JwtAuthExtensions
{
+ /// Authorization policy applied to every v1 controller (via ApiControllerBase).
+ public const string ErpAccessPolicy = "ErpAccess";
+
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
{
- var issuer = config["Jwt:Issuer"];
- var audience = config["Jwt:Audience"];
- var signingKey = config["Jwt:SigningKey"] ?? string.Empty;
+ var issuer = config["Auth:Issuer"];
+ var audience = config["Auth:Audience"];
+ var publicKeyXml = config["Auth:RsaPublicKeyXml"]
+ ?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured.");
+ var requiredUserType = config["Auth:RequiredUserTypeCode"];
+ var requiredRole = config["Auth:RequiredRoleCode"];
+
+ // AuthHex publishes no JWKS; the RSA public key is configured statically.
+ var rsa = RSA.Create();
+ rsa.FromXmlString(publicKeyXml);
+ var signingKey = new RsaSecurityKey(rsa);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
+ // Keep AuthHex's claim names verbatim (UserId, UserTypeCode, RoleCode …).
+ options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
@@ -28,12 +45,26 @@ public static class JwtAuthExtensions
ValidAudience = audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
- IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)),
+ IssuerSigningKey = signingKey,
+ ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.FromSeconds(30)
};
});
- services.AddAuthorization();
+ services.AddAuthorization(options =>
+ {
+ options.AddPolicy(ErpAccessPolicy, policy =>
+ {
+ policy.RequireAuthenticatedUser();
+ // Door gate: only enforce a UserType/Role when configured (AuthHex is a
+ // shared IdP). Empty config = require a valid ERP token only.
+ if (!string.IsNullOrWhiteSpace(requiredUserType))
+ policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType);
+ if (!string.IsNullOrWhiteSpace(requiredRole))
+ policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole);
+ });
+ });
+
return services;
}
}
diff --git a/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs b/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs
new file mode 100644
index 0000000..e163895
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs
@@ -0,0 +1,76 @@
+using System.Security.Claims;
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using ERPCore.Infra.Persistence;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.EntityFrameworkCore;
+
+namespace ERPCore.Infra.Auth;
+
+///
+/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
+/// AuthHex tokens carry the user as a custom UserId (GUID) claim and no
+/// sub/nameid. This transformation JIT-provisions a local shadow
+/// (keyed by auth_user_id) and injects the local
+/// int id as , so
+/// /AuditUserId resolve the real user unchanged.
+/// Idempotent — may run several times per request.
+///
+public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
+{
+ private readonly ErpDbContext _db;
+
+ public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db;
+
+ public async Task TransformAsync(ClaimsPrincipal principal)
+ {
+ if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
+ return principal;
+ if (identity.HasClaim(c => c.Type == ClaimTypes.NameIdentifier))
+ return principal; // already resolved this request
+
+ var raw = principal.FindFirstValue(AuthHexClaims.UserId);
+ if (!Guid.TryParse(raw, out var authUserId))
+ return principal; // no mappable identity → CurrentUser falls back to system
+
+ var nic = principal.FindFirstValue(AuthHexClaims.Nic);
+ var localId = await ResolveOrProvisionAsync(authUserId, nic);
+
+ identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString()));
+ return principal;
+ }
+
+ private async Task ResolveOrProvisionAsync(Guid authUserId, string? nic)
+ {
+ var existing = await _db.Users.AsNoTracking()
+ .Where(u => u.AuthUserId == authUserId)
+ .Select(u => u.UserId)
+ .FirstOrDefaultAsync();
+ if (existing != 0) return existing;
+
+ var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim();
+ var user = new User
+ {
+ AuthUserId = authUserId,
+ Username = label,
+ DisplayName = string.IsNullOrWhiteSpace(nic) ? "AuthHex User" : nic.Trim(),
+ Status = EntityStatus.Active
+ };
+
+ try
+ {
+ _db.Users.Add(user);
+ await _db.SaveChangesAsync();
+ return user.UserId;
+ }
+ catch (DbUpdateException)
+ {
+ // Lost a race (unique auth_user_id) — the row now exists; re-read it.
+ _db.Entry(user).State = EntityState.Detached;
+ return await _db.Users.AsNoTracking()
+ .Where(u => u.AuthUserId == authUserId)
+ .Select(u => u.UserId)
+ .FirstAsync();
+ }
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs b/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs
new file mode 100644
index 0000000..8f231b0
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs
@@ -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;
+
+/// A mutation captured before save, awaiting its (possibly generated) key.
+public sealed record PendingAudit(EntityEntry Entry, string EntityType, AuditAction Action, string ChangeSet, int CapturedId, bool IsAdded);
+
+///
+/// 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 before save so old→new is
+/// accurate; generated keys for inserts are read after save.
+///
+public static class AuditScribe
+{
+ private static readonly HashSet 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 Capture(ChangeTracker tracker)
+ {
+ var pending = new List();
+ 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, int 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 int 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.ToInt32(value);
+ }
+
+ private static string BuildChangeSet(EntityEntry entry, AuditAction action)
+ {
+ var set = new Dictionary();
+ 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 { ["old"] = p.OriginalValue, ["new"] = p.CurrentValue };
+ break;
+ }
+ }
+ return JsonSerializer.Serialize(set, Json);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs
new file mode 100644
index 0000000..a93681c
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("audit_logs");
+ builder.HasKey(a => a.AuditId);
+
+ builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80);
+ builder.Property(a => a.Action).HasConversion().HasMaxLength(10).IsRequired();
+ builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb");
+ builder.Property(a => a.CreatedAt).IsRequired();
+
+ builder.HasOne().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
+{
+ public void Configure(EntityTypeBuilder 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 });
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs
new file mode 100644
index 0000000..8de6adf
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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
+{
+ public void Configure(EntityTypeBuilder 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();
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
new file mode 100644
index 0000000..087ea19
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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
+{
+ public void Configure(EntityTypeBuilder 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().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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs
new file mode 100644
index 0000000..00add0a
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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
+{
+ public void Configure(EntityTypeBuilder 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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs
new file mode 100644
index 0000000..4746889
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().HasMaxLength(20).IsRequired();
+
+ builder.HasIndex(r => new { r.Context, r.Code }).IsUnique();
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs
new file mode 100644
index 0000000..d50a0e6
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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
+{
+ public void Configure(EntityTypeBuilder 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().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs
new file mode 100644
index 0000000..57d51a6
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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
+{
+ public void Configure(EntityTypeBuilder 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().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- ().WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().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 });
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs
new file mode 100644
index 0000000..fa44f42
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().HasMaxLength(20).IsRequired();
+ builder.Property(c => c.Status).HasConversion().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
+{
+ public void Configure(EntityTypeBuilder 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().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs
new file mode 100644
index 0000000..2aae8bb
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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
+{
+ public void Configure(EntityTypeBuilder 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().WithMany().HasForeignKey(l => l.SrcBinId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.DestBinId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs
index 7a1c48b..eee8827 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs
@@ -18,6 +18,11 @@ public sealed class UserConfiguration : IEntityTypeConfiguration
builder.Property(u => u.Status)
.HasConversion().HasMaxLength(20).IsRequired();
+ // Maps the local shadow user to its AuthHex identity (unique; NULL for the
+ // system user — Postgres allows multiple NULLs in a unique index).
+ builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
+ builder.HasIndex(u => u.AuthUserId).IsUnique();
+
// Seeded fallback audit actor while auth is deferred (§6).
builder.HasData(new User
{
diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
new file mode 100644
index 0000000..59a8803
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
@@ -0,0 +1,45 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Enums;
+using Microsoft.EntityFrameworkCore;
+
+namespace ERPCore.Infra.Persistence;
+
+///
+/// Idempotent startup seeding of configurable reference data (docs/10 §B.8.3).
+/// Reason codes are seeded at runtime (not via HasData) so the identity
+/// sequence advances normally and later admin POST /reason-codes calls
+/// cannot collide with seeded ids.
+///
+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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index d8bf5d5..6a7d7e6 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -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;
/// 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 .
///
public class ErpDbContext : DbContext
{
- public ErpDbContext(DbContextOptions options) : base(options)
+ private readonly ICurrentUser _currentUser;
+
+ public ErpDbContext(DbContextOptions options, ICurrentUser currentUser) : base(options)
{
+ _currentUser = currentUser;
}
// --- Master Data (docs/10 Part C.1) ---
@@ -39,6 +45,37 @@ public class ErpDbContext : DbContext
public DbSet PurchaseOrders => Set();
public DbSet PoLines => Set();
+ // --- Goods Receipt (docs/10 Part C.3) ---
+ public DbSet Grns => Set();
+ public DbSet GrnLines => Set();
+
+ // --- Batch / Serial (docs/10 Part C.4) ---
+ public DbSet Batches => Set();
+ public DbSet Serials => Set();
+
+ // --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) ---
+ public DbSet StockLayers => Set();
+ public DbSet StockLedger => Set();
+
+ // --- Stock transactions (docs/10 Part C.6) ---
+ public DbSet StockTransfers => Set();
+ public DbSet StockTransferLines => Set();
+ public DbSet StockAdjustments => Set();
+ public DbSet StockAdjustmentLines => Set();
+ public DbSet StockCounts => Set();
+ public DbSet StockCountLines => Set();
+
+ // --- Purchase returns (docs/10 Part C.2) ---
+ public DbSet PurchaseReturns => Set();
+ public DbSet PurchaseReturnLines => Set();
+
+ // --- Reference data (docs/10 Part C.7) ---
+ public DbSet ReasonCodes => Set();
+
+ // --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
+ public DbSet AuditLogs => Set();
+ public DbSet JournalEntryStubs => Set();
+
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 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 pending)
+ {
+ var now = DateTime.UtcNow;
+ var userId = _currentUser.AuditUserId;
+ foreach (var p in pending)
+ AuditLogs.Add(AuditScribe.ToLog(p, userId, now));
+ }
}
diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs
deleted file mode 100644
index 4d6fe75..0000000
--- a/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs
+++ /dev/null
@@ -1,445 +0,0 @@
-//
-using System;
-using ERPCore.Infra.Persistence;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.EntityFrameworkCore.Migrations;
-using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
-
-#nullable disable
-
-namespace ERPCore.Infra.Persistence.Migrations
-{
- [DbContext(typeof(ErpDbContext))]
- [Migration("20260709095653_InitialCreate")]
- partial class InitialCreate
- {
- ///
- protected override void BuildTargetModel(ModelBuilder modelBuilder)
- {
-#pragma warning disable 612, 618
- modelBuilder
- .HasAnnotation("ProductVersion", "10.0.9")
- .HasAnnotation("Relational:MaxIdentifierLength", 63);
-
- NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
-
- modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
- {
- b.Property("BinId")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId"));
-
- b.Property("BinType")
- .HasMaxLength(50)
- .HasColumnType("character varying(50)");
-
- b.Property("Code")
- .IsRequired()
- .HasMaxLength(50)
- .HasColumnType("character varying(50)");
-
- b.Property("WarehouseId")
- .HasColumnType("bigint");
-
- b.HasKey("BinId");
-
- b.HasIndex("WarehouseId", "Code")
- .IsUnique();
-
- b.ToTable("bins", (string)null);
- });
-
- modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
- {
- b.Property("CategoryId")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId"));
-
- b.Property("Name")
- .IsRequired()
- .HasMaxLength(200)
- .HasColumnType("character varying(200)");
-
- b.Property("ParentId")
- .HasColumnType("bigint");
-
- b.HasKey("CategoryId");
-
- b.HasIndex("ParentId");
-
- b.ToTable("categories", (string)null);
- });
-
- modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
- {
- b.Property("ItemId")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId"));
-
- b.Property("BaseUomId")
- .HasColumnType("bigint");
-
- b.Property