Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae7627fcf2 | |||
| 67150425e4 | |||
| 9e1aa57987 | |||
| 22f86451e3 |
@@ -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 <c>[Route]</c> 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).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Produces("application/json")]
|
||||
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
|
||||
public abstract class ApiControllerBase : ControllerBase
|
||||
{
|
||||
/// <summary>Parse a mandatory <c>If-Match</c> header, or 428 if absent/malformed.</summary>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only audit trail (FR-X-02; auditor role). Extends the documented §11 API —
|
||||
/// the audit trail is required (AR-01 compensating control) and read access is the
|
||||
/// only way to consume it.
|
||||
/// </summary>
|
||||
[Route("api/v1/audit-logs")]
|
||||
public sealed class AuditLogsController : ApiControllerBase
|
||||
{
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public AuditLogsController(IAuditService audit) => _audit = audit;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<AuditLogDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<AuditLogDto>>> List(
|
||||
[FromQuery] string? entityType, [FromQuery] long? entityId, [FromQuery] long? userId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _audit.ListLogsAsync(entityType, entityId, userId, from, to, query, ct));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
|
||||
[Route("api/v1/grns")]
|
||||
public sealed class GrnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IGrnService _grns;
|
||||
|
||||
public GrnsController(IGrnService grns) => _grns = grns;
|
||||
|
||||
[HttpGet("{grnId:long}")]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<GrnDto>> GetById(long grnId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _grns.GetAsync(grnId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create a Draft GRN against a PO or direct. Cost is PO-derived for PO lines.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<GrnDto>> Create([FromBody] CreateGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _grns.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/grns/{dto.GrnId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
|
||||
[HttpPost("{grnId:long}/confirm")]
|
||||
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<GrnConfirmResultDto>> Confirm(
|
||||
long grnId, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken ct)
|
||||
=> Ok(await _grns.ConfirmAsync(grnId, idempotencyKey, ct));
|
||||
|
||||
/// <summary>Release or reject an inspection-hold line (FR-GRN-05).</summary>
|
||||
[HttpPost("{grnId:long}/lines/{grnLineId:long}/release")]
|
||||
[ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
||||
long grnId, long grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
||||
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only GL-ready journal stubs (FR-STK-13; consumed by the Accounting phase).
|
||||
/// Data only — no posting in Phase 1.
|
||||
/// </summary>
|
||||
[Route("api/v1/journal-entries")]
|
||||
public sealed class JournalEntriesController : ApiControllerBase
|
||||
{
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public JournalEntriesController(IAuditService audit) => _audit = audit;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<JournalEntryStubDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<JournalEntryStubDto>>> List(
|
||||
[FromQuery] string? sourceDocType, [FromQuery] long? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _audit.ListJournalAsync(sourceDocType, sourceDocId, query, ct));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
|
||||
[Route("api/v1/purchase-returns")]
|
||||
public sealed class PurchaseReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly IPurchaseReturnService _returns;
|
||||
|
||||
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
|
||||
|
||||
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<PurchaseReturnDto>> Create([FromBody] CreatePurchaseReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _returns.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/purchase-returns/{dto.ReturnId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Reference;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Reason-code reference endpoints (docs/11 §6).</summary>
|
||||
[Route("api/v1/reason-codes")]
|
||||
public sealed class ReasonCodesController : ApiControllerBase
|
||||
{
|
||||
private readonly IReasonCodeService _codes;
|
||||
|
||||
public ReasonCodesController(IReasonCodeService codes) => _codes = codes;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ReasonCodeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ReasonCodeDto>>> List(
|
||||
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _codes.ListAsync(context, query, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ReasonCodeDto>> Create([FromBody] CreateReasonCodeRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _codes.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/reason-codes/{dto.ReasonCodeId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
||||
[Route("api/v1/stock-adjustments")]
|
||||
public sealed class StockAdjustmentsController : ApiControllerBase
|
||||
{
|
||||
private readonly IAdjustmentService _adjustments;
|
||||
|
||||
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
||||
|
||||
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<AdjustmentDto>> Create([FromBody] CreateAdjustmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _adjustments.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
|
||||
[Route("api/v1/stock")]
|
||||
public sealed class StockController : ApiControllerBase
|
||||
{
|
||||
private readonly IStockService _stock;
|
||||
private readonly IReorderService _reorder;
|
||||
|
||||
public StockController(IStockService stock, IReorderService reorder)
|
||||
{
|
||||
_stock = stock;
|
||||
_reorder = reorder;
|
||||
}
|
||||
|
||||
[HttpGet("on-hand")]
|
||||
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] long itemId, [FromQuery] long warehouseId, CancellationToken ct)
|
||||
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
|
||||
|
||||
[HttpGet("ledger")]
|
||||
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||
[FromQuery] long? itemId, [FromQuery] long? warehouseId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
|
||||
|
||||
[HttpGet("valuation")]
|
||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<StockValuationDto>> Valuation([FromQuery] long itemId, [FromQuery] long warehouseId, CancellationToken ct)
|
||||
=> Ok(await _stock.GetValuationAsync(itemId, warehouseId, ct));
|
||||
|
||||
/// <summary>Items at/below their reorder point (FR-STK-10), computed on read.</summary>
|
||||
[HttpGet("reorder-alerts")]
|
||||
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
|
||||
[FromQuery] long? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
|
||||
|
||||
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
|
||||
[HttpPost("reorder-alerts/{itemId:long}/requisition")]
|
||||
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<RequisitionDto>> SuggestRequisition(
|
||||
long itemId, [FromQuery] long warehouseId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _reorder.CreateSuggestedRequisitionAsync(itemId, warehouseId, ct);
|
||||
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
|
||||
[Route("api/v1/stock-counts")]
|
||||
public sealed class StockCountsController : ApiControllerBase
|
||||
{
|
||||
private readonly ICountService _counts;
|
||||
|
||||
public StockCountsController(ICountService counts) => _counts = counts;
|
||||
|
||||
[HttpGet("{countId:long}")]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CountDto>> GetById(long countId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _counts.GetAsync(countId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create a count with system quantities snapshotted (immutable).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<CountDto>> Create([FromBody] CreateCountRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _counts.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/stock-counts/{dto.CountId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Enter counted quantities; variance = counted − system.</summary>
|
||||
[HttpPut("{countId:long}/counts")]
|
||||
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<CountDto>> EnterCounts(long countId, [FromBody] EnterCountsRequest request, CancellationToken ct)
|
||||
=> Ok(await _counts.EnterCountsAsync(countId, request, ct));
|
||||
|
||||
/// <summary>Post: emit a variance adjustment and close the count.</summary>
|
||||
[HttpPost("{countId:long}/post")]
|
||||
[ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<CountPostResultDto>> Post(long countId, CancellationToken ct)
|
||||
=> Ok(await _counts.PostAsync(countId, ct));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
|
||||
[Route("api/v1/stock-transfers")]
|
||||
public sealed class StockTransfersController : ApiControllerBase
|
||||
{
|
||||
private readonly ITransferService _transfers;
|
||||
|
||||
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
|
||||
|
||||
[HttpGet("{transferId:long}")]
|
||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<TransferDto>> GetById(long transferId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _transfers.GetAsync(transferId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<TransferDto>> Create([FromBody] CreateTransferRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _transfers.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/stock-transfers/{dto.TransferId}", dto);
|
||||
}
|
||||
|
||||
/// <summary>Dispatch: consume source FIFO layers into in-transit. 409 STOCK_NEGATIVE_BLOCKED if short.</summary>
|
||||
[HttpPost("{transferId:long}/dispatch")]
|
||||
[ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<DispatchResultDto>> Dispatch(long transferId, CancellationToken ct)
|
||||
=> Ok(await _transfers.DispatchAsync(transferId, ct));
|
||||
|
||||
/// <summary>Receive: create the destination layer at the inherited cost (cost-preserving).</summary>
|
||||
[HttpPost("{transferId:long}/receive")]
|
||||
[ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<ReceiveResultDto>> Receive(long transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct)
|
||||
=> Ok(await _transfers.ReceiveAsync(transferId, request, ct));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable audit trail entry (FR-X-02) — the compensating control for the deferred
|
||||
/// RBAC (02-SECURITY AR-01/B.3). One row per create/update/delete of an audited
|
||||
/// entity, capturing who / when / what changed (old→new in <see cref="ChangeSet"/>).
|
||||
/// Written automatically by <c>ErpDbContext.SaveChangesAsync</c>. Append-only at the
|
||||
/// app level; DB-role revocation of UPDATE/DELETE is deferred hardening (B.3).
|
||||
/// Model: docs/10 Part C.7.
|
||||
/// </summary>
|
||||
public class AuditLog
|
||||
{
|
||||
public long AuditId { get; set; }
|
||||
public long UserId { get; set; }
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public long EntityId { get; set; }
|
||||
public AuditAction Action { get; set; }
|
||||
/// <summary>JSON change set: field→value (create/delete) or field→{old,new} (update).</summary>
|
||||
public string ChangeSet { get; set; } = "{}";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Batch/lot for a batch-tracked item (FR-GRN-04, FR-WH-03). Expiry drives FEFO
|
||||
/// picking of perishables. Model: docs/10 Part C.4.
|
||||
/// </summary>
|
||||
public class Batch
|
||||
{
|
||||
public long BatchId { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string BatchNo { get; set; } = string.Empty;
|
||||
public DateOnly? ExpiryDate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Goods Receipt Note header (FR-GRN-01/02). Raised against a PO or direct
|
||||
/// (<see cref="PoId"/> null). On confirm each line creates a FIFO layer and posts
|
||||
/// an inbound ledger entry. Mutable aggregate with an <see cref="RowVersion"/>
|
||||
/// concurrency token (docs/10 C.10). Model: docs/10 Part C.3.
|
||||
/// </summary>
|
||||
public class Grn
|
||||
{
|
||||
public long GrnId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public long? PoId { get; set; }
|
||||
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||
|
||||
public long VendorId { get; set; }
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public long WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public GrnStatus Status { get; set; } = GrnStatus.Draft;
|
||||
|
||||
public long CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
|
||||
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
|
||||
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
|
||||
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
|
||||
/// </summary>
|
||||
public class GrnLine
|
||||
{
|
||||
public long GrnLineId { get; set; }
|
||||
|
||||
public long GrnId { get; set; }
|
||||
public Grn? Grn { get; set; }
|
||||
|
||||
public long? PoLineId { get; set; }
|
||||
public PoLine? PoLine { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public long UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public long? BinId { get; set; }
|
||||
public Bin? Bin { get; set; }
|
||||
|
||||
public long? BatchId { get; set; }
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal UnitCost { get; set; }
|
||||
public decimal ReceivedValue { get; set; }
|
||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// GL-ready journal entry emitted per stock movement (FR-STK-13) — data only, no
|
||||
/// posting in Phase 1 (the Accounting module consumes these later). One row per
|
||||
/// ledger entry, referencing the same source document polymorphically. Account
|
||||
/// codes are Phase-1 placeholders until a chart of accounts exists.
|
||||
/// Model: docs/10 Part C.7.
|
||||
/// </summary>
|
||||
public class JournalEntryStub
|
||||
{
|
||||
public long JournalId { get; set; }
|
||||
public string SourceDocType { get; set; } = string.Empty;
|
||||
public long SourceDocId { get; set; }
|
||||
public string DebitAccount { get; set; } = string.Empty;
|
||||
public string CreditAccount { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase return header (FR-PROC-08) — returns received goods to a vendor,
|
||||
/// generating an outbound stock movement. Auto-posts with a mandatory reason code.
|
||||
/// Model: docs/10 Part C.2.
|
||||
/// </summary>
|
||||
public class PurchaseReturn
|
||||
{
|
||||
public long ReturnId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public long VendorId { get; set; }
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public long WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public long ReasonCodeId { get; set; }
|
||||
public ReasonCode? ReasonCode { get; set; }
|
||||
|
||||
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
|
||||
|
||||
public long CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public ICollection<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase-return line (FR-PROC-08) referencing the original GRN line for
|
||||
/// traceability. <see cref="Qty"/> is in base UOM. Model: docs/10 Part C.2.
|
||||
/// </summary>
|
||||
public class PurchaseReturnLine
|
||||
{
|
||||
public long ReturnLineId { get; set; }
|
||||
|
||||
public long ReturnId { get; set; }
|
||||
public PurchaseReturn? Return { get; set; }
|
||||
|
||||
public long? GrnLineId { get; set; }
|
||||
public GrnLine? GrnLine { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Configurable reason code for adjustments, returns and count variances
|
||||
/// (FR-X-04). Model: docs/10 Part C.7.
|
||||
/// </summary>
|
||||
public class ReasonCode
|
||||
{
|
||||
public long ReasonCodeId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public ReasonContext Context { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04).
|
||||
/// Model: docs/10 Part C.4.
|
||||
/// </summary>
|
||||
public class Serial
|
||||
{
|
||||
public long SerialId { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string SerialNo { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "InStock";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Stock adjustment header (FR-STK-07) — the highest-risk feature in the phase
|
||||
/// (02-SECURITY C.5). Auto-posts in Phase 1 with a mandatory reason code and user
|
||||
/// stamp. Mutable aggregate with an <see cref="RowVersion"/> token (docs/10 C.10).
|
||||
/// Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockAdjustment
|
||||
{
|
||||
public long AdjustmentId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public long WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public long ReasonCodeId { get; set; }
|
||||
public ReasonCode? ReasonCode { get; set; }
|
||||
|
||||
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
|
||||
|
||||
public long CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Adjustment line (FR-STK-07). <see cref="QtyDelta"/> is a signed base-UOM
|
||||
/// quantity: negative consumes FIFO layers, positive creates a layer at last cost.
|
||||
/// Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockAdjustmentLine
|
||||
{
|
||||
public long AdjLineId { get; set; }
|
||||
|
||||
public long AdjustmentId { get; set; }
|
||||
public StockAdjustment? Adjustment { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public long? BinId { get; set; }
|
||||
public long? BatchId { get; set; }
|
||||
public long? SerialId { get; set; }
|
||||
|
||||
public decimal QtyDelta { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle/full physical count header (FR-STK-08). System quantities are snapshotted
|
||||
/// at creation and are immutable once opened (02-SECURITY C.7); posting emits a
|
||||
/// variance adjustment. Mutable aggregate with an <see cref="RowVersion"/> token.
|
||||
/// Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockCount
|
||||
{
|
||||
public long CountId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public long WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public CountType CountType { get; set; }
|
||||
public CountStatus Status { get; set; } = CountStatus.Draft;
|
||||
|
||||
public long CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<StockCountLine> Lines { get; set; } = new List<StockCountLine>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Count line (FR-STK-08). <see cref="SystemQty"/> is the immutable snapshot;
|
||||
/// <see cref="Variance"/> = counted − system (in base UOM). Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockCountLine
|
||||
{
|
||||
public long CountLineId { get; set; }
|
||||
|
||||
public long CountId { get; set; }
|
||||
public StockCount? Count { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public long? BinId { get; set; }
|
||||
|
||||
public decimal SystemQty { get; set; }
|
||||
public decimal? CountedQty { get; set; }
|
||||
public decimal? Variance { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// FIFO cost layer — a quantity received at a specific unit cost, consumed
|
||||
/// oldest-first (FR-STK-02). Keyed per item **per warehouse**; quantities and
|
||||
/// <see cref="UnitCost"/> are in the item's base UOM. Answers valuation
|
||||
/// ("what's on hand and at what cost"). Model: docs/10 Part C.5.
|
||||
/// </summary>
|
||||
public class StockLayer
|
||||
{
|
||||
public long LayerId { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public long WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public long? BatchId { get; set; }
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
public long? SerialId { get; set; }
|
||||
public Serial? Serial { get; set; }
|
||||
|
||||
/// <summary>Originating GRN line — carries the inspection hold status for this stock.</summary>
|
||||
public long? GrnLineId { get; set; }
|
||||
public GrnLine? GrnLine { get; set; }
|
||||
|
||||
public decimal QtyReceived { get; set; }
|
||||
public decimal QtyRemaining { get; set; }
|
||||
public decimal UnitCost { get; set; }
|
||||
public DateTime ReceiptDate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable, append-only stock ledger (FR-STK-01, FR-X-05). One row per costed
|
||||
/// movement; answers history ("what moved, when, by whom"). The originating
|
||||
/// document is referenced polymorphically via
|
||||
/// <see cref="SourceDocType"/>/<see cref="SourceDocId"/> (no hard FK per type) so
|
||||
/// new transaction types write here without a schema change. Model: docs/10 Part C.5.
|
||||
/// </summary>
|
||||
public class StockLedger
|
||||
{
|
||||
public long LedgerId { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public long WarehouseId { get; set; }
|
||||
public long? BinId { get; set; }
|
||||
public long? BatchId { get; set; }
|
||||
public long? SerialId { get; set; }
|
||||
public long UserId { get; set; }
|
||||
|
||||
public Direction Direction { get; set; }
|
||||
public decimal QtyBase { get; set; }
|
||||
public decimal UnitCost { get; set; }
|
||||
public decimal Value { get; set; }
|
||||
public decimal RunningBalance { get; set; }
|
||||
|
||||
public string SourceDocType { get; set; } = string.Empty;
|
||||
public long SourceDocId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Inter-warehouse stock transfer header (FR-STK-05/06). Dispatch consumes source
|
||||
/// FIFO layers into in-transit; receive creates the destination layer at the
|
||||
/// inherited cost (cost-preserving). Mutable aggregate with an
|
||||
/// <see cref="RowVersion"/> token (docs/10 C.10). Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockTransfer
|
||||
{
|
||||
public long TransferId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public long SrcWarehouseId { get; set; }
|
||||
public Warehouse? SrcWarehouse { get; set; }
|
||||
|
||||
public long DestWarehouseId { get; set; }
|
||||
public Warehouse? DestWarehouse { get; set; }
|
||||
|
||||
public TransferStatus Status { get; set; } = TransferStatus.Draft;
|
||||
|
||||
public long CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<StockTransferLine> Lines { get; set; } = new List<StockTransferLine>();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Transfer line (FR-STK-05/06). <see cref="Qty"/> is in base UOM.
|
||||
/// <para>
|
||||
/// Deviation note: <see cref="UnitCost"/> and <see cref="QtyReceived"/> extend
|
||||
/// docs/10 Part C.6's <c>STOCK_TRANSFER_LINE</c> to make the transfer
|
||||
/// cost-preserving: at dispatch the value-weighted cost of the consumed source
|
||||
/// layers is stored here, and receive recreates the destination layer at that cost
|
||||
/// (supports partial receive via <see cref="QtyReceived"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class StockTransferLine
|
||||
{
|
||||
public long TransferLineId { get; set; }
|
||||
|
||||
public long TransferId { get; set; }
|
||||
public StockTransfer? Transfer { get; set; }
|
||||
|
||||
public long ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public long? SrcBinId { get; set; }
|
||||
public long? DestBinId { get; set; }
|
||||
public long? BatchId { get; set; }
|
||||
public long? SerialId { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
/// <summary>Value-weighted unit cost of the consumed source layers (set at dispatch).</summary>
|
||||
public decimal? UnitCost { get; set; }
|
||||
|
||||
/// <summary>Quantity already received at the destination (partial-receive support).</summary>
|
||||
public decimal QtyReceived { get; set; }
|
||||
}
|
||||
@@ -3,18 +3,23 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>system</c> 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 <see cref="UserId"/> (long) is what every `createdBy`/`requestedBy`/
|
||||
/// audit/ledger FK references; <see cref="AuthUserId"/> maps it to the AuthHex
|
||||
/// <c>UserId</c> (GUID) and is JIT-provisioned on first authenticated request
|
||||
/// (docs/10 A.4/C.7). A seeded <c>system</c> user (id 1, null AuthUserId) is the
|
||||
/// fallback actor for unauthenticated/system operations. Model: docs/10 Part C.7.
|
||||
/// </summary>
|
||||
public class User
|
||||
{
|
||||
/// <summary>Seeded fallback actor used while auth is deferred.</summary>
|
||||
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
|
||||
public const long SystemUserId = 1;
|
||||
|
||||
public long UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
|
||||
public Guid? AuthUserId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so
|
||||
/// <see cref="PendingApproval"/> is reserved for the future threshold-approval
|
||||
/// workflow (FR-STK-07). Stored as a string.
|
||||
/// </summary>
|
||||
public enum AdjustmentStatus
|
||||
{
|
||||
Draft,
|
||||
PendingApproval,
|
||||
Posted
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Kind of mutation recorded in the audit trail (FR-X-02). Stored as a string.</summary>
|
||||
public enum AuditAction
|
||||
{
|
||||
Create,
|
||||
Update,
|
||||
Delete
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Stock-count lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
|
||||
public enum CountStatus
|
||||
{
|
||||
Draft,
|
||||
Counted,
|
||||
Posted
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Physical-count scope (docs/11 §8; FR-STK-08). Stored as a string.</summary>
|
||||
public enum CountType
|
||||
{
|
||||
Cycle,
|
||||
Full
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Stock-ledger movement direction (docs/11 §8). Stored as a string.</summary>
|
||||
public enum Direction
|
||||
{
|
||||
In,
|
||||
Out
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Goods-receipt-note lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
|
||||
public enum GrnStatus
|
||||
{
|
||||
Draft,
|
||||
Confirmed,
|
||||
Closed
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Inspection-hold state of received stock (docs/11 §8; FR-GRN-05). <see cref="OnHold"/>
|
||||
/// stock is on-hand but not issuable until released (FR-WH-07). Stored as a string.
|
||||
/// </summary>
|
||||
public enum HoldStatus
|
||||
{
|
||||
Available,
|
||||
OnHold,
|
||||
Rejected
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Where a reason code applies (FR-X-04; docs/10 §B.8.3). Stored as a string.</summary>
|
||||
public enum ReasonContext
|
||||
{
|
||||
Adjustment,
|
||||
Return,
|
||||
Count
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Purchase-return lifecycle (docs/11 §3.4). Auto-posts in Phase 1. Stored as a string.</summary>
|
||||
public enum ReturnStatus
|
||||
{
|
||||
Draft,
|
||||
Posted
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Stock-transfer lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
|
||||
public enum TransferStatus
|
||||
{
|
||||
Draft,
|
||||
InTransit,
|
||||
Received,
|
||||
Closed
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Audit;
|
||||
|
||||
/// <summary>An audit-trail entry (FR-X-02). <c>ChangeSet</c> is the stored JSON, inlined.</summary>
|
||||
public sealed record AuditLogDto(
|
||||
long AuditId, long UserId, string EntityType, long EntityId, AuditAction Action, JsonElement ChangeSet, DateTime CreatedAt);
|
||||
|
||||
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
|
||||
public sealed record JournalEntryStubDto(
|
||||
long JournalId, string SourceDocType, long SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Grn;
|
||||
|
||||
// Responses (docs/11 §4) --------------------------------------------------------
|
||||
|
||||
public sealed record GrnLineDto(
|
||||
long GrnLineId, long? PoLineId, long ItemId, long UomId, long? BinId,
|
||||
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, long? BatchId);
|
||||
|
||||
public sealed record GrnDto(
|
||||
long GrnId, string DocNo, long? PoId, long VendorId, long WarehouseId, GrnStatus Status,
|
||||
long CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
long LayerId, long ItemId, long WarehouseId, long? BatchId,
|
||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||
|
||||
public sealed record GrnConfirmResultDto(
|
||||
long GrnId, GrnStatus Status, DateTime PostedAt,
|
||||
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<long> LedgerRefs, PurchaseOrderStatus? PoStatus);
|
||||
|
||||
public sealed record ReleaseLineResultDto(long GrnLineId, HoldStatus HoldStatus);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class BatchInput
|
||||
{
|
||||
[Required, StringLength(50)] public string BatchNo { get; set; } = string.Empty;
|
||||
public DateOnly? ExpiryDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateGrnLineInput
|
||||
{
|
||||
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
|
||||
public long? PoLineId { get; set; }
|
||||
[Required] public long ItemId { get; set; }
|
||||
[Required] public long UomId { get; set; }
|
||||
public long? BinId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
/// <summary>Used only for direct (no-PO) receipts; ignored when <see cref="PoLineId"/> is set.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
|
||||
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
public BatchInput? Batch { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateGrnRequest
|
||||
{
|
||||
/// <summary>PO to receive against; null for a direct/emergency receipt (FR-GRN-02).</summary>
|
||||
public long? PoId { get; set; }
|
||||
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
|
||||
public long? VendorId { get; set; }
|
||||
[Required] public long WarehouseId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReleaseLineRequest
|
||||
{
|
||||
/// <summary><c>Release</c> makes the stock available; <c>Reject</c> removes it from on-hand.</summary>
|
||||
[Required, RegularExpression("Release|Reject")]
|
||||
public string Action { get; set; } = "Release";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
// Responses (docs/11 §3.4) ------------------------------------------------------
|
||||
|
||||
public sealed record PurchaseReturnLineDto(long ReturnLineId, long? GrnLineId, long ItemId, decimal Qty);
|
||||
|
||||
public sealed record PurchaseReturnDto(
|
||||
long ReturnId, string DocNo, long VendorId, long WarehouseId, long ReasonCodeId, ReturnStatus Status,
|
||||
long CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<long> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreatePurchaseReturnLineInput
|
||||
{
|
||||
/// <summary>Original GRN line, for traceability against the receipt.</summary>
|
||||
public long? GrnLineId { get; set; }
|
||||
[Required] public long ItemId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreatePurchaseReturnRequest
|
||||
{
|
||||
[Required] public long VendorId { get; set; }
|
||||
[Required] public long WarehouseId { get; set; }
|
||||
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
|
||||
public long? ReasonCodeId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreatePurchaseReturnLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Reference;
|
||||
|
||||
/// <summary>Reason code (docs/11 §6).</summary>
|
||||
public sealed record ReasonCodeDto(long ReasonCodeId, string Code, string Description, ReasonContext Context);
|
||||
|
||||
public sealed class CreateReasonCodeRequest
|
||||
{
|
||||
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Description { get; set; } = string.Empty;
|
||||
[Required, EnumDataType(typeof(ReasonContext))] public ReasonContext Context { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Responses (docs/11 §5.5) ------------------------------------------------------
|
||||
|
||||
public sealed record AdjustmentLineDto(long AdjLineId, long ItemId, long? BinId, long? BatchId, decimal QtyDelta);
|
||||
|
||||
public sealed record AdjustmentDto(
|
||||
long AdjustmentId, string DocNo, long WarehouseId, long ReasonCodeId, AdjustmentStatus Status,
|
||||
long CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<long> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateAdjustmentLineInput
|
||||
{
|
||||
[Required] public long ItemId { get; set; }
|
||||
public long? BinId { get; set; }
|
||||
public long? BatchId { get; set; }
|
||||
/// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary>
|
||||
public decimal QtyDelta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateAdjustmentRequest
|
||||
{
|
||||
[Required] public long WarehouseId { get; set; }
|
||||
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
|
||||
public long? ReasonCodeId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Responses (docs/11 §5.6) ------------------------------------------------------
|
||||
|
||||
public sealed record CountLineDto(long CountLineId, long ItemId, long? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
|
||||
|
||||
public sealed record CountDto(
|
||||
long CountId, string DocNo, long WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
|
||||
|
||||
public sealed record CountPostResultDto(long CountId, CountStatus Status, long? AdjustmentId, IReadOnlyList<long> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateCountRequest
|
||||
{
|
||||
[Required] public long WarehouseId { get; set; }
|
||||
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
|
||||
[Required, MinLength(1)] public List<long> ItemIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class EnterCountLineInput
|
||||
{
|
||||
[Required] public long CountLineId { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal CountedQty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EnterCountsRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<EnterCountLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
/// <summary>An item at/below its reorder point (docs/11 §5.7; FR-STK-10). Computed on read.</summary>
|
||||
public sealed record ReorderAlertDto(
|
||||
long ItemId, long WarehouseId, decimal Available,
|
||||
decimal ReorderPoint, decimal ReorderQty, decimal SuggestedRequisitionQty);
|
||||
@@ -0,0 +1,22 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out).</summary>
|
||||
public sealed record StockOnHandDto(
|
||||
long ItemId, long WarehouseId, decimal OnHand, decimal Available,
|
||||
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
|
||||
|
||||
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
|
||||
public sealed record StockLedgerRowDto(
|
||||
long LedgerId, long ItemId, long WarehouseId, long? BinId, long? BatchId, long? SerialId,
|
||||
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
||||
string SourceDocType, long SourceDocId, long UserId, DateTime CreatedAt);
|
||||
|
||||
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationLayerDto(long LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
||||
|
||||
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationDto(
|
||||
long ItemId, long WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
|
||||
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Responses (docs/11 §5.4) ------------------------------------------------------
|
||||
|
||||
public sealed record TransferLineDto(
|
||||
long TransferLineId, long ItemId, long? SrcBinId, long? DestBinId, long? BatchId, decimal Qty, decimal QtyReceived);
|
||||
|
||||
public sealed record TransferDto(
|
||||
long TransferId, string DocNo, long SrcWarehouseId, long DestWarehouseId,
|
||||
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
|
||||
|
||||
public sealed record ConsumedLayerDto(long LayerId, decimal QtyConsumed, decimal UnitCost);
|
||||
|
||||
public sealed record DispatchResultDto(
|
||||
long TransferId, TransferStatus Status, IReadOnlyList<ConsumedLayerDto> ConsumedLayers, IReadOnlyList<long> LedgerRefs);
|
||||
|
||||
public sealed record TransferCreatedLayerDto(long LayerId, long WarehouseId, decimal QtyReceived, decimal UnitCost);
|
||||
|
||||
public sealed record ReceiveResultDto(
|
||||
long TransferId, TransferStatus Status, IReadOnlyList<TransferCreatedLayerDto> CreatedLayers, IReadOnlyList<long> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateTransferLineInput
|
||||
{
|
||||
[Required] public long ItemId { get; set; }
|
||||
public long? SrcBinId { get; set; }
|
||||
public long? DestBinId { get; set; }
|
||||
public long? BatchId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateTransferRequest
|
||||
{
|
||||
[Required] public long SrcWarehouseId { get; set; }
|
||||
[Required] public long DestWarehouseId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReceiveTransferLineInput
|
||||
{
|
||||
[Required] public long TransferLineId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReceiveTransferRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<ReceiveTransferLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Claim type names emitted by the AuthHex IdP (see its <c>JwtTokenHelper</c>).
|
||||
/// AuthHex uses no standard <c>sub</c>/<c>nameid</c>; identity is the custom
|
||||
/// <see cref="UserId"/> (GUID). These are read verbatim (JWT bearer is configured
|
||||
/// with <c>MapInboundClaims = false</c>).
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
@@ -1,25 +1,42 @@
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// JWT bearer wiring. Authentication only — RBAC/authorization policies are
|
||||
/// deferred for Phase 1; the validated principal exists solely so that
|
||||
/// <see cref="ICurrentUser"/> 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 <c>AuthHex</c>, audience
|
||||
/// <c>AuthHexClient</c>. A single door policy (<see cref="ErpAccessPolicy"/>) admits
|
||||
/// only ERP <c>UserType</c>/<c>Role</c> holders when those codes are configured;
|
||||
/// per-endpoint RBAC stays deferred. Identity → audit actor is resolved by
|
||||
/// <see cref="ShadowUserClaimsTransformation"/> + <see cref="ICurrentUser"/>.
|
||||
/// </summary>
|
||||
public static class JwtAuthExtensions
|
||||
{
|
||||
/// <summary>Authorization policy applied to every v1 controller (via ApiControllerBase).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5).
|
||||
/// AuthHex tokens carry the user as a custom <c>UserId</c> (GUID) claim and no
|
||||
/// <c>sub</c>/<c>nameid</c>. This transformation JIT-provisions a local shadow
|
||||
/// <see cref="User"/> (keyed by <c>auth_user_id</c>) and injects the local
|
||||
/// <c>long</c> id as <see cref="ClaimTypes.NameIdentifier"/>, so
|
||||
/// <see cref="ICurrentUser"/>/<c>AuditUserId</c> resolve the real user unchanged.
|
||||
/// Idempotent — <see cref="IClaimsTransformation"/> may run several times per request.
|
||||
/// </summary>
|
||||
public sealed class ShadowUserClaimsTransformation : IClaimsTransformation
|
||||
{
|
||||
private readonly ErpDbContext _db;
|
||||
|
||||
public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db;
|
||||
|
||||
public async Task<ClaimsPrincipal> 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<long> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Auditing;
|
||||
|
||||
/// <summary>A mutation captured before save, awaiting its (possibly generated) key.</summary>
|
||||
public sealed record PendingAudit(EntityEntry Entry, string EntityType, AuditAction Action, string ChangeSet, long CapturedId, bool IsAdded);
|
||||
|
||||
/// <summary>
|
||||
/// Builds audit-trail rows from the EF change tracker (FR-X-02). High-volume /
|
||||
/// derived / self-referential tables are excluded (the stock ledger is itself the
|
||||
/// stock movement audit). Change sets are captured <b>before</b> save so old→new is
|
||||
/// accurate; generated keys for inserts are read <b>after</b> save.
|
||||
/// </summary>
|
||||
public static class AuditScribe
|
||||
{
|
||||
private static readonly HashSet<Type> Excluded =
|
||||
[
|
||||
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
|
||||
typeof(StockLedger), typeof(StockLayer),
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static List<PendingAudit> Capture(ChangeTracker tracker)
|
||||
{
|
||||
var pending = new List<PendingAudit>();
|
||||
foreach (var entry in tracker.Entries())
|
||||
{
|
||||
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) continue;
|
||||
if (Excluded.Contains(entry.Entity.GetType())) continue;
|
||||
|
||||
var action = entry.State switch
|
||||
{
|
||||
EntityState.Added => AuditAction.Create,
|
||||
EntityState.Deleted => AuditAction.Delete,
|
||||
_ => AuditAction.Update,
|
||||
};
|
||||
|
||||
var changeSet = BuildChangeSet(entry, action);
|
||||
if (action == AuditAction.Update && changeSet == "{}") continue; // only concurrency token touched, etc.
|
||||
|
||||
var isAdded = entry.State == EntityState.Added;
|
||||
pending.Add(new PendingAudit(entry, entry.Entity.GetType().Name, action, changeSet, isAdded ? 0 : ReadKey(entry), isAdded));
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
public static AuditLog ToLog(PendingAudit p, long userId, DateTime now) => new()
|
||||
{
|
||||
UserId = userId,
|
||||
EntityType = p.EntityType,
|
||||
EntityId = p.IsAdded ? ReadKey(p.Entry) : p.CapturedId,
|
||||
Action = p.Action,
|
||||
ChangeSet = p.ChangeSet,
|
||||
CreatedAt = now,
|
||||
};
|
||||
|
||||
private static long ReadKey(EntityEntry entry)
|
||||
{
|
||||
var pk = entry.Metadata.FindPrimaryKey();
|
||||
if (pk is null || pk.Properties.Count != 1) return 0;
|
||||
var value = entry.Property(pk.Properties[0].Name).CurrentValue;
|
||||
return value is null ? 0 : Convert.ToInt64(value);
|
||||
}
|
||||
|
||||
private static string BuildChangeSet(EntityEntry entry, AuditAction action)
|
||||
{
|
||||
var set = new Dictionary<string, object?>();
|
||||
foreach (var p in entry.Properties)
|
||||
{
|
||||
if (p.Metadata.IsPrimaryKey()) continue;
|
||||
if (p.Metadata.Name == nameof(Item.RowVersion)) continue;
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case AuditAction.Create when p.CurrentValue is not null:
|
||||
set[p.Metadata.Name] = p.CurrentValue;
|
||||
break;
|
||||
case AuditAction.Delete:
|
||||
set[p.Metadata.Name] = p.OriginalValue;
|
||||
break;
|
||||
case AuditAction.Update when p.IsModified && !Equals(p.OriginalValue, p.CurrentValue):
|
||||
set[p.Metadata.Name] = new Dictionary<string, object?> { ["old"] = p.OriginalValue, ["new"] = p.CurrentValue };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return JsonSerializer.Serialize(set, Json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AuditLog> builder)
|
||||
{
|
||||
builder.ToTable("audit_logs");
|
||||
builder.HasKey(a => a.AuditId);
|
||||
|
||||
builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80);
|
||||
builder.Property(a => a.Action).HasConversion<string>().HasMaxLength(10).IsRequired();
|
||||
builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb");
|
||||
builder.Property(a => a.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(a => new { a.EntityType, a.EntityId });
|
||||
builder.HasIndex(a => a.CreatedAt);
|
||||
builder.HasIndex(a => a.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class JournalEntryStubConfiguration : IEntityTypeConfiguration<JournalEntryStub>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JournalEntryStub> builder)
|
||||
{
|
||||
builder.ToTable("journal_entry_stubs");
|
||||
builder.HasKey(j => j.JournalId);
|
||||
|
||||
builder.Property(j => j.SourceDocType).IsRequired().HasMaxLength(10);
|
||||
builder.Property(j => j.DebitAccount).IsRequired().HasMaxLength(20);
|
||||
builder.Property(j => j.CreditAccount).IsRequired().HasMaxLength(20);
|
||||
builder.Property(j => j.Amount).HasPrecision(18, 4);
|
||||
|
||||
builder.HasIndex(j => new { j.SourceDocType, j.SourceDocId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class BatchConfiguration : IEntityTypeConfiguration<Batch>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Batch> builder)
|
||||
{
|
||||
builder.ToTable("batches");
|
||||
builder.HasKey(b => b.BatchId);
|
||||
|
||||
builder.Property(b => b.BatchNo).IsRequired().HasMaxLength(50);
|
||||
|
||||
builder.HasOne(b => b.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Batch number unique within an item.
|
||||
builder.HasIndex(b => new { b.ItemId, b.BatchNo }).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Serial> builder)
|
||||
{
|
||||
builder.ToTable("serials");
|
||||
builder.HasKey(s => s.SerialId);
|
||||
|
||||
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
|
||||
builder.Property(s => s.Status).IsRequired().HasMaxLength(20);
|
||||
|
||||
builder.HasOne(s => s.Item)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class GrnConfiguration : IEntityTypeConfiguration<Grn>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Grn> builder)
|
||||
{
|
||||
builder.ToTable("grns");
|
||||
builder.HasKey(g => g.GrnId);
|
||||
|
||||
builder.Property(g => g.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(g => g.DocNo).IsUnique();
|
||||
|
||||
builder.Property(g => g.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(g => g.CreatedAt).IsRequired();
|
||||
builder.Property(g => g.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(g => g.Status);
|
||||
builder.HasIndex(g => g.PoId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<GrnLine> builder)
|
||||
{
|
||||
builder.ToTable("grn_lines");
|
||||
builder.HasKey(l => l.GrnLineId);
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
|
||||
builder.Property(l => l.ReceivedValue).HasPrecision(18, 4);
|
||||
builder.Property(l => l.HoldStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
|
||||
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration<PurchaseReturn>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PurchaseReturn> builder)
|
||||
{
|
||||
builder.ToTable("purchase_returns");
|
||||
builder.HasKey(r => r.ReturnId);
|
||||
|
||||
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(r => r.DocNo).IsUnique();
|
||||
|
||||
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(r => r.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PurchaseReturnLineConfiguration : IEntityTypeConfiguration<PurchaseReturnLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PurchaseReturnLine> builder)
|
||||
{
|
||||
builder.ToTable("purchase_return_lines");
|
||||
builder.HasKey(l => l.ReturnLineId);
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class ReasonCodeConfiguration : IEntityTypeConfiguration<ReasonCode>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ReasonCode> builder)
|
||||
{
|
||||
builder.ToTable("reason_codes");
|
||||
builder.HasKey(r => r.ReasonCodeId);
|
||||
|
||||
builder.Property(r => r.Code).IsRequired().HasMaxLength(20);
|
||||
builder.Property(r => r.Description).IsRequired().HasMaxLength(200);
|
||||
builder.Property(r => r.Context).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
|
||||
builder.HasIndex(r => new { r.Context, r.Code }).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<StockAdjustment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockAdjustment> builder)
|
||||
{
|
||||
builder.ToTable("stock_adjustments");
|
||||
builder.HasKey(a => a.AdjustmentId);
|
||||
|
||||
builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(a => a.DocNo).IsUnique();
|
||||
|
||||
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(a => a.CreatedAt).IsRequired();
|
||||
builder.Property(a => a.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration<StockAdjustmentLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockAdjustmentLine> builder)
|
||||
{
|
||||
builder.ToTable("stock_adjustment_lines");
|
||||
builder.HasKey(l => l.AdjLineId);
|
||||
|
||||
builder.Property(l => l.QtyDelta).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class StockLayerConfiguration : IEntityTypeConfiguration<StockLayer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockLayer> builder)
|
||||
{
|
||||
builder.ToTable("stock_layers");
|
||||
builder.HasKey(l => l.LayerId);
|
||||
|
||||
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
|
||||
builder.Property(l => l.QtyRemaining).HasPrecision(18, 4);
|
||||
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
|
||||
builder.Property(l => l.ReceiptDate).IsRequired();
|
||||
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Warehouse).WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// FIFO consumption orders by receipt date then layer id, scoped per item+warehouse.
|
||||
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.ReceiptDate, l.LayerId });
|
||||
builder.HasIndex(l => l.GrnLineId);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StockLedgerConfiguration : IEntityTypeConfiguration<StockLedger>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockLedger> builder)
|
||||
{
|
||||
// Append-only (FR-STK-01/FR-X-05): the app never updates/deletes ledger rows.
|
||||
// DB-level revocation of UPDATE/DELETE is a deferred hardening step (02-SECURITY B.3).
|
||||
builder.ToTable("stock_ledger");
|
||||
builder.HasKey(l => l.LedgerId);
|
||||
|
||||
builder.Property(l => l.Direction).HasConversion<string>().HasMaxLength(5).IsRequired();
|
||||
builder.Property(l => l.QtyBase).HasPrecision(18, 4);
|
||||
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
|
||||
builder.Property(l => l.Value).HasPrecision(18, 4);
|
||||
builder.Property(l => l.RunningBalance).HasPrecision(18, 4);
|
||||
builder.Property(l => l.SourceDocType).IsRequired().HasMaxLength(10);
|
||||
builder.Property(l => l.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasOne<Item>().WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Warehouse>().WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Time-series query paths (NFR-06) and polymorphic source tracing.
|
||||
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.LedgerId });
|
||||
builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.CreatedAt });
|
||||
builder.HasIndex(l => new { l.SourceDocType, l.SourceDocId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class StockCountConfiguration : IEntityTypeConfiguration<StockCount>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockCount> builder)
|
||||
{
|
||||
builder.ToTable("stock_counts");
|
||||
builder.HasKey(c => c.CountId);
|
||||
|
||||
builder.Property(c => c.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(c => c.DocNo).IsUnique();
|
||||
|
||||
builder.Property(c => c.CountType).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(c => c.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(c => c.CreatedAt).IsRequired();
|
||||
builder.Property(c => c.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(c => c.Status);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StockCountLineConfiguration : IEntityTypeConfiguration<StockCountLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockCountLine> builder)
|
||||
{
|
||||
builder.ToTable("stock_count_lines");
|
||||
builder.HasKey(l => l.CountLineId);
|
||||
|
||||
builder.Property(l => l.SystemQty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.CountedQty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.Variance).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(l => l.Count).WithMany(c => c.Lines).HasForeignKey(l => l.CountId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class StockTransferConfiguration : IEntityTypeConfiguration<StockTransfer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockTransfer> builder)
|
||||
{
|
||||
builder.ToTable("stock_transfers");
|
||||
builder.HasKey(t => t.TransferId);
|
||||
|
||||
builder.Property(t => t.DocNo).IsRequired().HasMaxLength(30);
|
||||
builder.HasIndex(t => t.DocNo).IsUnique();
|
||||
|
||||
builder.Property(t => t.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
|
||||
builder.Property(t => t.CreatedAt).IsRequired();
|
||||
builder.Property(t => t.RowVersion).IsRowVersion();
|
||||
|
||||
builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(t => t.Status);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StockTransferLineConfiguration : IEntityTypeConfiguration<StockTransferLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StockTransferLine> builder)
|
||||
{
|
||||
builder.ToTable("stock_transfer_lines");
|
||||
builder.HasKey(l => l.TransferLineId);
|
||||
|
||||
builder.Property(l => l.Qty).HasPrecision(18, 4);
|
||||
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
|
||||
builder.Property(l => l.QtyReceived).HasPrecision(18, 4);
|
||||
|
||||
builder.HasOne(l => l.Transfer).WithMany(t => t.Lines).HasForeignKey(l => l.TransferId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.SrcBinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.DestBinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
|
||||
builder.Property(u => u.Status)
|
||||
.HasConversion<string>().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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Infra.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent startup seeding of configurable reference data (docs/10 §B.8.3).
|
||||
/// Reason codes are seeded at runtime (not via <c>HasData</c>) so the identity
|
||||
/// sequence advances normally and later admin <c>POST /reason-codes</c> calls
|
||||
/// cannot collide with seeded ids.
|
||||
/// </summary>
|
||||
public static class DataSeeder
|
||||
{
|
||||
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
|
||||
[
|
||||
("DMG", "Damage", ReasonContext.Adjustment),
|
||||
("THEFT", "Theft/Loss", ReasonContext.Adjustment),
|
||||
("VAR", "Count Variance", ReasonContext.Adjustment),
|
||||
("EXP", "Expiry Write-off", ReasonContext.Adjustment),
|
||||
("SYS", "System Correction", ReasonContext.Adjustment),
|
||||
("DEF", "Defective", ReasonContext.Return),
|
||||
("WRONG", "Wrong Item", ReasonContext.Return),
|
||||
("OVER", "Over-supply", ReasonContext.Return),
|
||||
("QREJ", "Quality Reject", ReasonContext.Return),
|
||||
];
|
||||
|
||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await db.ReasonCodes
|
||||
.Select(r => new { r.Context, r.Code })
|
||||
.ToListAsync(ct);
|
||||
var have = existing.Select(x => (x.Context, x.Code)).ToHashSet();
|
||||
|
||||
var toAdd = StandardReasonCodes
|
||||
.Where(r => !have.Contains((r.Context, r.Code)))
|
||||
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
|
||||
.ToList();
|
||||
|
||||
if (toAdd.Count == 0) return;
|
||||
|
||||
db.ReasonCodes.AddRange(toAdd);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence.Auditing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Infra.Persistence;
|
||||
@@ -8,11 +10,15 @@ namespace ERPCore.Infra.Persistence;
|
||||
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
|
||||
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
|
||||
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
|
||||
/// Every save writes an immutable audit trail (FR-X-02) via <see cref="AuditScribe"/>.
|
||||
/// </summary>
|
||||
public class ErpDbContext : DbContext
|
||||
{
|
||||
public ErpDbContext(DbContextOptions<ErpDbContext> options) : base(options)
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public ErpDbContext(DbContextOptions<ErpDbContext> options, ICurrentUser currentUser) : base(options)
|
||||
{
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
// --- Master Data (docs/10 Part C.1) ---
|
||||
@@ -39,6 +45,37 @@ public class ErpDbContext : DbContext
|
||||
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
|
||||
public DbSet<PoLine> PoLines => Set<PoLine>();
|
||||
|
||||
// --- Goods Receipt (docs/10 Part C.3) ---
|
||||
public DbSet<Grn> Grns => Set<Grn>();
|
||||
public DbSet<GrnLine> GrnLines => Set<GrnLine>();
|
||||
|
||||
// --- Batch / Serial (docs/10 Part C.4) ---
|
||||
public DbSet<Batch> Batches => Set<Batch>();
|
||||
public DbSet<Serial> Serials => Set<Serial>();
|
||||
|
||||
// --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) ---
|
||||
public DbSet<StockLayer> StockLayers => Set<StockLayer>();
|
||||
public DbSet<StockLedger> StockLedger => Set<StockLedger>();
|
||||
|
||||
// --- Stock transactions (docs/10 Part C.6) ---
|
||||
public DbSet<StockTransfer> StockTransfers => Set<StockTransfer>();
|
||||
public DbSet<StockTransferLine> StockTransferLines => Set<StockTransferLine>();
|
||||
public DbSet<StockAdjustment> StockAdjustments => Set<StockAdjustment>();
|
||||
public DbSet<StockAdjustmentLine> StockAdjustmentLines => Set<StockAdjustmentLine>();
|
||||
public DbSet<StockCount> StockCounts => Set<StockCount>();
|
||||
public DbSet<StockCountLine> StockCountLines => Set<StockCountLine>();
|
||||
|
||||
// --- Purchase returns (docs/10 Part C.2) ---
|
||||
public DbSet<PurchaseReturn> PurchaseReturns => Set<PurchaseReturn>();
|
||||
public DbSet<PurchaseReturnLine> PurchaseReturnLines => Set<PurchaseReturnLine>();
|
||||
|
||||
// --- Reference data (docs/10 Part C.7) ---
|
||||
public DbSet<ReasonCode> ReasonCodes => Set<ReasonCode>();
|
||||
|
||||
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -47,4 +84,39 @@ public class ErpDbContext : DbContext
|
||||
// (Infra/Persistence/Configurations/*).
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
|
||||
}
|
||||
|
||||
// Audit trail (FR-X-02): capture mutations before save (accurate old→new), then
|
||||
// write the log rows once inserts have their generated keys. A second base save
|
||||
// persists the logs without re-auditing them.
|
||||
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default)
|
||||
{
|
||||
var pending = AuditScribe.Capture(ChangeTracker);
|
||||
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
||||
if (pending.Count > 0)
|
||||
{
|
||||
WriteAuditLogs(pending);
|
||||
await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
var pending = AuditScribe.Capture(ChangeTracker);
|
||||
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
if (pending.Count > 0)
|
||||
{
|
||||
WriteAuditLogs(pending);
|
||||
base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void WriteAuditLogs(IReadOnlyList<PendingAudit> pending)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var userId = _currentUser.AuditUserId;
|
||||
foreach (var p in pending)
|
||||
AuditLogs.Add(AuditScribe.ToLog(p, userId, now));
|
||||
}
|
||||
}
|
||||
|
||||
+1480
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStockAndGrn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "batches",
|
||||
columns: table => new
|
||||
{
|
||||
BatchId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BatchNo = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
ExpiryDate = table.Column<DateOnly>(type: "date", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_batches", x => x.BatchId);
|
||||
table.ForeignKey(
|
||||
name: "FK_batches_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grns",
|
||||
columns: table => new
|
||||
{
|
||||
GrnId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
PoId = table.Column<long>(type: "bigint", nullable: true),
|
||||
VendorId = table.Column<long>(type: "bigint", nullable: false),
|
||||
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
PostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grns", x => x.GrnId);
|
||||
table.ForeignKey(
|
||||
name: "FK_grns_purchase_orders_PoId",
|
||||
column: x => x.PoId,
|
||||
principalTable: "purchase_orders",
|
||||
principalColumn: "PoId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grns_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grns_vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "vendors",
|
||||
principalColumn: "VendorId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grns_warehouses_WarehouseId",
|
||||
column: x => x.WarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "serials",
|
||||
columns: table => new
|
||||
{
|
||||
SerialId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
SerialNo = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_serials", x => x.SerialId);
|
||||
table.ForeignKey(
|
||||
name: "FK_serials_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "grn_lines",
|
||||
columns: table => new
|
||||
{
|
||||
GrnLineId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
GrnId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PoLineId = table.Column<long>(type: "bigint", nullable: true),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
UomId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BinId = table.Column<long>(type: "bigint", nullable: true),
|
||||
BatchId = table.Column<long>(type: "bigint", nullable: true),
|
||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
UnitCost = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false),
|
||||
ReceivedValue = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
HoldStatus = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_grn_lines", x => x.GrnLineId);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_lines_batches_BatchId",
|
||||
column: x => x.BatchId,
|
||||
principalTable: "batches",
|
||||
principalColumn: "BatchId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_lines_bins_BinId",
|
||||
column: x => x.BinId,
|
||||
principalTable: "bins",
|
||||
principalColumn: "BinId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_lines_grns_GrnId",
|
||||
column: x => x.GrnId,
|
||||
principalTable: "grns",
|
||||
principalColumn: "GrnId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_lines_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_lines_po_lines_PoLineId",
|
||||
column: x => x.PoLineId,
|
||||
principalTable: "po_lines",
|
||||
principalColumn: "PoLineId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_grn_lines_uoms_UomId",
|
||||
column: x => x.UomId,
|
||||
principalTable: "uoms",
|
||||
principalColumn: "UomId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "stock_ledger",
|
||||
columns: table => new
|
||||
{
|
||||
LedgerId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BinId = table.Column<long>(type: "bigint", nullable: true),
|
||||
BatchId = table.Column<long>(type: "bigint", nullable: true),
|
||||
SerialId = table.Column<long>(type: "bigint", nullable: true),
|
||||
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Direction = table.Column<string>(type: "character varying(5)", maxLength: 5, nullable: false),
|
||||
QtyBase = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
UnitCost = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false),
|
||||
Value = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
RunningBalance = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
SourceDocType = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
SourceDocId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_stock_ledger", x => x.LedgerId);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_ledger_batches_BatchId",
|
||||
column: x => x.BatchId,
|
||||
principalTable: "batches",
|
||||
principalColumn: "BatchId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_ledger_bins_BinId",
|
||||
column: x => x.BinId,
|
||||
principalTable: "bins",
|
||||
principalColumn: "BinId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_ledger_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_ledger_serials_SerialId",
|
||||
column: x => x.SerialId,
|
||||
principalTable: "serials",
|
||||
principalColumn: "SerialId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_ledger_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_ledger_warehouses_WarehouseId",
|
||||
column: x => x.WarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "stock_layers",
|
||||
columns: table => new
|
||||
{
|
||||
LayerId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BatchId = table.Column<long>(type: "bigint", nullable: true),
|
||||
SerialId = table.Column<long>(type: "bigint", nullable: true),
|
||||
GrnLineId = table.Column<long>(type: "bigint", nullable: true),
|
||||
QtyReceived = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
QtyRemaining = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
UnitCost = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false),
|
||||
ReceiptDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_stock_layers", x => x.LayerId);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_layers_batches_BatchId",
|
||||
column: x => x.BatchId,
|
||||
principalTable: "batches",
|
||||
principalColumn: "BatchId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_layers_grn_lines_GrnLineId",
|
||||
column: x => x.GrnLineId,
|
||||
principalTable: "grn_lines",
|
||||
principalColumn: "GrnLineId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_layers_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_layers_serials_SerialId",
|
||||
column: x => x.SerialId,
|
||||
principalTable: "serials",
|
||||
principalColumn: "SerialId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_layers_warehouses_WarehouseId",
|
||||
column: x => x.WarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_batches_ItemId_BatchNo",
|
||||
table: "batches",
|
||||
columns: new[] { "ItemId", "BatchNo" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_lines_BatchId",
|
||||
table: "grn_lines",
|
||||
column: "BatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_lines_BinId",
|
||||
table: "grn_lines",
|
||||
column: "BinId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_lines_GrnId",
|
||||
table: "grn_lines",
|
||||
column: "GrnId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_lines_ItemId",
|
||||
table: "grn_lines",
|
||||
column: "ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_lines_PoLineId",
|
||||
table: "grn_lines",
|
||||
column: "PoLineId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grn_lines_UomId",
|
||||
table: "grn_lines",
|
||||
column: "UomId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grns_CreatedBy",
|
||||
table: "grns",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grns_DocNo",
|
||||
table: "grns",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grns_PoId",
|
||||
table: "grns",
|
||||
column: "PoId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grns_Status",
|
||||
table: "grns",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grns_VendorId",
|
||||
table: "grns",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_grns_WarehouseId",
|
||||
table: "grns",
|
||||
column: "WarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_serials_ItemId_SerialNo",
|
||||
table: "serials",
|
||||
columns: new[] { "ItemId", "SerialNo" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_layers_BatchId",
|
||||
table: "stock_layers",
|
||||
column: "BatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_layers_GrnLineId",
|
||||
table: "stock_layers",
|
||||
column: "GrnLineId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_layers_ItemId_WarehouseId_ReceiptDate_LayerId",
|
||||
table: "stock_layers",
|
||||
columns: new[] { "ItemId", "WarehouseId", "ReceiptDate", "LayerId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_layers_SerialId",
|
||||
table: "stock_layers",
|
||||
column: "SerialId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_layers_WarehouseId",
|
||||
table: "stock_layers",
|
||||
column: "WarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_ledger_BatchId",
|
||||
table: "stock_ledger",
|
||||
column: "BatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_ledger_BinId",
|
||||
table: "stock_ledger",
|
||||
column: "BinId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_ledger_ItemId_WarehouseId_CreatedAt",
|
||||
table: "stock_ledger",
|
||||
columns: new[] { "ItemId", "WarehouseId", "CreatedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_ledger_ItemId_WarehouseId_LedgerId",
|
||||
table: "stock_ledger",
|
||||
columns: new[] { "ItemId", "WarehouseId", "LedgerId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_ledger_SerialId",
|
||||
table: "stock_ledger",
|
||||
column: "SerialId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_ledger_SourceDocType_SourceDocId",
|
||||
table: "stock_ledger",
|
||||
columns: new[] { "SourceDocType", "SourceDocId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_ledger_UserId",
|
||||
table: "stock_ledger",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_ledger_WarehouseId",
|
||||
table: "stock_ledger",
|
||||
column: "WarehouseId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "stock_layers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "stock_ledger");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "grn_lines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "serials");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "batches");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "grns");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1847
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStockTransactions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "reason_codes",
|
||||
columns: table => new
|
||||
{
|
||||
ReasonCodeId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Context = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_reason_codes", x => x.ReasonCodeId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "stock_transfers",
|
||||
columns: table => new
|
||||
{
|
||||
TransferId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
SrcWarehouseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DestWarehouseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_stock_transfers", x => x.TransferId);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfers_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfers_warehouses_DestWarehouseId",
|
||||
column: x => x.DestWarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfers_warehouses_SrcWarehouseId",
|
||||
column: x => x.SrcWarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "stock_adjustments",
|
||||
columns: table => new
|
||||
{
|
||||
AdjustmentId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ReasonCodeId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_stock_adjustments", x => x.AdjustmentId);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_adjustments_reason_codes_ReasonCodeId",
|
||||
column: x => x.ReasonCodeId,
|
||||
principalTable: "reason_codes",
|
||||
principalColumn: "ReasonCodeId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_adjustments_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_adjustments_warehouses_WarehouseId",
|
||||
column: x => x.WarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "stock_transfer_lines",
|
||||
columns: table => new
|
||||
{
|
||||
TransferLineId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TransferId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
SrcBinId = table.Column<long>(type: "bigint", nullable: true),
|
||||
DestBinId = table.Column<long>(type: "bigint", nullable: true),
|
||||
BatchId = table.Column<long>(type: "bigint", nullable: true),
|
||||
SerialId = table.Column<long>(type: "bigint", nullable: true),
|
||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
UnitCost = table.Column<decimal>(type: "numeric(18,6)", precision: 18, scale: 6, nullable: true),
|
||||
QtyReceived = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_stock_transfer_lines", x => x.TransferLineId);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfer_lines_batches_BatchId",
|
||||
column: x => x.BatchId,
|
||||
principalTable: "batches",
|
||||
principalColumn: "BatchId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfer_lines_bins_DestBinId",
|
||||
column: x => x.DestBinId,
|
||||
principalTable: "bins",
|
||||
principalColumn: "BinId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfer_lines_bins_SrcBinId",
|
||||
column: x => x.SrcBinId,
|
||||
principalTable: "bins",
|
||||
principalColumn: "BinId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfer_lines_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfer_lines_serials_SerialId",
|
||||
column: x => x.SerialId,
|
||||
principalTable: "serials",
|
||||
principalColumn: "SerialId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_transfer_lines_stock_transfers_TransferId",
|
||||
column: x => x.TransferId,
|
||||
principalTable: "stock_transfers",
|
||||
principalColumn: "TransferId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "stock_adjustment_lines",
|
||||
columns: table => new
|
||||
{
|
||||
AdjLineId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
AdjustmentId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BinId = table.Column<long>(type: "bigint", nullable: true),
|
||||
BatchId = table.Column<long>(type: "bigint", nullable: true),
|
||||
SerialId = table.Column<long>(type: "bigint", nullable: true),
|
||||
QtyDelta = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_stock_adjustment_lines", x => x.AdjLineId);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_adjustment_lines_batches_BatchId",
|
||||
column: x => x.BatchId,
|
||||
principalTable: "batches",
|
||||
principalColumn: "BatchId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_adjustment_lines_bins_BinId",
|
||||
column: x => x.BinId,
|
||||
principalTable: "bins",
|
||||
principalColumn: "BinId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_adjustment_lines_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_adjustment_lines_serials_SerialId",
|
||||
column: x => x.SerialId,
|
||||
principalTable: "serials",
|
||||
principalColumn: "SerialId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_adjustment_lines_stock_adjustments_AdjustmentId",
|
||||
column: x => x.AdjustmentId,
|
||||
principalTable: "stock_adjustments",
|
||||
principalColumn: "AdjustmentId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_reason_codes_Context_Code",
|
||||
table: "reason_codes",
|
||||
columns: new[] { "Context", "Code" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustment_lines_AdjustmentId",
|
||||
table: "stock_adjustment_lines",
|
||||
column: "AdjustmentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustment_lines_BatchId",
|
||||
table: "stock_adjustment_lines",
|
||||
column: "BatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustment_lines_BinId",
|
||||
table: "stock_adjustment_lines",
|
||||
column: "BinId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustment_lines_ItemId",
|
||||
table: "stock_adjustment_lines",
|
||||
column: "ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustment_lines_SerialId",
|
||||
table: "stock_adjustment_lines",
|
||||
column: "SerialId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustments_CreatedBy",
|
||||
table: "stock_adjustments",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustments_DocNo",
|
||||
table: "stock_adjustments",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustments_ReasonCodeId",
|
||||
table: "stock_adjustments",
|
||||
column: "ReasonCodeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_adjustments_WarehouseId",
|
||||
table: "stock_adjustments",
|
||||
column: "WarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfer_lines_BatchId",
|
||||
table: "stock_transfer_lines",
|
||||
column: "BatchId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfer_lines_DestBinId",
|
||||
table: "stock_transfer_lines",
|
||||
column: "DestBinId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfer_lines_ItemId",
|
||||
table: "stock_transfer_lines",
|
||||
column: "ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfer_lines_SerialId",
|
||||
table: "stock_transfer_lines",
|
||||
column: "SerialId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfer_lines_SrcBinId",
|
||||
table: "stock_transfer_lines",
|
||||
column: "SrcBinId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfer_lines_TransferId",
|
||||
table: "stock_transfer_lines",
|
||||
column: "TransferId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfers_CreatedBy",
|
||||
table: "stock_transfers",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfers_DestWarehouseId",
|
||||
table: "stock_transfers",
|
||||
column: "DestWarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfers_DocNo",
|
||||
table: "stock_transfers",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfers_SrcWarehouseId",
|
||||
table: "stock_transfers",
|
||||
column: "SrcWarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_transfers_Status",
|
||||
table: "stock_transfers",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "stock_adjustment_lines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "stock_transfer_lines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "stock_adjustments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "stock_transfers");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "reason_codes");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2134
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCountsAndReturns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "purchase_returns",
|
||||
columns: table => new
|
||||
{
|
||||
ReturnId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
VendorId = table.Column<long>(type: "bigint", nullable: false),
|
||||
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ReasonCodeId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_purchase_returns", x => x.ReturnId);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_returns_reason_codes_ReasonCodeId",
|
||||
column: x => x.ReasonCodeId,
|
||||
principalTable: "reason_codes",
|
||||
principalColumn: "ReasonCodeId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_returns_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_returns_vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "vendors",
|
||||
principalColumn: "VendorId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_returns_warehouses_WarehouseId",
|
||||
column: x => x.WarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "stock_counts",
|
||||
columns: table => new
|
||||
{
|
||||
CountId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DocNo = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
WarehouseId = table.Column<long>(type: "bigint", nullable: false),
|
||||
CountType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_stock_counts", x => x.CountId);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_counts_users_CreatedBy",
|
||||
column: x => x.CreatedBy,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_counts_warehouses_WarehouseId",
|
||||
column: x => x.WarehouseId,
|
||||
principalTable: "warehouses",
|
||||
principalColumn: "WarehouseId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "purchase_return_lines",
|
||||
columns: table => new
|
||||
{
|
||||
ReturnLineId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
ReturnId = table.Column<long>(type: "bigint", nullable: false),
|
||||
GrnLineId = table.Column<long>(type: "bigint", nullable: true),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Qty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_purchase_return_lines", x => x.ReturnLineId);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_return_lines_grn_lines_GrnLineId",
|
||||
column: x => x.GrnLineId,
|
||||
principalTable: "grn_lines",
|
||||
principalColumn: "GrnLineId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_return_lines_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_purchase_return_lines_purchase_returns_ReturnId",
|
||||
column: x => x.ReturnId,
|
||||
principalTable: "purchase_returns",
|
||||
principalColumn: "ReturnId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "stock_count_lines",
|
||||
columns: table => new
|
||||
{
|
||||
CountLineId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
CountId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ItemId = table.Column<long>(type: "bigint", nullable: false),
|
||||
BinId = table.Column<long>(type: "bigint", nullable: true),
|
||||
SystemQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
CountedQty = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true),
|
||||
Variance = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_stock_count_lines", x => x.CountLineId);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_count_lines_bins_BinId",
|
||||
column: x => x.BinId,
|
||||
principalTable: "bins",
|
||||
principalColumn: "BinId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_count_lines_items_ItemId",
|
||||
column: x => x.ItemId,
|
||||
principalTable: "items",
|
||||
principalColumn: "ItemId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_stock_count_lines_stock_counts_CountId",
|
||||
column: x => x.CountId,
|
||||
principalTable: "stock_counts",
|
||||
principalColumn: "CountId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_return_lines_GrnLineId",
|
||||
table: "purchase_return_lines",
|
||||
column: "GrnLineId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_return_lines_ItemId",
|
||||
table: "purchase_return_lines",
|
||||
column: "ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_return_lines_ReturnId",
|
||||
table: "purchase_return_lines",
|
||||
column: "ReturnId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_returns_CreatedBy",
|
||||
table: "purchase_returns",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_returns_DocNo",
|
||||
table: "purchase_returns",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_returns_ReasonCodeId",
|
||||
table: "purchase_returns",
|
||||
column: "ReasonCodeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_returns_VendorId",
|
||||
table: "purchase_returns",
|
||||
column: "VendorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_purchase_returns_WarehouseId",
|
||||
table: "purchase_returns",
|
||||
column: "WarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_count_lines_BinId",
|
||||
table: "stock_count_lines",
|
||||
column: "BinId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_count_lines_CountId",
|
||||
table: "stock_count_lines",
|
||||
column: "CountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_count_lines_ItemId",
|
||||
table: "stock_count_lines",
|
||||
column: "ItemId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_counts_CreatedBy",
|
||||
table: "stock_counts",
|
||||
column: "CreatedBy");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_counts_DocNo",
|
||||
table: "stock_counts",
|
||||
column: "DocNo",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_counts_Status",
|
||||
table: "stock_counts",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_stock_counts_WarehouseId",
|
||||
table: "stock_counts",
|
||||
column: "WarehouseId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "purchase_return_lines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "stock_count_lines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "purchase_returns");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "stock_counts");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2222
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuditAndJournal : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "audit_logs",
|
||||
columns: table => new
|
||||
{
|
||||
AuditId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||
EntityType = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||
EntityId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Action = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
ChangeSet = table.Column<string>(type: "jsonb", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_audit_logs", x => x.AuditId);
|
||||
table.ForeignKey(
|
||||
name: "FK_audit_logs_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "journal_entry_stubs",
|
||||
columns: table => new
|
||||
{
|
||||
JournalId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SourceDocType = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
SourceDocId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DebitAccount = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
CreditAccount = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Amount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_journal_entry_stubs", x => x.JournalId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_audit_logs_CreatedAt",
|
||||
table: "audit_logs",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_audit_logs_EntityType_EntityId",
|
||||
table: "audit_logs",
|
||||
columns: new[] { "EntityType", "EntityId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_audit_logs_UserId",
|
||||
table: "audit_logs",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_journal_entry_stubs_SourceDocType_SourceDocId",
|
||||
table: "journal_entry_stubs",
|
||||
columns: new[] { "SourceDocType", "SourceDocId" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "audit_logs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "journal_entry_stubs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2229
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuthUserId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "auth_user_id",
|
||||
table: "users",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "users",
|
||||
keyColumn: "UserId",
|
||||
keyValue: 1L,
|
||||
column: "auth_user_id",
|
||||
value: null);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_users_auth_user_id",
|
||||
table: "users",
|
||||
column: "auth_user_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_users_auth_user_id",
|
||||
table: "users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "auth_user_id",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@ using ERPCore.Repositories;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.OpenApi;
|
||||
using Serilog;
|
||||
@@ -30,12 +32,14 @@ builder.Services.AddDbContext<ErpDbContext>(o =>
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
|
||||
|
||||
// JWT bearer auth (RBAC deferred; identity used only for the audit stamp)
|
||||
// Auth: validate external AuthHex RS256 tokens + ERP door policy (docs/10 A.4)
|
||||
builder.Services.AddErpJwtAuth(builder.Configuration);
|
||||
|
||||
// Current-user (audit actor) derived from token `sub`
|
||||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||||
// JIT-provisions a local shadow user and injects the local `long` id as `nameid`.
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
builder.Services.AddScoped<IClaimsTransformation, ShadowUserClaimsTransformation>();
|
||||
|
||||
// Unit of work + generic repository base
|
||||
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
@@ -54,6 +58,23 @@ builder.Services.AddScoped<IRequisitionService, RequisitionService>();
|
||||
builder.Services.AddScoped<IRfqService, RfqService>();
|
||||
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
|
||||
|
||||
// Stock core + goods receipt (docs/11 §4–5)
|
||||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||||
builder.Services.AddScoped<IStockService, StockService>();
|
||||
builder.Services.AddScoped<IGrnService, GrnService>();
|
||||
|
||||
// Stock transactions + reference data (docs/11 §5–6)
|
||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
||||
builder.Services.AddScoped<IAdjustmentService, AdjustmentService>();
|
||||
builder.Services.AddScoped<ITransferService, TransferService>();
|
||||
builder.Services.AddScoped<ICountService, CountService>();
|
||||
builder.Services.AddScoped<IReorderService, ReorderService>();
|
||||
builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
|
||||
|
||||
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
|
||||
builder.Services.AddScoped<IAuditService, AuditService>();
|
||||
|
||||
// Health checks (EF Core DB)
|
||||
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
|
||||
|
||||
@@ -63,6 +84,13 @@ builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title =
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Seed configurable reference data (reason codes) idempotently at startup.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
||||
await DataSeeder.SeedAsync(db);
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
|
||||
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a
|
||||
/// decrease, layer create on an increase) + ledger posting is delegated to
|
||||
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05).
|
||||
/// </summary>
|
||||
public sealed class AdjustmentService : IAdjustmentService
|
||||
{
|
||||
private readonly IRepository<StockAdjustment> _adjustments;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IStockMutator _mutator;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public AdjustmentService(
|
||||
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
|
||||
ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_adjustments = adjustments;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
_reasonCodes = reasonCodes;
|
||||
_mutator = mutator;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (request.ReasonCodeId is null)
|
||||
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for an adjustment.", 400);
|
||||
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
|
||||
if (reason.Context != ReasonContext.Adjustment)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
|
||||
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (line.QtyDelta == 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422);
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var deltas = request.Lines
|
||||
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList();
|
||||
|
||||
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
|
||||
var entity = new StockAdjustment
|
||||
{
|
||||
DocNo = docNo,
|
||||
WarehouseId = request.WarehouseId,
|
||||
ReasonCodeId = request.ReasonCodeId.Value,
|
||||
Status = AdjustmentStatus.Posted,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = now,
|
||||
Lines = request.Lines.Select(l => new StockAdjustmentLine
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
BinId = l.BinId,
|
||||
BatchId = l.BatchId,
|
||||
QtyDelta = l.QtyDelta
|
||||
}).ToList()
|
||||
};
|
||||
await _adjustments.AddAsync(entity, token);
|
||||
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
|
||||
|
||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
|
||||
return (entity, refs);
|
||||
}, ct);
|
||||
|
||||
return new AdjustmentDto(
|
||||
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
|
||||
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
|
||||
adjustment.Lines.OrderBy(l => l.AdjLineId)
|
||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
|
||||
ledgerRefs.Select(l => l.LedgerId).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Text.Json;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class AuditService : IAuditService
|
||||
{
|
||||
private readonly IRepository<AuditLog> _logs;
|
||||
private readonly IRepository<JournalEntryStub> _journal;
|
||||
|
||||
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal)
|
||||
{
|
||||
_logs = logs;
|
||||
_journal = journal;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
|
||||
string? entityType, long? entityId, long? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _logs.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType);
|
||||
if (entityId is not null) q = q.Where(l => l.EntityId == entityId);
|
||||
if (userId is not null) q = q.Where(l => l.UserId == userId);
|
||||
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(l => l.AuditId)
|
||||
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
|
||||
var dtos = rows.Select(l => new AuditLogDto(
|
||||
l.AuditId, l.UserId, l.EntityType, l.EntityId, l.Action,
|
||||
JsonSerializer.Deserialize<JsonElement>(l.ChangeSet), l.CreatedAt)).ToList();
|
||||
|
||||
return PagedResponse<AuditLogDto>.Create(dtos, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
|
||||
string? sourceDocType, long? sourceDocId, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _journal.Query().AsNoTracking();
|
||||
if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(j => j.SourceDocType == sourceDocType);
|
||||
if (sourceDocId is not null) q = q.Where(j => j.SourceDocId == sourceDocId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(j => j.JournalId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(j => new JournalEntryStubDto(j.JournalId, j.SourceDocType, j.SourceDocId, j.DebitAccount, j.CreditAccount, j.Amount))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
|
||||
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
|
||||
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
|
||||
/// and closes the count — all in one UoW transaction.
|
||||
/// </summary>
|
||||
public sealed class CountService : ICountService
|
||||
{
|
||||
private const string VarianceReasonCode = "VAR";
|
||||
|
||||
private readonly IRepository<StockCount> _counts;
|
||||
private readonly IRepository<StockAdjustment> _adjustments;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly IStockMutator _mutator;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public CountService(
|
||||
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_counts = counts;
|
||||
_adjustments = adjustments;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
_reasonCodes = reasonCodes;
|
||||
_fifo = fifo;
|
||||
_mutator = mutator;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<CountDto?> GetAsync(long countId, CancellationToken ct = default)
|
||||
{
|
||||
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
|
||||
.FirstOrDefaultAsync(c => c.CountId == countId, ct);
|
||||
return count is null ? null : Map(count);
|
||||
}
|
||||
|
||||
public async Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
var itemIds = request.ItemIds.Distinct().ToList();
|
||||
foreach (var id in itemIds)
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {id} does not exist.", 422);
|
||||
|
||||
// Snapshot system quantities now (immutable once opened).
|
||||
var lines = new List<StockCountLine>();
|
||||
foreach (var id in itemIds)
|
||||
{
|
||||
var systemQty = await _fifo.GetOnHandAsync(id, request.WarehouseId, ct);
|
||||
lines.Add(new StockCountLine { ItemId = id, SystemQty = systemQty });
|
||||
}
|
||||
|
||||
var count = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Count, token);
|
||||
var entity = new StockCount
|
||||
{
|
||||
DocNo = docNo,
|
||||
WarehouseId = request.WarehouseId,
|
||||
CountType = request.CountType,
|
||||
Status = CountStatus.Draft,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = lines
|
||||
};
|
||||
await _counts.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(count);
|
||||
}
|
||||
|
||||
public async Task<CountDto> EnterCountsAsync(long countId, EnterCountsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var count = await _counts.Query().Include(c => c.Lines)
|
||||
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
|
||||
?? throw new NotFoundException($"Count {countId} was not found.");
|
||||
if (count.Status == CountStatus.Posted)
|
||||
throw new ConflictException($"Count {countId} is already posted.");
|
||||
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422);
|
||||
line.CountedQty = input.CountedQty;
|
||||
line.Variance = input.CountedQty - line.SystemQty;
|
||||
}
|
||||
|
||||
count.Status = CountStatus.Counted;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return Map(count);
|
||||
}
|
||||
|
||||
public async Task<CountPostResultDto> PostAsync(long countId, CancellationToken ct = default)
|
||||
{
|
||||
var count = await _counts.Query().Include(c => c.Lines)
|
||||
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
|
||||
?? throw new NotFoundException($"Count {countId} was not found.");
|
||||
if (count.Status == CountStatus.Posted)
|
||||
throw new ConflictException($"Count {countId} is already posted.");
|
||||
if (count.Status != CountStatus.Counted)
|
||||
throw new ConflictException($"Count {countId} has no entered counts to post.");
|
||||
|
||||
var deltas = count.Lines
|
||||
.Where(l => l.Variance.HasValue && l.Variance.Value != 0)
|
||||
.Select(l => new StockDelta(l.ItemId, l.BinId, null, l.Variance!.Value))
|
||||
.ToList();
|
||||
|
||||
// No variances → just close the count, no adjustment.
|
||||
if (deltas.Count == 0)
|
||||
{
|
||||
count.Status = CountStatus.Posted;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new CountPostResultDto(count.CountId, count.Status, null, Array.Empty<long>());
|
||||
}
|
||||
|
||||
var reason = await _reasonCodes.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var (adjustmentId, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
|
||||
var adjustment = new StockAdjustment
|
||||
{
|
||||
DocNo = docNo,
|
||||
WarehouseId = count.WarehouseId,
|
||||
ReasonCodeId = reason.ReasonCodeId,
|
||||
Status = AdjustmentStatus.Posted,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = now,
|
||||
Lines = deltas.Select(d => new StockAdjustmentLine
|
||||
{
|
||||
ItemId = d.ItemId,
|
||||
BinId = d.BinId,
|
||||
QtyDelta = d.QtyDelta
|
||||
}).ToList()
|
||||
};
|
||||
await _adjustments.AddAsync(adjustment, token);
|
||||
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
|
||||
|
||||
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
|
||||
|
||||
count.Status = CountStatus.Posted;
|
||||
return (adjustment.AdjustmentId, refs);
|
||||
}, ct);
|
||||
|
||||
// Map ledger ids after commit so they are populated.
|
||||
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, ledgerEntries.Select(r => r.LedgerId).ToList());
|
||||
}
|
||||
|
||||
private static CountDto Map(StockCount c) => new(
|
||||
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
|
||||
c.Lines.OrderBy(l => l.CountLineId)
|
||||
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Grn;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Goods-receipt service. Create builds a Draft GRN (cost derived from the PO line
|
||||
/// for PO-based receipts — 02-SECURITY C.3; over-receipt tolerance enforced).
|
||||
/// Confirm posts FIFO layers + inbound ledger and updates PO receipts inside one
|
||||
/// UoW transaction (NFR-02/05). Quantities are converted to the item's base UOM
|
||||
/// for the ledger/layers (FR-MD-03).
|
||||
/// </summary>
|
||||
public sealed class GrnService : IGrnService
|
||||
{
|
||||
// Phase 1: block any receipt beyond the PO line's open quantity (configurable later, NFR-10).
|
||||
private const decimal OverReceiptTolerance = 0m;
|
||||
|
||||
private readonly IRepository<Grn> _grns;
|
||||
private readonly IRepository<PurchaseOrder> _pos;
|
||||
private readonly IRepository<PoLine> _poLines;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<Uom> _uoms;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Bin> _bins;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Batch> _batches;
|
||||
private readonly IRepository<UomConversion> _conversions;
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public GrnService(
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
|
||||
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
|
||||
IRepository<UomConversion> conversions, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_grns = grns;
|
||||
_pos = pos;
|
||||
_poLines = poLines;
|
||||
_items = items;
|
||||
_uoms = uoms;
|
||||
_warehouses = warehouses;
|
||||
_bins = bins;
|
||||
_vendors = vendors;
|
||||
_batches = batches;
|
||||
_conversions = conversions;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<GrnDto?> GetAsync(long grnId, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query().AsNoTracking()
|
||||
.Include(g => g.Lines)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
return grn is null ? null : Map(grn);
|
||||
}
|
||||
|
||||
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
// Resolve vendor + PO context.
|
||||
PurchaseOrder? po = null;
|
||||
long vendorId;
|
||||
if (request.PoId is not null)
|
||||
{
|
||||
po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
|
||||
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
|
||||
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
|
||||
throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
|
||||
vendorId = po.VendorId;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (request.VendorId is null)
|
||||
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
|
||||
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
vendorId = request.VendorId.Value;
|
||||
}
|
||||
|
||||
var lines = new List<GrnLine>();
|
||||
var batchCache = new Dictionary<(long ItemId, string BatchNo), Batch>();
|
||||
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
|
||||
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
|
||||
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
|
||||
|
||||
// Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct.
|
||||
decimal unitCost;
|
||||
if (input.PoLineId is not null)
|
||||
{
|
||||
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422);
|
||||
if (poLine.ItemId != input.ItemId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
|
||||
|
||||
var openQty = poLine.Qty - poLine.QtyReceived;
|
||||
if (input.Qty > openQty * (1 + OverReceiptTolerance))
|
||||
throw new DomainException(ErrorCodes.OverReceiptTolerance,
|
||||
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
|
||||
|
||||
unitCost = poLine.UnitPrice;
|
||||
}
|
||||
else
|
||||
{
|
||||
unitCost = input.UnitCost;
|
||||
}
|
||||
|
||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||
|
||||
lines.Add(new GrnLine
|
||||
{
|
||||
PoLineId = input.PoLineId,
|
||||
ItemId = input.ItemId,
|
||||
UomId = input.UomId,
|
||||
BinId = input.BinId,
|
||||
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
|
||||
Qty = input.Qty,
|
||||
UnitCost = unitCost,
|
||||
ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero),
|
||||
HoldStatus = input.HoldStatus
|
||||
});
|
||||
}
|
||||
|
||||
var grn = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Grn, token);
|
||||
var entity = new Grn
|
||||
{
|
||||
DocNo = docNo,
|
||||
PoId = request.PoId,
|
||||
VendorId = vendorId,
|
||||
WarehouseId = request.WarehouseId,
|
||||
Status = GrnStatus.Draft,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = lines
|
||||
};
|
||||
await _grns.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(grn);
|
||||
}
|
||||
|
||||
public async Task<GrnConfirmResultDto> ConfirmAsync(long grnId, string? idempotencyKey, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query()
|
||||
.Include(g => g.Lines)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
// Idempotent replay: an already-confirmed GRN returns its existing result.
|
||||
if (grn.Status == GrnStatus.Confirmed)
|
||||
return await BuildConfirmResultAsync(grn, ct);
|
||||
if (grn.Status == GrnStatus.Closed)
|
||||
throw new ConflictException($"GRN {grnId} is closed.");
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var runningBalance = new Dictionary<(long, long), decimal>();
|
||||
var createdLayers = new List<StockLayer>();
|
||||
var ledgerRefs = new List<StockLedger>();
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
|
||||
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token);
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||
qtyBase, unitCostBase, now, token);
|
||||
createdLayers.Add(layer);
|
||||
|
||||
var key = (line.ItemId, grn.WarehouseId);
|
||||
if (!runningBalance.TryGetValue(key, out var bal))
|
||||
bal = await _fifo.GetOnHandAsync(line.ItemId, grn.WarehouseId, token);
|
||||
bal += qtyBase;
|
||||
runningBalance[key] = bal;
|
||||
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BinId, line.BatchId, null, actor,
|
||||
Direction.In, qtyBase, unitCostBase, bal, DocumentTypes.Grn, grn.GrnId, now, token);
|
||||
ledgerRefs.Add(entry);
|
||||
|
||||
if (line.PoLineId is not null)
|
||||
{
|
||||
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
|
||||
if (poLine is not null) poLine.QtyReceived += line.Qty;
|
||||
}
|
||||
}
|
||||
|
||||
grn.Status = GrnStatus.Confirmed;
|
||||
grn.PostedAt = now;
|
||||
|
||||
await UpdatePoStatusAsync(grn.PoId, token);
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, now,
|
||||
createdLayers.Select(ToCreatedLayer).ToList(),
|
||||
ledgerRefs.Select(l => l.LedgerId).ToList(),
|
||||
await GetPoStatusAsync(grn.PoId, ct));
|
||||
}
|
||||
|
||||
public async Task<ReleaseLineResultDto> ReleaseLineAsync(long grnId, long grnLineId, string action, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query()
|
||||
.Include(g => g.Lines)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId)
|
||||
?? throw new NotFoundException($"GRN line {grnLineId} was not found on GRN {grnId}.");
|
||||
|
||||
if (grn.Status != GrnStatus.Confirmed)
|
||||
throw new ConflictException($"GRN {grnId} must be confirmed before releasing holds.");
|
||||
if (line.HoldStatus != HoldStatus.OnHold)
|
||||
throw new ConflictException($"GRN line {grnLineId} is {line.HoldStatus}, not OnHold.");
|
||||
|
||||
if (string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
line.HoldStatus = HoldStatus.Available;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return new ReleaseLineResultDto(grnLineId, HoldStatus.Available);
|
||||
}
|
||||
|
||||
// Reject: remove the held stock from on-hand and post a reversing ledger entry.
|
||||
// Linking rejected stock to a formal purchase return is deferred (§3.4).
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
line.HoldStatus = HoldStatus.Rejected;
|
||||
var layers = await _layers.Query()
|
||||
.Where(l => l.GrnLineId == grnLineId && l.QtyRemaining > 0).ToListAsync(token);
|
||||
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
var bal = await _fifo.GetOnHandAsync(layer.ItemId, layer.WarehouseId, token) - layer.QtyRemaining;
|
||||
await _fifo.PostLedgerAsync(
|
||||
layer.ItemId, layer.WarehouseId, line.BinId, layer.BatchId, null, actor,
|
||||
Direction.Out, layer.QtyRemaining, layer.UnitCost, bal, DocumentTypes.Grn, grn.GrnId, now, token);
|
||||
layer.QtyRemaining = 0;
|
||||
}
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
|
||||
}
|
||||
|
||||
private async Task<Batch?> ResolveBatchAsync(
|
||||
Item item, BatchInput? batch, Dictionary<(long, string), Batch> cache, CancellationToken ct)
|
||||
{
|
||||
if (item.TrackingMode == TrackingMode.Batch && batch is null)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {item.ItemId} is batch-tracked; a batch is required.", 422);
|
||||
if (batch is null) return null;
|
||||
|
||||
var key = (item.ItemId, batch.BatchNo.Trim());
|
||||
if (cache.TryGetValue(key, out var cached)) return cached;
|
||||
|
||||
var existing = await _batches.Query().FirstOrDefaultAsync(b => b.ItemId == item.ItemId && b.BatchNo == key.Item2, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
cache[key] = existing;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var created = new Batch { ItemId = item.ItemId, BatchNo = key.Item2, ExpiryDate = batch.ExpiryDate };
|
||||
await _batches.AddAsync(created, ct);
|
||||
cache[key] = created;
|
||||
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
|
||||
}
|
||||
|
||||
private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
|
||||
Item item, long uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
|
||||
{
|
||||
if (uomId == item.BaseUomId)
|
||||
return (qty, unitCostPerUom);
|
||||
|
||||
var conv = await _conversions.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
|
||||
|
||||
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
|
||||
}
|
||||
|
||||
private async Task UpdatePoStatusAsync(long? poId, CancellationToken ct)
|
||||
{
|
||||
if (poId is null) return;
|
||||
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
|
||||
if (po is null) return;
|
||||
|
||||
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
|
||||
? PurchaseOrderStatus.FullyReceived
|
||||
: PurchaseOrderStatus.PartiallyReceived;
|
||||
po.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task<PurchaseOrderStatus?> GetPoStatusAsync(long? poId, CancellationToken ct)
|
||||
{
|
||||
if (poId is null) return null;
|
||||
return await _pos.Query().AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<GrnConfirmResultDto> BuildConfirmResultAsync(Grn grn, CancellationToken ct)
|
||||
{
|
||||
var lineIds = grn.Lines.Select(l => l.GrnLineId).ToList();
|
||||
var layers = await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.GrnLineId != null && lineIds.Contains(l.GrnLineId.Value)).ToListAsync(ct);
|
||||
var ledgerRefs = await _ledger.Query().AsNoTracking()
|
||||
.Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grn.GrnId && l.Direction == Direction.In)
|
||||
.Select(l => l.LedgerId).ToListAsync(ct);
|
||||
|
||||
return new GrnConfirmResultDto(
|
||||
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt,
|
||||
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
|
||||
}
|
||||
|
||||
private static CreatedLayerDto ToCreatedLayer(StockLayer l) => new(
|
||||
l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate);
|
||||
|
||||
private static GrnDto Map(Grn g) => new(
|
||||
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
|
||||
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
|
||||
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
|
||||
public interface IAdjustmentService
|
||||
{
|
||||
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
|
||||
/// 02-SECURITY B.2.3). Both are append-only; no write API.
|
||||
/// </summary>
|
||||
public interface IAuditService
|
||||
{
|
||||
Task<PagedResponse<AuditLogDto>> ListLogsAsync(
|
||||
string? entityType, long? entityId, long? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
|
||||
string? sourceDocType, long? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
|
||||
public interface ICountService
|
||||
{
|
||||
Task<CountDto?> GetAsync(long countId, CancellationToken ct = default);
|
||||
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
|
||||
Task<CountDto> EnterCountsAsync(long countId, EnterCountsRequest request, CancellationToken ct = default);
|
||||
Task<CountPostResultDto> PostAsync(long countId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Stock;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// FIFO cost-layer and ledger domain service (00-CORE §4, docs/10 Part A.2).
|
||||
/// Invoked by stock services **inside** a UoW transaction — never from a
|
||||
/// controller/repository. This turn covers inbound layer creation, ledger posting
|
||||
/// and valuation; oldest-first consumption (issues/transfers/adjustments) arrives
|
||||
/// with §5 and must row-lock the layers it consumes (NFR-02).
|
||||
/// </summary>
|
||||
public interface IFifoCostingService
|
||||
{
|
||||
/// <summary>Create an inbound FIFO layer (qty and unit cost in the item's base UOM).</summary>
|
||||
Task<StockLayer> CreateInboundLayerAsync(
|
||||
long itemId, long warehouseId, long? batchId, long? serialId, long? grnLineId,
|
||||
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Consume <paramref name="qtyBase"/> from open layers oldest-first, row-locking
|
||||
/// the affected layers for the transaction (NFR-02). Skips on-hold and expired
|
||||
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
|
||||
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
|
||||
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
|
||||
/// and ledger costing). Must run inside a UoW transaction.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
long itemId, long warehouseId, long? batchId, decimal qtyBase, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Append an immutable ledger entry (value = qtyBase × unitCost).</summary>
|
||||
Task<StockLedger> PostLedgerAsync(
|
||||
long itemId, long warehouseId, long? binId, long? batchId, long? serialId, long userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
||||
string sourceDocType, long sourceDocId, DateTime createdAt, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
|
||||
Task<decimal> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Valuation over open layers: Σ(qtyRemaining × unitCost) (FR-STK-04).</summary>
|
||||
Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>A quantity consumed from one FIFO layer at that layer's unit cost.</summary>
|
||||
public sealed record ConsumedSegment(long LayerId, decimal Qty, decimal UnitCost);
|
||||
@@ -0,0 +1,15 @@
|
||||
using ERPCore.Dtos.Grn;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
|
||||
public interface IGrnService
|
||||
{
|
||||
Task<GrnDto?> GetAsync(long grnId, CancellationToken ct = default);
|
||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically.</summary>
|
||||
Task<GrnConfirmResultDto> ConfirmAsync(long grnId, string? idempotencyKey, CancellationToken ct = default);
|
||||
|
||||
Task<ReleaseLineResultDto> ReleaseLineAsync(long grnId, long grnLineId, string action, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ERPCore.Dtos.Procurement;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
|
||||
public interface IPurchaseReturnService
|
||||
{
|
||||
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Reference;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Reason-code reference data (docs/11 §6; FR-X-04).</summary>
|
||||
public interface IReasonCodeService
|
||||
{
|
||||
Task<PagedResponse<ReasonCodeDto>> ListAsync(ReasonContext? context, PageQuery query, CancellationToken ct = default);
|
||||
Task<ReasonCodeDto> CreateAsync(CreateReasonCodeRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Dtos.Stock;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary>
|
||||
public interface IReorderService
|
||||
{
|
||||
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(long? warehouseId, PageQuery query, CancellationToken ct = default);
|
||||
Task<RequisitionDto> CreateSuggestedRequisitionAsync(long itemId, long warehouseId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>A signed base-UOM change to an item's stock at one warehouse.</summary>
|
||||
public sealed record StockDelta(long ItemId, long? BinId, long? BatchId, decimal QtyDelta);
|
||||
|
||||
/// <summary>
|
||||
/// Shared poster for stock-affecting documents (adjustments, count variances,
|
||||
/// purchase returns). Applies signed deltas — negative consumes FIFO layers
|
||||
/// oldest-first (row-locked, negative-stock blocked), positive creates a layer at
|
||||
/// last cost — and appends the ledger entries. Runs inside the caller's UoW
|
||||
/// transaction (the caller owns numbering, the header, and the commit); the
|
||||
/// document must already be saved so its id is a valid ledger <c>sourceDocId</c>.
|
||||
/// </summary>
|
||||
public interface IStockMutator
|
||||
{
|
||||
Task<IReadOnlyList<StockLedger>> ApplyAsync(
|
||||
long warehouseId, string sourceDocType, long sourceDocId, DateTime now,
|
||||
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Read-side stock enquiry, ledger and valuation (docs/11 §5.1–5.3).</summary>
|
||||
public interface IStockService
|
||||
{
|
||||
Task<StockOnHandDto> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default);
|
||||
|
||||
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
long? itemId, long? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ERPCore.Dtos.Stock;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
|
||||
public interface ITransferService
|
||||
{
|
||||
Task<TransferDto?> GetAsync(long transferId, CancellationToken ct = default);
|
||||
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Dispatch: consume source FIFO layers into in-transit (row-locked).</summary>
|
||||
Task<DispatchResultDto> DispatchAsync(long transferId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Receive: create the destination layer at the inherited (cost-preserving) cost.</summary>
|
||||
Task<ReceiveResultDto> ReceiveAsync(long transferId, ReceiveTransferRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason
|
||||
/// code and generates an outbound stock movement via the shared
|
||||
/// <see cref="IStockMutator"/> (FIFO consume, row-locked; over-return beyond
|
||||
/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction.
|
||||
/// </summary>
|
||||
public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
{
|
||||
private readonly IRepository<PurchaseReturn> _returns;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IRepository<ReasonCode> _reasonCodes;
|
||||
private readonly IRepository<GrnLine> _grnLines;
|
||||
private readonly IStockMutator _mutator;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public PurchaseReturnService(
|
||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
||||
IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_returns = returns;
|
||||
_vendors = vendors;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
_reasonCodes = reasonCodes;
|
||||
_grnLines = grnLines;
|
||||
_mutator = mutator;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (request.ReasonCodeId is null)
|
||||
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a purchase return.", 400);
|
||||
|
||||
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
|
||||
|
||||
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
|
||||
if (reason.Context != ReasonContext.Return)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
|
||||
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
if (line.GrnLineId is not null)
|
||||
{
|
||||
var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422);
|
||||
if (grnLine.ItemId != line.ItemId)
|
||||
throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} is for a different item.", 422);
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList();
|
||||
|
||||
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseReturn, token);
|
||||
var ret = new PurchaseReturn
|
||||
{
|
||||
DocNo = docNo,
|
||||
VendorId = request.VendorId,
|
||||
WarehouseId = request.WarehouseId,
|
||||
ReasonCodeId = request.ReasonCodeId.Value,
|
||||
Status = ReturnStatus.Posted,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = now,
|
||||
Lines = request.Lines.Select(l => new PurchaseReturnLine
|
||||
{
|
||||
GrnLineId = l.GrnLineId,
|
||||
ItemId = l.ItemId,
|
||||
Qty = l.Qty
|
||||
}).ToList()
|
||||
};
|
||||
await _returns.AddAsync(ret, token);
|
||||
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
|
||||
|
||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.PurchaseReturn, ret.ReturnId, now, deltas, token);
|
||||
return (ret, refs);
|
||||
}, ct);
|
||||
|
||||
// Map ledger ids after commit so they are populated.
|
||||
return new PurchaseReturnDto(
|
||||
entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status,
|
||||
entity.CreatedBy,
|
||||
entity.Lines.OrderBy(l => l.ReturnLineId)
|
||||
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
|
||||
ledgerEntries.Select(r => r.LedgerId).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Reference;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class ReasonCodeService : IReasonCodeService
|
||||
{
|
||||
private readonly IRepository<ReasonCode> _codes;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public ReasonCodeService(IRepository<ReasonCode> codes, IUnitOfWork uow)
|
||||
{
|
||||
_codes = codes;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ReasonCodeDto>> ListAsync(ReasonContext? context, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _codes.Query().AsNoTracking();
|
||||
if (context is not null) q = q.Where(r => r.Context == context);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(r => r.Context).ThenBy(r => r.Code)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(r => new ReasonCodeDto(r.ReasonCodeId, r.Code, r.Description, r.Context))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<ReasonCodeDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ReasonCodeDto> CreateAsync(CreateReasonCodeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var code = request.Code.Trim().ToUpperInvariant();
|
||||
if (await _codes.Query().AnyAsync(r => r.Context == request.Context && r.Code == code, ct))
|
||||
throw new ConflictException($"Reason code '{code}' already exists in context {request.Context}.");
|
||||
|
||||
var entity = new ReasonCode { Code = code, Description = request.Description.Trim(), Context = request.Context };
|
||||
await _codes.AddAsync(entity, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new ReasonCodeDto(entity.ReasonCodeId, entity.Code, entity.Description, entity.Context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Procurement;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reorder alerts are a query, not a stored entity (docs/10 C.9): available stock
|
||||
/// (from the FIFO layers) is compared to <see cref="ItemReorder"/> policy on read.
|
||||
/// </summary>
|
||||
public sealed class ReorderService : IReorderService
|
||||
{
|
||||
private readonly IRepository<ItemReorder> _reorders;
|
||||
private readonly IStockService _stock;
|
||||
private readonly IRequisitionService _requisitions;
|
||||
|
||||
public ReorderService(IRepository<ItemReorder> reorders, IStockService stock, IRequisitionService requisitions)
|
||||
{
|
||||
_reorders = reorders;
|
||||
_stock = stock;
|
||||
_requisitions = requisitions;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(long? warehouseId, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _reorders.Query().AsNoTracking();
|
||||
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
|
||||
var policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct);
|
||||
|
||||
var alerts = new List<ReorderAlertDto>();
|
||||
foreach (var p in policies)
|
||||
{
|
||||
var available = (await _stock.GetOnHandAsync(p.ItemId, p.WarehouseId, ct)).Available;
|
||||
if (available <= p.ReorderPoint)
|
||||
alerts.Add(new ReorderAlertDto(p.ItemId, p.WarehouseId, available, p.ReorderPoint, p.ReorderQty, p.ReorderQty));
|
||||
}
|
||||
|
||||
var page = alerts.Skip(query.Skip).Take(query.PageSize).ToList();
|
||||
return PagedResponse<ReorderAlertDto>.Create(page, query.Page, query.PageSize, alerts.Count);
|
||||
}
|
||||
|
||||
public async Task<RequisitionDto> CreateSuggestedRequisitionAsync(long itemId, long warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var policy = await _reorders.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.ItemId == itemId && r.WarehouseId == warehouseId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation,
|
||||
$"No reorder policy for item {itemId} at warehouse {warehouseId}.", 422);
|
||||
|
||||
return await _requisitions.CreateAsync(new CreateRequisitionRequest
|
||||
{
|
||||
Lines = [new CreateRequisitionLineInput { ItemId = itemId, Qty = policy.ReorderQty }]
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// FIFO cost-layer + ledger writer and valuation reader (docs/10 Part A.2).
|
||||
/// Writes (layer/ledger creation) are added to the tracked context and persisted
|
||||
/// by the caller's UoW transaction; they are never saved here.
|
||||
/// </summary>
|
||||
public sealed class FifoCostingService : IFifoCostingService
|
||||
{
|
||||
public const string BaseCurrency = "LKR";
|
||||
public const string Method = "FIFO";
|
||||
|
||||
// Phase-1 placeholder GL accounts (data only, no posting — FR-STK-13).
|
||||
private const string InventoryAccount = "1300";
|
||||
private const string ClearingAccount = "2100";
|
||||
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<JournalEntryStub> _journal;
|
||||
private readonly ErpDbContext _db;
|
||||
|
||||
public FifoCostingService(
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
|
||||
IRepository<JournalEntryStub> journal, ErpDbContext db)
|
||||
{
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_journal = journal;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<StockLayer> CreateInboundLayerAsync(
|
||||
long itemId, long warehouseId, long? batchId, long? serialId, long? grnLineId,
|
||||
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default)
|
||||
{
|
||||
var layer = new StockLayer
|
||||
{
|
||||
ItemId = itemId,
|
||||
WarehouseId = warehouseId,
|
||||
BatchId = batchId,
|
||||
SerialId = serialId,
|
||||
GrnLineId = grnLineId,
|
||||
QtyReceived = qtyBase,
|
||||
QtyRemaining = qtyBase,
|
||||
UnitCost = unitCost,
|
||||
ReceiptDate = receiptDate
|
||||
};
|
||||
await _layers.AddAsync(layer, ct);
|
||||
return layer;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
long itemId, long warehouseId, long? batchId, decimal qtyBase, CancellationToken ct = default)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
if (batchId is not null)
|
||||
{
|
||||
var expiry = await _db.Batches.AsNoTracking()
|
||||
.Where(b => b.BatchId == batchId).Select(b => b.ExpiryDate).FirstOrDefaultAsync(ct);
|
||||
if (expiry is not null && expiry < today)
|
||||
throw new DomainException(ErrorCodes.ExpiredBatchBlocked,
|
||||
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
|
||||
}
|
||||
|
||||
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
|
||||
// Excludes on-hold (grn line) and expired-batch stock. No LINQ is composed on
|
||||
// top of the raw SQL so the FOR UPDATE reaches the database intact.
|
||||
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
|
||||
// parameter — without it a null batch filter fails with 42P18.
|
||||
var layers = await _db.StockLayers
|
||||
.FromSqlInterpolated($"""
|
||||
SELECT * FROM stock_layers sl
|
||||
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
|
||||
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
|
||||
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" = 'OnHold')
|
||||
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
|
||||
ORDER BY sl."ReceiptDate", sl."LayerId"
|
||||
FOR UPDATE
|
||||
""")
|
||||
.ToListAsync(ct);
|
||||
|
||||
var available = layers.Sum(l => l.QtyRemaining);
|
||||
if (available < qtyBase)
|
||||
{
|
||||
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
|
||||
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
|
||||
&& (batchId == null || l.BatchId == batchId)
|
||||
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold, ct);
|
||||
if (heldExists)
|
||||
throw new DomainException(ErrorCodes.OnHoldNotIssuable,
|
||||
$"Stock for item {itemId} at warehouse {warehouseId} is on inspection hold and cannot be issued.", 409);
|
||||
|
||||
throw new DomainException(ErrorCodes.StockNegativeBlocked,
|
||||
$"Available {available} < requested {qtyBase} for item {itemId} at warehouse {warehouseId}.", 409);
|
||||
}
|
||||
|
||||
var segments = new List<ConsumedSegment>();
|
||||
var remaining = qtyBase;
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
if (remaining <= 0) break;
|
||||
var take = Math.Min(remaining, layer.QtyRemaining);
|
||||
layer.QtyRemaining -= take;
|
||||
remaining -= take;
|
||||
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
public async Task<StockLedger> PostLedgerAsync(
|
||||
long itemId, long warehouseId, long? binId, long? batchId, long? serialId, long userId,
|
||||
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
|
||||
string sourceDocType, long sourceDocId, DateTime createdAt, CancellationToken ct = default)
|
||||
{
|
||||
var entry = new StockLedger
|
||||
{
|
||||
ItemId = itemId,
|
||||
WarehouseId = warehouseId,
|
||||
BinId = binId,
|
||||
BatchId = batchId,
|
||||
SerialId = serialId,
|
||||
UserId = userId,
|
||||
Direction = direction,
|
||||
QtyBase = qtyBase,
|
||||
UnitCost = unitCost,
|
||||
Value = Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero),
|
||||
RunningBalance = runningBalance,
|
||||
SourceDocType = sourceDocType,
|
||||
SourceDocId = sourceDocId,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
await _ledger.AddAsync(entry, ct);
|
||||
|
||||
// GL-ready journal entry per movement (FR-STK-13; data only, no posting).
|
||||
// Inbound debits Inventory / credits Clearing; outbound reverses.
|
||||
var (debit, credit) = direction == Direction.In
|
||||
? (InventoryAccount, ClearingAccount)
|
||||
: (ClearingAccount, InventoryAccount);
|
||||
await _journal.AddAsync(new JournalEntryStub
|
||||
{
|
||||
SourceDocType = sourceDocType,
|
||||
SourceDocId = sourceDocId,
|
||||
DebitAccount = debit,
|
||||
CreditAccount = credit,
|
||||
Amount = entry.Value
|
||||
}, ct);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
public async Task<decimal> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default)
|
||||
=> await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId)
|
||||
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
|
||||
public async Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var open = await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
|
||||
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var layers = open.Select(l => new StockValuationLayerDto(
|
||||
l.LayerId, l.QtyRemaining, l.UnitCost,
|
||||
Math.Round(l.QtyRemaining * l.UnitCost, 4, MidpointRounding.AwayFromZero), l.ReceiptDate)).ToList();
|
||||
|
||||
return new StockValuationDto(
|
||||
itemId, warehouseId, layers,
|
||||
layers.Sum(l => l.QtyRemaining), layers.Sum(l => l.Value), BaseCurrency, Method);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
|
||||
/// <summary>
|
||||
/// Applies signed stock deltas for a document and posts the ledger (see
|
||||
/// <see cref="IStockMutator"/>). Running balances are tracked in-memory per
|
||||
/// (item, warehouse), seeded from current on-hand, so multiple lines for the same
|
||||
/// item chain correctly within the transaction.
|
||||
/// </summary>
|
||||
public sealed class StockMutator : IStockMutator
|
||||
{
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public StockMutator(IFifoCostingService fifo, IRepository<StockLayer> layers, ICurrentUser currentUser)
|
||||
{
|
||||
_fifo = fifo;
|
||||
_layers = layers;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StockLedger>> ApplyAsync(
|
||||
long warehouseId, string sourceDocType, long sourceDocId, DateTime now,
|
||||
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default)
|
||||
{
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var balances = new Dictionary<long, decimal>();
|
||||
var entries = new List<StockLedger>();
|
||||
|
||||
foreach (var d in deltas)
|
||||
{
|
||||
if (d.QtyDelta == 0) continue;
|
||||
|
||||
if (!balances.TryGetValue(d.ItemId, out var bal))
|
||||
bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct);
|
||||
|
||||
StockLedger entry;
|
||||
if (d.QtyDelta < 0)
|
||||
{
|
||||
var qty = -d.QtyDelta;
|
||||
var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct);
|
||||
var unitCost = segments.Sum(s => s.Qty * s.UnitCost) / qty;
|
||||
bal -= qty;
|
||||
entry = await _fifo.PostLedgerAsync(
|
||||
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
|
||||
Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var unitCost = await LastCostAsync(d.ItemId, warehouseId, ct);
|
||||
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct);
|
||||
bal += d.QtyDelta;
|
||||
entry = await _fifo.PostLedgerAsync(
|
||||
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
|
||||
Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct);
|
||||
}
|
||||
|
||||
balances[d.ItemId] = bal;
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async Task<decimal> LastCostAsync(long itemId, long warehouseId, CancellationToken ct)
|
||||
=> await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId)
|
||||
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
|
||||
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct) ?? 0m;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
|
||||
public sealed class StockService : IStockService
|
||||
{
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<StockTransferLine> _transferLines;
|
||||
|
||||
public StockService(
|
||||
IFifoCostingService fifo, IRepository<StockLayer> layers,
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
|
||||
{
|
||||
_fifo = fifo;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_transferLines = transferLines;
|
||||
}
|
||||
|
||||
public async Task<StockOnHandDto> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
|
||||
|
||||
// On-hold stock is on-hand but not issuable — sourced from GRN lines still OnHold.
|
||||
var onHold = await _layers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId
|
||||
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold)
|
||||
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
|
||||
// Outbound in-transit: dispatched from this warehouse, not yet received at the
|
||||
// destination. Dispatch already consumed the source layers, so this stock has
|
||||
// left onHand — it is reported for visibility (AR-05) but is NOT re-subtracted
|
||||
// from available (that would double-count). reserved (sales) stays a stub.
|
||||
var inTransit = await _transferLines.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == itemId
|
||||
&& l.Transfer!.SrcWarehouseId == warehouseId
|
||||
&& l.Transfer.Status == TransferStatus.InTransit)
|
||||
.SumAsync(l => (decimal?)(l.Qty - l.QtyReceived), ct) ?? 0m;
|
||||
const decimal reserved = 0m;
|
||||
var available = onHand - onHold - reserved;
|
||||
|
||||
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
long? itemId, long? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _ledger.Query().AsNoTracking();
|
||||
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
|
||||
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
|
||||
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(l => l.LedgerId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(l => new StockLedgerRowDto(
|
||||
l.LedgerId, l.ItemId, l.WarehouseId, l.BinId, l.BatchId, l.SerialId,
|
||||
l.Direction, l.QtyBase, l.UnitCost, l.Value, l.RunningBalance,
|
||||
l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<StockLedgerRowDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default)
|
||||
=> _fifo.GetValuationAsync(itemId, warehouseId, ct);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using ERPCore.Domain;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Stock;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Inter-warehouse transfer service (FR-STK-05/06). Dispatch consumes source FIFO
|
||||
/// layers (row-locked; negative-stock blocked) and records the value-weighted cost
|
||||
/// on the line; receive recreates the destination layer at that cost
|
||||
/// (cost-preserving — no revaluation). Both run in a single UoW transaction.
|
||||
/// </summary>
|
||||
public sealed class TransferService : ITransferService
|
||||
{
|
||||
private readonly IRepository<StockTransfer> _transfers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public TransferService(
|
||||
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_transfers = transfers;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
_fifo = fifo;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<TransferDto?> GetAsync(long transferId, CancellationToken ct = default)
|
||||
{
|
||||
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
|
||||
.FirstOrDefaultAsync(x => x.TransferId == transferId, ct);
|
||||
return t is null ? null : Map(t);
|
||||
}
|
||||
|
||||
public async Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (request.SrcWarehouseId == request.DestWarehouseId)
|
||||
throw new DomainException(ErrorCodes.Validation, "destWarehouseId must differ from srcWarehouseId.", 422);
|
||||
await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
|
||||
await EnsureWarehouseAsync(request.DestWarehouseId, ct);
|
||||
foreach (var line in request.Lines)
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var transfer = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var docNo = await _numbers.NextAsync(DocumentTypes.Transfer, token);
|
||||
var entity = new StockTransfer
|
||||
{
|
||||
DocNo = docNo,
|
||||
SrcWarehouseId = request.SrcWarehouseId,
|
||||
DestWarehouseId = request.DestWarehouseId,
|
||||
Status = TransferStatus.Draft,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(l => new StockTransferLine
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
SrcBinId = l.SrcBinId,
|
||||
DestBinId = l.DestBinId,
|
||||
BatchId = l.BatchId,
|
||||
Qty = l.Qty
|
||||
}).ToList()
|
||||
};
|
||||
await _transfers.AddAsync(entity, token);
|
||||
return entity;
|
||||
}, ct);
|
||||
|
||||
return Map(transfer);
|
||||
}
|
||||
|
||||
public async Task<DispatchResultDto> DispatchAsync(long transferId, CancellationToken ct = default)
|
||||
{
|
||||
var transfer = await _transfers.Query().Include(t => t.Lines)
|
||||
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
|
||||
?? throw new NotFoundException($"Transfer {transferId} was not found.");
|
||||
if (transfer.Status != TransferStatus.Draft)
|
||||
throw new ConflictException($"Transfer {transferId} is {transfer.Status}; only a Draft can be dispatched.");
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var consumed = new List<ConsumedLayerDto>();
|
||||
var ledgerRefs = new List<StockLedger>();
|
||||
var balances = new Dictionary<long, decimal>();
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId))
|
||||
{
|
||||
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token);
|
||||
var value = segments.Sum(s => s.Qty * s.UnitCost);
|
||||
line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive
|
||||
|
||||
if (!balances.TryGetValue(line.ItemId, out var bal))
|
||||
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token);
|
||||
bal -= line.Qty;
|
||||
balances[line.ItemId] = bal;
|
||||
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
line.ItemId, transfer.SrcWarehouseId, line.SrcBinId, line.BatchId, null, actor,
|
||||
Direction.Out, line.Qty, line.UnitCost.Value, bal, DocumentTypes.Transfer, transfer.TransferId, now, token);
|
||||
ledgerRefs.Add(entry);
|
||||
|
||||
consumed.AddRange(segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)));
|
||||
}
|
||||
|
||||
transfer.Status = TransferStatus.InTransit;
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
return new DispatchResultDto(transfer.TransferId, transfer.Status, consumed, ledgerRefs.Select(l => l.LedgerId).ToList());
|
||||
}
|
||||
|
||||
public async Task<ReceiveResultDto> ReceiveAsync(long transferId, ReceiveTransferRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var transfer = await _transfers.Query().Include(t => t.Lines)
|
||||
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
|
||||
?? throw new NotFoundException($"Transfer {transferId} was not found.");
|
||||
if (transfer.Status != TransferStatus.InTransit)
|
||||
throw new ConflictException($"Transfer {transferId} is {transfer.Status}; only an InTransit transfer can be received.");
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var now = DateTime.UtcNow;
|
||||
var createdLayers = new List<StockLayer>();
|
||||
var ledgerRefs = new List<StockLedger>();
|
||||
var balances = new Dictionary<long, decimal>();
|
||||
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
var line = transfer.Lines.FirstOrDefault(l => l.TransferLineId == input.TransferLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Transfer line {input.TransferLineId} is not on transfer {transferId}.", 422);
|
||||
|
||||
var outstanding = line.Qty - line.QtyReceived;
|
||||
if (input.Qty > outstanding)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Receiving {input.Qty} exceeds the outstanding in-transit {outstanding} on line {input.TransferLineId}.", 422);
|
||||
|
||||
var unitCost = line.UnitCost ?? 0m;
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token);
|
||||
createdLayers.Add(layer);
|
||||
|
||||
if (!balances.TryGetValue(line.ItemId, out var bal))
|
||||
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.DestWarehouseId, token);
|
||||
bal += input.Qty;
|
||||
balances[line.ItemId] = bal;
|
||||
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
line.ItemId, transfer.DestWarehouseId, line.DestBinId, line.BatchId, null, actor,
|
||||
Direction.In, input.Qty, unitCost, bal, DocumentTypes.Transfer, transfer.TransferId, now, token);
|
||||
ledgerRefs.Add(entry);
|
||||
|
||||
line.QtyReceived += input.Qty;
|
||||
}
|
||||
|
||||
if (transfer.Lines.All(l => l.QtyReceived >= l.Qty))
|
||||
transfer.Status = TransferStatus.Received;
|
||||
return 0;
|
||||
}, ct);
|
||||
|
||||
// Map after commit so layer ids are populated.
|
||||
var created = createdLayers
|
||||
.Select(l => new TransferCreatedLayerDto(l.LayerId, l.WarehouseId, l.QtyReceived, l.UnitCost))
|
||||
.ToList();
|
||||
return new ReceiveResultDto(transfer.TransferId, transfer.Status, created, ledgerRefs.Select(l => l.LedgerId).ToList());
|
||||
}
|
||||
|
||||
private async Task EnsureWarehouseAsync(long warehouseId, CancellationToken ct)
|
||||
{
|
||||
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
|
||||
}
|
||||
|
||||
private static TransferDto Map(StockTransfer t) => new(
|
||||
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
|
||||
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
|
||||
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
|
||||
}
|
||||
@@ -7,8 +7,5 @@
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
|
||||
},
|
||||
"Jwt": {
|
||||
"SigningKey": "dev-only-signing-key-please-change-me-0123456789"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=CHANGE_ME;Password=CHANGE_ME"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "ERPCore",
|
||||
"Audience": "ERPCore.Clients",
|
||||
"SigningKey": "CHANGE_ME_DEV_ONLY_32+_CHARS",
|
||||
"AccessTokenMinutes": 120
|
||||
"Auth": {
|
||||
"Issuer": "AuthHex",
|
||||
"Audience": "AuthHexClient",
|
||||
"RsaPublicKeyXml": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
|
||||
"RequiredUserTypeCode": "",
|
||||
"RequiredRoleCode": ""
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
+75
-30
@@ -8,57 +8,61 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4)
|
||||
- [x] Folder structure per 00-CORE §5.3
|
||||
- [x] `ErpDbContext` + Npgsql wired; `InitialCreate` migration **created and applied** (2026-07-10, 8 master-data tables). `/health` → `Healthy`.
|
||||
- [x] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs` (JWT bearer *validated*; endpoints not yet `[Authorize]`-gated — see §6 auth note)
|
||||
- [x] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs` — JWT bearer now validates **RS256** tokens from the external **AuthHex IdP** (issuer `AuthHex` / audience `AuthHexClient` / static RSA public key). v1 endpoints `[Authorize]`-gated via the `ErpAccess` door policy (§6); `/health`, `/api/meta`, Swagger stay anonymous.
|
||||
- [x] `IUnitOfWork` + `UnitOfWork` (transaction boundary)
|
||||
- [x] Generic repository base + interfaces
|
||||
- [x] `ICurrentUser` (audit stamp from token `sub`)
|
||||
- [x] `ICurrentUser` (audit stamp from token identity claim `nameid`/`sub`) — with AuthHex the actor comes from the `UserId` GUID → local shadow user (`nameid` injected by the §6 provisioning step)
|
||||
- [x] ProblemDetails middleware + domain exception → `code` mapping (System/Errors; full §7 catalog added to `ErrorCodes`)
|
||||
|
||||
## 1. Master Data
|
||||
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. Still `[~]` (not `[x]`) for **one** reason: the **security gate** (00-CORE §8) — the foundational auth control (02-SECURITY B.1) and the audit trail (B.3, the AR-01 compensating control) land in §6. Flip to `[x]` once §6 auth+audit are wired.
|
||||
> Code complete for all items below (2026-07-09). **Live smoke test PASSED against Postgres (2026-07-10):** create/get/list/update/status/reorder/uom-conversions across all 5 controllers; ETag round-trip 200 / stale→412 / missing→428; SKU_DUPLICATE→400; bad reference→422; missing-field→400 ValidationProblemDetails; category `?tree=true` nesting; `pageSize=9999` clamped to 200; deactivate via PATCH status→204. **Flipped `[x]` on 2026-07-14** — the §6 security gate (00-CORE §8: auth control 02-SECURITY B.1 + audit trail B.3) is now met (AuthHex RS256 validation + `[Authorize]` door policy + shadow-user provisioning, and the audit trail). The dated smoke-test notes in §2–§5 that reference a pending "§6 gate" are historical.
|
||||
- [x] Item: entity + config + enums (ItemType, TrackingMode; EntityStatus added) — `xmin`/RowVersion concurrency token (Npgsql), unique SKU
|
||||
- [~] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
|
||||
- [~] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
|
||||
- [~] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation)
|
||||
- [~] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
|
||||
- [~] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
|
||||
- [~] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
|
||||
- [x] Item: repository (generic) + service + controller (CRUD, narrow DTOs, ETag/If-Match→412, SKU_DUPLICATE, reference validation) — server-controlled fields excluded (02-SECURITY C.1)
|
||||
- [x] UOM + UOM conversions (`GET/POST /uoms`, `PUT /items/{id}/uom-conversions` full-replace upsert)
|
||||
- [x] Category (hierarchy, `GET /categories?tree=true` nested build, parent-exists validation)
|
||||
- [x] Vendor (CRUD, ETag/If-Match, unique code, deactivate via `PATCH /vendors/{id}/status`)
|
||||
- [x] Warehouse + Bin (`/warehouses`, nested `/warehouses/{id}/bins`, bin code unique per warehouse)
|
||||
- [x] Item reorder settings (`PUT /items/{id}/reorder` full-replace upsert, warehouse-exists validation)
|
||||
|
||||
## 2. Procurement
|
||||
> Requisition/RFQ/PO implemented 2026-07-10. **Live smoke test PASSED** (docNo `PR/RFQ/PO-2026-#####` gap-controlled + incrementing, requestedBy/createdBy = seeded system user, RFQ comparison matrix, duplicate quotation→409, PO auto-approve + server-computed totals matching the spec example `112100/20178/132278`, edit-while-open with recomputed totals + ETag 200/412, PO_NOT_EDITABLE→409 on cancelled, cancel→200, bad reference→422). Same `[~]` reason as §1: the §6 security gate (auth + audit) is not yet wired.
|
||||
- [~] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
|
||||
- [~] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
|
||||
- [~] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel
|
||||
- [ ] Purchase Return (outbound movement, reason code) — **deferred**: needs GRN lines + stock ledger/FIFO (§3/§4). Build with those.
|
||||
- [x] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get)
|
||||
- [x] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix)
|
||||
- [x] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel
|
||||
- [x] Purchase Return (outbound movement, reason code) — `POST /purchase-returns` auto-posts an outbound FIFO consume via shared `StockMutator`; mandatory Return-context reason (`REASON_CODE_REQUIRED`→400, wrong context→422), references the GRN line for traceability, over-return→`409 STOCK_NEGATIVE_BLOCKED`. Verified. (Cumulative return-vs-received cap still relies on the stock-availability guard.)
|
||||
|
||||
> **Deviation (recorded):** `VendorQuotation` is modelled as header + `VendorQuotationLine` (per-item pricing) to satisfy the API contract (docs/11 §3.2); docs/10 Part C.2's scalar `VENDOR_QUOTATION(unit_price, lead_days)` with no item ref cannot represent it. Update the ER model doc to match.
|
||||
> **RFQ `vendorIds`** are validated for existence but not persisted (no RFQ↔vendor link in the model); quotations reference vendors directly.
|
||||
|
||||
## 3. Goods Receipt
|
||||
- [ ] GRN create (against PO / direct), over-receipt tolerance
|
||||
- [ ] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn, Idempotency-Key)
|
||||
- [ ] Inspection hold release / reject
|
||||
> Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate.
|
||||
- [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **PO-derived server-side** (client `999` verified ignored → PO price used, 02-SECURITY C.3); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred.
|
||||
- [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred.
|
||||
- [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4).
|
||||
|
||||
## 4. Stock Core
|
||||
- [ ] StockLayer + StockLedger entities/config (ledger append-only)
|
||||
- [ ] `FifoCostingService` (consume oldest-first with row lock; valuation)
|
||||
- [ ] Stock enquiry (onHand / available / onHold / inTransit)
|
||||
- [ ] Ledger query · Valuation query
|
||||
> Implemented + **live smoke test PASSED** 2026-07-13: receive→confirm creates FIFO layers + inbound ledger (qtyBase/unitCost/value/runningBalance correct), on-hand/valuation/ledger queries correct, OnHold excluded from `available`, UOM→base conversion applied. Same `[~]` gate (§6 auth+audit).
|
||||
- [x] StockLayer + StockLedger entities/config — ledger **append-only at the app level** (never updated/deleted); DB-role `UPDATE`/`DELETE` revoke is deferred hardening (02-SECURITY B.3). Layers keyed per item **per warehouse**, base-UOM qty + unit cost; ledger polymorphic source (`sourceDocType`/`sourceDocId`), time-series indexes.
|
||||
- [x] `FifoCostingService` — inbound layer + ledger posting + valuation **and oldest-first consume with row lock** (`SELECT … FOR UPDATE`, on-hold/expired exclusion, negative-stock block) all implemented + verified 2026-07-13 via §5. Blended cost on multi-layer consume verified (700@10 + 100@12 → 10.25).
|
||||
- [x] Stock enquiry (onHand / available / onHold / inTransit) — onHand/available/onHold **and inTransit** now live + verified (inTransit = outstanding InTransit-transfer qty out of this warehouse). `reserved` stays a 0 stub until Sales.
|
||||
- [x] Ledger query · Valuation query — `GET /stock/ledger` (item/warehouse/from/to + paging), `GET /stock/valuation` (open layers, totals, FIFO); both verified.
|
||||
|
||||
## 5. Stock Transactions
|
||||
- [ ] Transfer: create → dispatch (consume, In-Transit) → receive (dest layer, cost-preserving)
|
||||
- [ ] Adjustment (auto-post, mandatory reason code)
|
||||
- [ ] Count (cycle/full → enter counts → variance → post)
|
||||
- [ ] Reorder alerts (query) + suggest requisition
|
||||
> **All four §5 features implemented + live smoke test PASSED 2026-07-13** (Adjustment, Transfer, Count, Reorder alerts); Purchase Return (§3.4) also done this pass. Same `[~]` gate (§6 auth+audit).
|
||||
- [x] Transfer: create → dispatch (consume source FIFO row-locked → In-Transit) → receive (dest layer, **cost-preserving**) — verified: dispatch reduces source onHand + reports inTransit; receive creates dest layer at inherited cost (300 @12 → dest value 3600); `destWarehouseId != srcWarehouseId`→422; dispatch short→`409 STOCK_NEGATIVE_BLOCKED`. Partial receive supported (`QtyReceived`).
|
||||
- [x] Adjustment (auto-post, mandatory reason code) — **highest-risk feature (02-SECURITY C.5)**: `REASON_CODE_REQUIRED`→400, non-Adjustment reason→422, decrease FIFO-consumes (blended cost, negative→409), increase creates a layer at last cost. All verified.
|
||||
- [x] Count (cycle/full → enter counts → variance → post) — create snapshots systemQty (immutable), enter sets counted+variance→Counted, post emits a variance `StockAdjustment` via shared `StockMutator` + closes the count. Verified: variance −15 (post→on-hand 485) and +10 increase; re-post→409.
|
||||
- [x] Reorder alerts (query) + suggest requisition — `GET /stock/reorder-alerts` (available ≤ ROP, computed on read) + `POST …/{itemId}/requisition` (draft PR at suggested qty). Verified.
|
||||
|
||||
## 6. Cross-cutting
|
||||
> **Auth-enforcement gap (open):** JWT bearer *validation* is wired, but no token issuer exists yet and controllers are **not** `[Authorize]`-gated, so §1 endpoints are currently open. This is the AR-01/NFR-03 control surface — gate all v1 endpoints (fallback authorization policy) in the same change as `POST /auth/login`, then re-run the 02-SECURITY B.1 checklist and flip §1 items to `[x]`.
|
||||
- [ ] Audit log on every mutation (who/when/old→new)
|
||||
> **Status: COMPLETE.** Audit trail, doc numbering, reason codes, JournalEntryStub, negative-stock block, and now **authentication** (external AuthHex IdP integration) are all done + verified. The §6 security gate (NFR-03 auth + AR-01 audit) is met — **§1–§5 flipped `[~]`→`[x]`** (2026-07-14). FEFO pick-ordering is the only intentional deferral.
|
||||
- [x] Audit log on every mutation (who/when/old→new) — `AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred.
|
||||
- [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO.
|
||||
- [~] Auth: simple in-app login → JWT (`POST /auth/login`) — foundation only: `User` table + seeded `system` user (id 1) exist and `ICurrentUser.AuditUserId` stamps docs; login endpoint + `[Authorize]` still pending.
|
||||
- [ ] JournalEntryStub emitted per stock movement (data only)
|
||||
- [ ] Negative-stock policy enforcement (default block)
|
||||
- [ ] FEFO picking for perishables; block expired / on-hold issue
|
||||
- [x] Auth: **external AuthHex IdP integration** (2026-07-14) — ERPCore is a resource server. `JwtAuthExtensions` validates **RS256** against AuthHex's RSA **public** key (config `Auth:RsaPublicKeyXml` → `RsaSecurityKey`; `MapInboundClaims=false`), issuer `AuthHex`, audience `AuthHexClient` (no JWKS → static key). `[Authorize(ErpAccess)]` on `ApiControllerBase` gates every v1 endpoint; the `ErpAccess` policy `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` from `Auth:RequiredUserTypeCode`/`RequiredRoleCode` (empty ⇒ any valid ERP token — AuthHex is ERP-dedicated). **Shadow-user JIT provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`) maps the token's `UserId` **GUID** → a local `users` row (`auth_user_id` unique; Username/DisplayName = `NIC`), idempotent, and injects the local `long` id as `nameid` so `ICurrentUser.AuditUserId` resolves the real actor. Migration `AddAuthUserId`. **Verified:** no token→401; `/health`,`/api/meta`,Swagger anonymous; valid token→200; shadow user provisioned (User 2, Username=NIC, AuthUserId=GUID); item Create **audited as the shadow user (id 2, not system)**; re-request reuses the same user; door gate → **403** on UserType mismatch, **200** on match.
|
||||
- [x] JournalEntryStub emitted per stock movement (data only) — `JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70).
|
||||
- [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync` → `409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub.
|
||||
- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
|
||||
- [x] Reason codes (FR-X-04) — `ReasonCode` entity + `GET/POST /reason-codes`; standard set (docs/10 §B.8.3) seeded idempotently at startup (`DataSeeder`). Verified.
|
||||
|
||||
## Deferred (Phase 2+ — do NOT build now, hooks only)
|
||||
- [ ] Vendor invoice + three-way match
|
||||
@@ -88,3 +92,44 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
|
||||
- **Verified:** build clean; app boots; full procurement smoke green (see §2 note) — requisition→submit, RFQ→quotation→comparison, PO create/get/edit/approve/cancel, numbering increment, error paths 409/412/422.
|
||||
- **Deferred/next:** Purchase Return (needs GRN+stock), then §3 GRN, §4 Stock Core (FIFO/ledger), §5 stock transactions. Auth/audit (§6) still the gate for flipping §1/§2 to `[x]`.
|
||||
- Housekeeping: an empty user-created migration `20260709124415_initial` sits between InitialCreate and AddProcurement (applied, harmless no-op; emits a cosmetic CS8981 lowercase-name warning).
|
||||
|
||||
### 2026-07-13 — Stock Core (§4) + Goods Receipt (§3, minus purchase return)
|
||||
- Entities: Batch, Serial, StockLayer (FIFO), StockLedger (append-only), Grn/GrnLine + configs; enums Direction, HoldStatus, GrnStatus. DbSets + migration `AddStockAndGrn` (6 tables) applied.
|
||||
- Services: `FifoCostingService` (Services/Stock — inbound layer, ledger post, on-hand, valuation), `StockService` (enquiry/ledger/valuation), `GrnService` (create with PO-derived cost + over-receipt + batch resolve + UOM→base; confirm in `ExecuteInTransactionAsync`; release/reject). Controllers `/stock`, `/grns`.
|
||||
- **Verified against Postgres:** PO→GRN receive (client cost ignored, PO price used) → confirm → 2 FIFO layers + 2 ledger rows (running balance) → PO FullyReceived; on-hand 5000 / valuation 62500 / ledger In; **idempotent re-confirm** (on-hand stayed 5000); over-receipt `422` at the open-qty boundary, exact-fill `201`; batch receive **OnHold** → excluded from `available` → **release** → available; **UOM conversion** 10×Box-12 → 120 base units @10 (value 1200).
|
||||
- **Deferred (next):** §5 stock transactions — Transfer (dispatch/receive, in-transit, cost-preserving), Adjustment (auto-post + reason code), Count, Reorder alerts — which is where the FIFO **oldest-first consume + row lock** lands; then Purchase Return (§3.4) and GRN reject→return linkage. `ReasonCode` (§6) needed for adjustments/returns. Auth/audit (§6) remains the `[x]` gate.
|
||||
|
||||
### 2026-07-13 (2) — Stock Transactions (§5: Adjustment, Transfer, Reorder) + FIFO consume + Reason codes
|
||||
- **FIFO consume engine** in `FifoCostingService.ConsumeAsync`: oldest-first, row-locked via `SELECT … FOR UPDATE` (raw SQL, no LINQ composed on top so the lock reaches PG; `{batchId}::bigint` cast avoids a 42P18 null-param error), excludes on-hold + expired layers, throws `STOCK_NEGATIVE_BLOCKED`/`ONHOLD_NOT_ISSUABLE`/`EXPIRED_BATCH_BLOCKED`, returns consumed segments for cost-preserving moves.
|
||||
- Entities: ReasonCode, StockAdjustment/Line, StockTransfer/Line (+ `UnitCost`/`QtyReceived` extension on the transfer line for cost-preservation) + configs; enums TransferStatus, AdjustmentStatus, ReasonContext. Migration `AddStockTransactions` (5 tables) applied.
|
||||
- Services/controllers: `ReasonCodeService` (`/reason-codes`, startup seed via `DataSeeder`), `AdjustmentService` (`/stock-adjustments`), `TransferService` (`/stock-transfers` create/dispatch/receive), `ReorderService` (`/stock/reorder-alerts` + suggest-requisition). `StockService.GetOnHandAsync` now computes real inTransit.
|
||||
- **Verified against Postgres:** adjustment decrease FIFO-consume with blended cost 10.25 across two layers, negative→409, `REASON_CODE_REQUIRED`→400, wrong-context reason→422, increase-at-last-cost; transfer create→dispatch (source onHand↓, inTransit↑, consumedLayers)→receive (dest layer cost-preserved @12, value 3600), dest==src→422, dispatch-short→409; reorder alerts list + draft-PR suggestion; reason codes seeded (5 Adjustment + 4 Return).
|
||||
- **Deferred (next):** §5 **Count** (create snapshot → enter counts → post variance via the Adjustment engine), **Purchase Return** (§3.4, + GRN reject→return linkage), FEFO pick ordering, `JournalEntryStub`. Auth/audit (§6) remains the `[x]` gate.
|
||||
|
||||
### 2026-07-13 (3) — Count (§5.6) + Purchase Return (§3.4) + shared StockMutator
|
||||
- **`StockMutator`** (Services/Stock): shared signed-delta poster (negative → FIFO consume; positive → layer at last cost) + ledger, run inside the caller's txn. Adjustment/Count-post/Return all delegate to it → one code path for stock-affecting postings.
|
||||
- Refactored `AdjustmentService` onto `StockMutator` and added an **intermediate `SaveChanges`** so the header id is flushed before ledger posting — fixes a latent bug where new-in-txn documents wrote `sourceDocId=0` (verified: `ADJ` ledger now `sourceDocId=5`). Also fixed `ledgerRefs:[0]` by mapping ledger ids **after** commit (Count + Return).
|
||||
- Entities: StockCount/Line (+ CountType, CountStatus), PurchaseReturn/Line (+ ReturnStatus) + configs. Migration `AddCountsAndReturns` (4 tables) applied.
|
||||
- Services/controllers: `CountService` (`/stock-counts` create/enter/post), `PurchaseReturnService` (`/purchase-returns`).
|
||||
- **Verified against Postgres:** count snapshot 500 → counted 485 → post variance −15 (on-hand→485, adjustmentId+ledgerRefs), positive variance +10, re-post→409; return 100 outbound (on-hand→385), `REASON_CODE_REQUIRED`→400, non-Return reason→422, over-return→409 `STOCK_NEGATIVE_BLOCKED`.
|
||||
- **§5 is now complete.** Remaining Phase-1 backend: §6 (auth/login + `[Authorize]`, audit trail, `JournalEntryStub`, negative-stock per-item override, FEFO pick ordering). Auth/audit is still the gate for flipping §1–§5 `[~]`→`[x]`.
|
||||
|
||||
### 2026-07-13 (4) — §6 audit trail + JournalEntryStub (auth deferred by request)
|
||||
- **Audit trail (FR-X-02):** `AuditLog` (jsonb `changeSet`) written by an `ErpDbContext.SaveChanges/Async` override via `AuditScribe` — captures before save (accurate old→new), writes rows after inserts get their keys. Create = field set, Update = only changed fields `{old,new}`, Delete = prior row; excludes PK/RowVersion and the ledger/layer/sequence/journal/self tables. Actor from `ICurrentUser` (system=1). `ErpDbContext` now takes `ICurrentUser` (design-time migration still works via DI).
|
||||
- **JournalEntryStub (FR-STK-13):** emitted for every ledger entry in `FifoCostingService.PostLedgerAsync` (In → Dr `1300`/Cr `2100`; Out reverses; amount = value). Placeholder GL accounts.
|
||||
- Read endpoints (auditor role, beyond documented §11): `GET /audit-logs` (entityType/entityId/userId/from/to), `GET /journal-entries` (sourceDocType/sourceDocId). `AuditService`. Migration `AddAuditAndJournal` (2 tables, jsonb) applied.
|
||||
- **Verified against Postgres:** item Create logged full field set (userId 1); item Update logged only `Name` + `UpdatedAt` as `{old,new}`; GRN confirm → journal In Dr1300/Cr2100 amount 700; adjustment decrease → journal Out Dr2100/Cr1300 amount 70; StockAdjustment Create audited.
|
||||
- **Only auth remains for Phase 1.** Everything else in §6 is done. Auth (~~`POST /auth/login` + global `[Authorize]`~~ — **superseded 2026-07-14**, now external **AuthHex** IdP integration; see the next entry) is intentionally deferred per request; wiring it is what flips §1–§5 `[~]`→`[x]`. FEFO pick-ordering left as a documented deferral (would conflict with FIFO-costing integrity without a physical/cost layer split); negative-stock stays the resolved global block (open-decision #2).
|
||||
|
||||
### 2026-07-14 — Auth architecture change: external AuthHex IdP (docs-only pass)
|
||||
- **Plan changed:** auth is no longer a local `POST /auth/login` inside ERPCore. A **separate AuthHex IdP** (runs on `:5011`, source at `c:\Users\WAS\Documents\Developments\ERP_Auth_Service\`) owns login/registration/recovery; ERPCore becomes a **resource server** that only validates AuthHex tokens. Updated `docs/10-BACKEND-PHASE1.md` (header, A.4 auth/audit-actor, A.5 DI, B.2.3, FR-X-01, NFR-03, C.7 `USER`, C.9, B.8.4 decision #10) and this file. **No code changed this pass.**
|
||||
- **Decisions (confirmed):** (1) identity = **shadow-user JIT provisioning** — add `auth_user_id` GUID (unique) to `users`, keep all `long` FKs; (2) authorization = **door-gate to an ERP `UserType`/`Role`**, per-endpoint RBAC still deferred; (3) scope = **docs only** now, code integration is a follow-up.
|
||||
- **Confirmed AuthHex facts:** RS256 (RSA 2048; ERPCore needs the static **public** key — no JWKS), issuer `AuthHex`, audience `AuthHexClient`, lifetime 1000 min prod / 60 min dev; claims `UserId`(GUID)/`UserTypeCode`/`RoleCode`/`NIC`/`jti`/`iat` (no `sub`/`nameid`); BCrypt password hashing; login `POST /api/loginUser {identifier,password}`.
|
||||
- **Open blockers (resolve before the code phase):** exact ERP `UserTypeCode`/`RoleCode` for the door gate (must exist in AuthHex); RSA public-key distribution + rotation process (no JWKS); shadow-user `Username`/`DisplayName` source (token has no name); secrets hygiene in AuthHex config (private key/SMTP/DB in plaintext); `docs/11 §2.0` still documents `/auth/login` (now AuthHex-owned) — recommend a follow-up annotation.
|
||||
|
||||
### 2026-07-14 (2) — Auth code integration: AuthHex resource server (§6 COMPLETE → §1–§5 flipped `[x]`)
|
||||
- **RS256 validation:** `JwtAuthExtensions` rewritten — `RsaSecurityKey` from `Auth:RsaPublicKeyXml` (AuthHex public key), `ValidIssuer=AuthHex`, `ValidAudience=AuthHexClient`, `ValidAlgorithms=[RS256]`, `MapInboundClaims=false` (keeps `UserId`/`UserTypeCode`/`RoleCode` verbatim). `appsettings.json` `Jwt`→`Auth` (public key + issuer/audience + `RequiredUserTypeCode`/`RequiredRoleCode`); removed the HS256 dev signing key.
|
||||
- **Door policy** `ErpAccess`: `RequireAuthenticatedUser` + optional `RequireClaim(UserTypeCode/RoleCode)` when configured (AuthHex is ERP-dedicated → empty default = any valid token). `[Authorize(ErpAccess)]` on `ApiControllerBase`; `MetaController`/health/Swagger stay anonymous.
|
||||
- **Shadow-user provisioning:** `ShadowUserClaimsTransformation` (`IClaimsTransformation`, scoped) maps token `UserId` GUID → local `users` row (`auth_user_id` unique, Username/DisplayName=`NIC`), idempotent w/ race-safe re-read, injects local `long` id as `nameid`. `User.AuthUserId` (Guid?) + `AuthHexClaims` consts + migration `AddAuthUserId`.
|
||||
- **Verified (minted AuthHex-shaped RS256 token, signed with AuthHex's real private key):** no token→401; `/health`,`/api/meta`,Swagger→200 anon; valid token→200; POST item→201 **audited as shadow user id 2** (Username=NIC, AuthUserId=GUID), not system; repeat request reuses user (1 provision); door gate `RequiredUserTypeCode=WAREHOUSE` → ERP-type token **403**, WAREHOUSE-type token **200**. Build clean; migration applied.
|
||||
- **§6 COMPLETE.** Only intentional deferral left in Phase 1: FEFO pick-ordering (§6, `[~]`). Follow-ups: set the real ERP `Auth:RequiredUserTypeCode`/`RoleCode` for production; secure the RSA key rotation process.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user