diff --git a/Backend/ERPCore/Controllers/ApiControllerBase.cs b/Backend/ERPCore/Controllers/ApiControllerBase.cs index ad4d1e3..e00e8b2 100644 --- a/Backend/ERPCore/Controllers/ApiControllerBase.cs +++ b/Backend/ERPCore/Controllers/ApiControllerBase.cs @@ -1,5 +1,7 @@ using ERPCore.Common.Http; +using ERPCore.Infra.Auth; using ERPCore.System.Errors; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace ERPCore.Controllers; @@ -8,10 +10,12 @@ namespace ERPCore.Controllers; /// Base for the v1 API controllers. Centralises ETag / If-Match handling /// (docs/11-BACKEND-PHASE1.md §1.6) so concurrency behaviour is uniform. /// Each controller declares its own explicit lowercase [Route] to match -/// the API contract paths (docs/11 §1.1). +/// the API contract paths (docs/11 §1.1). Every v1 endpoint requires a valid +/// AuthHex token satisfying the ERP door policy (docs/10 A.4). /// [ApiController] [Produces("application/json")] +[Authorize(JwtAuthExtensions.ErpAccessPolicy)] public abstract class ApiControllerBase : ControllerBase { /// Parse a mandatory If-Match header, or 428 if absent/malformed. diff --git a/Backend/ERPCore/Controllers/AuditLogsController.cs b/Backend/ERPCore/Controllers/AuditLogsController.cs new file mode 100644 index 0000000..6f66cf4 --- /dev/null +++ b/Backend/ERPCore/Controllers/AuditLogsController.cs @@ -0,0 +1,26 @@ +using ERPCore.Dtos.Audit; +using ERPCore.Dtos.Common; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Read-only audit trail (FR-X-02; auditor role). Extends the documented §11 API — +/// the audit trail is required (AR-01 compensating control) and read access is the +/// only way to consume it. +/// +[Route("api/v1/audit-logs")] +public sealed class AuditLogsController : ApiControllerBase +{ + private readonly IAuditService _audit; + + public AuditLogsController(IAuditService audit) => _audit = audit; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] string? entityType, [FromQuery] int? entityId, [FromQuery] int? userId, + [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _audit.ListLogsAsync(entityType, entityId, userId, from, to, query, ct)); +} diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs new file mode 100644 index 0000000..20ba722 --- /dev/null +++ b/Backend/ERPCore/Controllers/GrnsController.cs @@ -0,0 +1,52 @@ +using ERPCore.Dtos.Grn; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Goods-receipt endpoints (docs/11 §4). +[Route("api/v1/grns")] +public sealed class GrnsController : ApiControllerBase +{ + private readonly IGrnService _grns; + + public GrnsController(IGrnService grns) => _grns = grns; + + [HttpGet("{grnId:int}")] + [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int grnId, CancellationToken ct) + { + var dto = await _grns.GetAsync(grnId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + /// Create a Draft GRN against a PO or direct. Cost is PO-derived for PO lines. + [HttpPost] + [ProducesResponseType(typeof(GrnDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateGrnRequest request, CancellationToken ct) + { + var dto = await _grns.CreateAsync(request, ct); + return Created($"/api/v1/grns/{dto.GrnId}", dto); + } + + /// Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn). + [HttpPost("{grnId:int}/confirm")] + [ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Confirm( + int grnId, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken ct) + => Ok(await _grns.ConfirmAsync(grnId, idempotencyKey, ct)); + + /// Release or reject an inspection-hold line (FR-GRN-05). + [HttpPost("{grnId:int}/lines/{grnLineId:int}/release")] + [ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Release( + int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct) + => Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct)); +} diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs index eab6c79..c98493a 100644 --- a/Backend/ERPCore/Controllers/ItemsController.cs +++ b/Backend/ERPCore/Controllers/ItemsController.cs @@ -20,16 +20,16 @@ public sealed class ItemsController : ApiControllerBase public async Task>> List( [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, - [FromQuery] long? categoryId, + [FromQuery] int? categoryId, [FromQuery] TrackingMode? trackingMode, CancellationToken ct) => Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct)); /// Get a single item; returns an ETag for optimistic concurrency. - [HttpGet("{itemId:long}")] + [HttpGet("{itemId:int}")] [ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetById(long itemId, CancellationToken ct) + public async Task> GetById(int itemId, CancellationToken ct) { var result = await _items.GetAsync(itemId, ct); if (result is null) return NotFound(); @@ -51,11 +51,11 @@ public sealed class ItemsController : ApiControllerBase } /// Full update; requires If-Match (412 on stale ETag). - [HttpPut("{itemId:long}")] + [HttpPut("{itemId:int}")] [ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] - public async Task> Update(long itemId, [FromBody] UpdateItemRequest request, CancellationToken ct) + public async Task> Update(int itemId, [FromBody] UpdateItemRequest request, CancellationToken ct) { var expected = RequireIfMatch(); var result = await _items.UpdateAsync(itemId, request, expected, ct); @@ -64,26 +64,26 @@ public sealed class ItemsController : ApiControllerBase } /// Activate / deactivate the item (FR-MD-08 — deactivate, not delete). - [HttpPatch("{itemId:long}/status")] + [HttpPatch("{itemId:int}/status")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task SetStatus(long itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct) + public async Task SetStatus(int itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct) { await _items.SetStatusAsync(itemId, request.Status, ct); return NoContent(); } /// Replace the item's per-warehouse reorder settings (FR-MD-05). - [HttpPut("{itemId:long}/reorder")] + [HttpPut("{itemId:int}/reorder")] [ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> UpdateReorder(long itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct) + public async Task> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct) => Ok(await _items.UpdateReorderAsync(itemId, request, ct)); /// Replace the item's UOM conversions (FR-MD-02). - [HttpPut("{itemId:long}/uom-conversions")] + [HttpPut("{itemId:int}/uom-conversions")] [ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> UpdateUomConversions(long itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct) + public async Task> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct) => Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct)); } diff --git a/Backend/ERPCore/Controllers/JournalEntriesController.cs b/Backend/ERPCore/Controllers/JournalEntriesController.cs new file mode 100644 index 0000000..54789c9 --- /dev/null +++ b/Backend/ERPCore/Controllers/JournalEntriesController.cs @@ -0,0 +1,24 @@ +using ERPCore.Dtos.Audit; +using ERPCore.Dtos.Common; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Read-only GL-ready journal stubs (FR-STK-13; consumed by the Accounting phase). +/// Data only — no posting in Phase 1. +/// +[Route("api/v1/journal-entries")] +public sealed class JournalEntriesController : ApiControllerBase +{ + private readonly IAuditService _audit; + + public JournalEntriesController(IAuditService audit) => _audit = audit; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _audit.ListJournalAsync(sourceDocType, sourceDocId, query, ct)); +} diff --git a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs index 03c9524..a1bf9b9 100644 --- a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs +++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs @@ -17,13 +17,13 @@ public sealed class PurchaseOrdersController : ApiControllerBase [HttpGet] [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] public async Task>> List( - [FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] long? vendorId, CancellationToken ct) + [FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct) => Ok(await _pos.ListAsync(query, status, vendorId, ct)); - [HttpGet("{poId:long}")] + [HttpGet("{poId:int}")] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetById(long poId, CancellationToken ct) + public async Task> GetById(int poId, CancellationToken ct) { var result = await _pos.GetAsync(poId, ct); if (result is null) return NotFound(); @@ -44,12 +44,12 @@ public sealed class PurchaseOrdersController : ApiControllerBase } /// Edit while open (FR-PROC-05); requires If-Match. 409 PO_NOT_EDITABLE if closed. - [HttpPut("{poId:long}")] + [HttpPut("{poId:int}")] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] - public async Task> Update(long poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct) + public async Task> Update(int poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct) { var expected = RequireIfMatch(); var result = await _pos.UpdateAsync(poId, request, expected, ct); @@ -58,17 +58,17 @@ public sealed class PurchaseOrdersController : ApiControllerBase } /// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled. - [HttpPost("{poId:long}/approve")] + [HttpPost("{poId:int}/approve")] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> Approve(long poId, CancellationToken ct) + public async Task> Approve(int poId, CancellationToken ct) => Ok(await _pos.ApproveAsync(poId, ct)); /// Cancel — 409 if any goods have been received against the PO. - [HttpPost("{poId:long}/cancel")] + [HttpPost("{poId:int}/cancel")] [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task> Cancel(long poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct) + public async Task> Cancel(int poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct) => Ok(await _pos.CancelAsync(poId, request.Reason, ct)); } diff --git a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs new file mode 100644 index 0000000..9f59a56 --- /dev/null +++ b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs @@ -0,0 +1,26 @@ +using ERPCore.Dtos.Procurement; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Purchase-return endpoints (docs/11 §3.4). +[Route("api/v1/purchase-returns")] +public sealed class PurchaseReturnsController : ApiControllerBase +{ + private readonly IPurchaseReturnService _returns; + + public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns; + + /// Create + auto-post a return (outbound movement). 409 if return exceeds available stock. + [HttpPost] + [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreatePurchaseReturnRequest request, CancellationToken ct) + { + var dto = await _returns.CreateAsync(request, ct); + return Created($"/api/v1/purchase-returns/{dto.ReturnId}", dto); + } +} diff --git a/Backend/ERPCore/Controllers/ReasonCodesController.cs b/Backend/ERPCore/Controllers/ReasonCodesController.cs new file mode 100644 index 0000000..e67aa32 --- /dev/null +++ b/Backend/ERPCore/Controllers/ReasonCodesController.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Reference; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Reason-code reference endpoints (docs/11 §6). +[Route("api/v1/reason-codes")] +public sealed class ReasonCodesController : ApiControllerBase +{ + private readonly IReasonCodeService _codes; + + public ReasonCodesController(IReasonCodeService codes) => _codes = codes; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _codes.ListAsync(context, query, ct)); + + [HttpPost] + [ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create([FromBody] CreateReasonCodeRequest request, CancellationToken ct) + { + var dto = await _codes.CreateAsync(request, ct); + return Created($"/api/v1/reason-codes/{dto.ReasonCodeId}", dto); + } +} diff --git a/Backend/ERPCore/Controllers/RequisitionsController.cs b/Backend/ERPCore/Controllers/RequisitionsController.cs index 10d4c08..ab849b9 100644 --- a/Backend/ERPCore/Controllers/RequisitionsController.cs +++ b/Backend/ERPCore/Controllers/RequisitionsController.cs @@ -18,10 +18,10 @@ public sealed class RequisitionsController : ApiControllerBase public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) => Ok(await _requisitions.ListAsync(query, ct)); - [HttpGet("{requisitionId:long}")] + [HttpGet("{requisitionId:int}")] [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetById(long requisitionId, CancellationToken ct) + public async Task> GetById(int requisitionId, CancellationToken ct) { var dto = await _requisitions.GetAsync(requisitionId, ct); return dto is null ? NotFound() : Ok(dto); @@ -36,9 +36,9 @@ public sealed class RequisitionsController : ApiControllerBase return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto); } - [HttpPost("{requisitionId:long}/submit")] + [HttpPost("{requisitionId:int}/submit")] [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> Submit(long requisitionId, CancellationToken ct) + public async Task> Submit(int requisitionId, CancellationToken ct) => Ok(await _requisitions.SubmitAsync(requisitionId, ct)); } diff --git a/Backend/ERPCore/Controllers/RfqsController.cs b/Backend/ERPCore/Controllers/RfqsController.cs index 736ddbc..7cc0e1f 100644 --- a/Backend/ERPCore/Controllers/RfqsController.cs +++ b/Backend/ERPCore/Controllers/RfqsController.cs @@ -12,10 +12,10 @@ public sealed class RfqsController : ApiControllerBase public RfqsController(IRfqService rfqs) => _rfqs = rfqs; - [HttpGet("{rfqId:long}")] + [HttpGet("{rfqId:int}")] [ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetById(long rfqId, CancellationToken ct) + public async Task> GetById(int rfqId, CancellationToken ct) { var dto = await _rfqs.GetAsync(rfqId, ct); return dto is null ? NotFound() : Ok(dto); @@ -30,20 +30,20 @@ public sealed class RfqsController : ApiControllerBase return Created($"/api/v1/rfqs/{dto.RfqId}", dto); } - [HttpPost("{rfqId:long}/quotations")] + [HttpPost("{rfqId:int}/quotations")] [ProducesResponseType(typeof(VendorQuotationDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] - public async Task> AddQuotation(long rfqId, [FromBody] CreateQuotationRequest request, CancellationToken ct) + public async Task> AddQuotation(int rfqId, [FromBody] CreateQuotationRequest request, CancellationToken ct) { var dto = await _rfqs.AddQuotationAsync(rfqId, request, ct); return Created($"/api/v1/rfqs/{rfqId}/quotations/{dto.QuotationId}", dto); } - [HttpGet("{rfqId:long}/comparison")] + [HttpGet("{rfqId:int}/comparison")] [ProducesResponseType(typeof(RfqComparisonDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> Comparison(long rfqId, CancellationToken ct) + public async Task> Comparison(int rfqId, CancellationToken ct) => Ok(await _rfqs.GetComparisonAsync(rfqId, ct)); } diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs new file mode 100644 index 0000000..7952ba6 --- /dev/null +++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs @@ -0,0 +1,26 @@ +using ERPCore.Dtos.Stock; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Stock-adjustment endpoints (docs/11 §5.5). +[Route("api/v1/stock-adjustments")] +public sealed class StockAdjustmentsController : ApiControllerBase +{ + private readonly IAdjustmentService _adjustments; + + public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments; + + /// Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes). + [HttpPost] + [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateAdjustmentRequest request, CancellationToken ct) + { + var dto = await _adjustments.CreateAsync(request, ct); + return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto); + } +} diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs new file mode 100644 index 0000000..652942a --- /dev/null +++ b/Backend/ERPCore/Controllers/StockController.cs @@ -0,0 +1,56 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; +using ERPCore.Dtos.Stock; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7). +[Route("api/v1/stock")] +public sealed class StockController : ApiControllerBase +{ + private readonly IStockService _stock; + private readonly IReorderService _reorder; + + public StockController(IStockService stock, IReorderService reorder) + { + _stock = stock; + _reorder = reorder; + } + + [HttpGet("on-hand")] + [ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)] + public async Task> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) + => Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct)); + + [HttpGet("ledger")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> Ledger( + [FromQuery] int? itemId, [FromQuery] int? warehouseId, + [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct)); + + [HttpGet("valuation")] + [ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)] + public async Task> Valuation([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) + => Ok(await _stock.GetValuationAsync(itemId, warehouseId, ct)); + + /// Items at/below their reorder point (FR-STK-10), computed on read. + [HttpGet("reorder-alerts")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> ReorderAlerts( + [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct)); + + /// Create a draft requisition for an item's suggested reorder quantity. + [HttpPost("reorder-alerts/{itemId:int}/requisition")] + [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> SuggestRequisition( + int itemId, [FromQuery] int warehouseId, CancellationToken ct) + { + var dto = await _reorder.CreateSuggestedRequisitionAsync(itemId, warehouseId, ct); + return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto); + } +} diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs new file mode 100644 index 0000000..008ca79 --- /dev/null +++ b/Backend/ERPCore/Controllers/StockCountsController.cs @@ -0,0 +1,49 @@ +using ERPCore.Dtos.Stock; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Stock-count endpoints (docs/11 §5.6). +[Route("api/v1/stock-counts")] +public sealed class StockCountsController : ApiControllerBase +{ + private readonly ICountService _counts; + + public StockCountsController(ICountService counts) => _counts = counts; + + [HttpGet("{countId:int}")] + [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int countId, CancellationToken ct) + { + var dto = await _counts.GetAsync(countId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + /// Create a count with system quantities snapshotted (immutable). + [HttpPost] + [ProducesResponseType(typeof(CountDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateCountRequest request, CancellationToken ct) + { + var dto = await _counts.CreateAsync(request, ct); + return Created($"/api/v1/stock-counts/{dto.CountId}", dto); + } + + /// Enter counted quantities; variance = counted − system. + [HttpPut("{countId:int}/counts")] + [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> EnterCounts(int countId, [FromBody] EnterCountsRequest request, CancellationToken ct) + => Ok(await _counts.EnterCountsAsync(countId, request, ct)); + + /// Post: emit a variance adjustment and close the count. + [HttpPost("{countId:int}/post")] + [ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Post(int countId, CancellationToken ct) + => Ok(await _counts.PostAsync(countId, ct)); +} diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs new file mode 100644 index 0000000..fd5f52b --- /dev/null +++ b/Backend/ERPCore/Controllers/StockTransfersController.cs @@ -0,0 +1,49 @@ +using ERPCore.Dtos.Stock; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Stock-transfer endpoints (docs/11 §5.4). +[Route("api/v1/stock-transfers")] +public sealed class StockTransfersController : ApiControllerBase +{ + private readonly ITransferService _transfers; + + public StockTransfersController(ITransferService transfers) => _transfers = transfers; + + [HttpGet("{transferId:int}")] + [ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int transferId, CancellationToken ct) + { + var dto = await _transfers.GetAsync(transferId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + [ProducesResponseType(typeof(TransferDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateTransferRequest request, CancellationToken ct) + { + var dto = await _transfers.CreateAsync(request, ct); + return Created($"/api/v1/stock-transfers/{dto.TransferId}", dto); + } + + /// Dispatch: consume source FIFO layers into in-transit. 409 STOCK_NEGATIVE_BLOCKED if short. + [HttpPost("{transferId:int}/dispatch")] + [ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Dispatch(int transferId, CancellationToken ct) + => Ok(await _transfers.DispatchAsync(transferId, ct)); + + /// Receive: create the destination layer at the inherited cost (cost-preserving). + [HttpPost("{transferId:int}/receive")] + [ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Receive(int transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct) + => Ok(await _transfers.ReceiveAsync(transferId, request, ct)); +} diff --git a/Backend/ERPCore/Controllers/VendorsController.cs b/Backend/ERPCore/Controllers/VendorsController.cs index 40b17b3..ee1021d 100644 --- a/Backend/ERPCore/Controllers/VendorsController.cs +++ b/Backend/ERPCore/Controllers/VendorsController.cs @@ -20,10 +20,10 @@ public sealed class VendorsController : ApiControllerBase [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct) => Ok(await _vendors.ListAsync(query, status, ct)); - [HttpGet("{vendorId:long}")] + [HttpGet("{vendorId:int}")] [ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetById(long vendorId, CancellationToken ct) + public async Task> GetById(int vendorId, CancellationToken ct) { var result = await _vendors.GetAsync(vendorId, ct); if (result is null) return NotFound(); @@ -42,11 +42,11 @@ public sealed class VendorsController : ApiControllerBase return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value); } - [HttpPut("{vendorId:long}")] + [HttpPut("{vendorId:int}")] [ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] - public async Task> Update(long vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct) + public async Task> Update(int vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct) { var expected = RequireIfMatch(); var result = await _vendors.UpdateAsync(vendorId, request, expected, ct); @@ -54,10 +54,10 @@ public sealed class VendorsController : ApiControllerBase return Ok(result.Value); } - [HttpPatch("{vendorId:long}/status")] + [HttpPatch("{vendorId:int}/status")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task SetStatus(long vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct) + public async Task SetStatus(int vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct) { await _vendors.SetStatusAsync(vendorId, request.Status, ct); return NoContent(); diff --git a/Backend/ERPCore/Controllers/WarehousesController.cs b/Backend/ERPCore/Controllers/WarehousesController.cs index 4c2eb5c..ff3304b 100644 --- a/Backend/ERPCore/Controllers/WarehousesController.cs +++ b/Backend/ERPCore/Controllers/WarehousesController.cs @@ -18,10 +18,10 @@ public sealed class WarehousesController : ApiControllerBase public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) => Ok(await _warehouses.ListAsync(query, ct)); - [HttpGet("{warehouseId:long}")] + [HttpGet("{warehouseId:int}")] [ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetById(long warehouseId, CancellationToken ct) + public async Task> GetById(int warehouseId, CancellationToken ct) { var dto = await _warehouses.GetAsync(warehouseId, ct); return dto is null ? NotFound() : Ok(dto); @@ -36,17 +36,17 @@ public sealed class WarehousesController : ApiControllerBase return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto); } - [HttpGet("{warehouseId:long}/bins")] + [HttpGet("{warehouseId:int}/bins")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task>> ListBins(long warehouseId, CancellationToken ct) + public async Task>> ListBins(int warehouseId, CancellationToken ct) => Ok(await _warehouses.ListBinsAsync(warehouseId, ct)); - [HttpPost("{warehouseId:long}/bins")] + [HttpPost("{warehouseId:int}/bins")] [ProducesResponseType(typeof(BinDto), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task> CreateBin(long warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct) + public async Task> CreateBin(int warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct) { var dto = await _warehouses.CreateBinAsync(warehouseId, request, ct); return Created($"/api/v1/warehouses/{warehouseId}/bins/{dto.BinId}", dto); diff --git a/Backend/ERPCore/Domain/Entities/AuditLog.cs b/Backend/ERPCore/Domain/Entities/AuditLog.cs new file mode 100644 index 0000000..c139a8c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/AuditLog.cs @@ -0,0 +1,23 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Immutable audit trail entry (FR-X-02) — the compensating control for the deferred +/// RBAC (02-SECURITY AR-01/B.3). One row per create/update/delete of an audited +/// entity, capturing who / when / what changed (old→new in ). +/// Written automatically by ErpDbContext.SaveChangesAsync. Append-only at the +/// app level; DB-role revocation of UPDATE/DELETE is deferred hardening (B.3). +/// Model: docs/10 Part C.7. +/// +public class AuditLog +{ + public int AuditId { get; set; } + public int UserId { get; set; } + public string EntityType { get; set; } = string.Empty; + public int EntityId { get; set; } + public AuditAction Action { get; set; } + /// JSON change set: field→value (create/delete) or field→{old,new} (update). + public string ChangeSet { get; set; } = "{}"; + public DateTime CreatedAt { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Batch.cs b/Backend/ERPCore/Domain/Entities/Batch.cs new file mode 100644 index 0000000..03ba570 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Batch.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Batch/lot for a batch-tracked item (FR-GRN-04, FR-WH-03). Expiry drives FEFO +/// picking of perishables. Model: docs/10 Part C.4. +/// +public class Batch +{ + public int BatchId { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public string BatchNo { get; set; } = string.Empty; + public DateOnly? ExpiryDate { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Bin.cs b/Backend/ERPCore/Domain/Entities/Bin.cs index 331fce1..1d252e3 100644 --- a/Backend/ERPCore/Domain/Entities/Bin.cs +++ b/Backend/ERPCore/Domain/Entities/Bin.cs @@ -6,9 +6,9 @@ namespace ERPCore.Domain.Entities; /// public class Bin { - public long BinId { get; set; } + public int BinId { get; set; } - public long WarehouseId { get; set; } + public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } public string Code { get; set; } = string.Empty; diff --git a/Backend/ERPCore/Domain/Entities/Category.cs b/Backend/ERPCore/Domain/Entities/Category.cs index 5285373..6c34b87 100644 --- a/Backend/ERPCore/Domain/Entities/Category.cs +++ b/Backend/ERPCore/Domain/Entities/Category.cs @@ -6,10 +6,10 @@ namespace ERPCore.Domain.Entities; /// public class Category { - public long CategoryId { get; set; } + public int CategoryId { get; set; } public string Name { get; set; } = string.Empty; - public long? ParentId { get; set; } + public int? ParentId { get; set; } public Category? Parent { get; set; } public ICollection Children { get; set; } = new List(); } diff --git a/Backend/ERPCore/Domain/Entities/Grn.cs b/Backend/ERPCore/Domain/Entities/Grn.cs new file mode 100644 index 0000000..5642f1b --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Grn.cs @@ -0,0 +1,37 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Goods Receipt Note header (FR-GRN-01/02). Raised against a PO or direct +/// ( null). On confirm each line creates a FIFO layer and posts +/// an inbound ledger entry. Mutable aggregate with an +/// concurrency token (docs/10 C.10). Model: docs/10 Part C.3. +/// +public class Grn +{ + public int GrnId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public int? PoId { get; set; } + public PurchaseOrder? PurchaseOrder { get; set; } + + public int VendorId { get; set; } + public Vendor? Vendor { get; set; } + + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public GrnStatus Status { get; set; } = GrnStatus.Draft; + + public int CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? PostedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token. + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs new file mode 100644 index 0000000..5805127 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs @@ -0,0 +1,37 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// GRN line (FR-GRN-04..08). is the PO-derived cost for +/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost +/// for direct receipts. = qty × unitCost. +/// gates issuability. Model: docs/10 Part C.3. +/// +public class GrnLine +{ + public int GrnLineId { get; set; } + + public int GrnId { get; set; } + public Grn? Grn { get; set; } + + public int? PoLineId { get; set; } + public PoLine? PoLine { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public int UomId { get; set; } + public Uom? Uom { get; set; } + + public int? BinId { get; set; } + public Bin? Bin { get; set; } + + public int? BatchId { get; set; } + public Batch? Batch { get; set; } + + public decimal Qty { get; set; } + public decimal UnitCost { get; set; } + public decimal ReceivedValue { get; set; } + public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; +} diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs index 4083f57..f308dd3 100644 --- a/Backend/ERPCore/Domain/Entities/Item.cs +++ b/Backend/ERPCore/Domain/Entities/Item.cs @@ -9,18 +9,18 @@ namespace ERPCore.Domain.Entities; /// public class Item { - public long ItemId { get; set; } + public int ItemId { get; set; } public string Sku { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string? Description { get; set; } - public long CategoryId { get; set; } + public int CategoryId { get; set; } public Category? Category { get; set; } - public long BaseUomId { get; set; } + public int BaseUomId { get; set; } public Uom? BaseUom { get; set; } - public long? DefaultVendorId { get; set; } + public int? DefaultVendorId { get; set; } public Vendor? DefaultVendor { get; set; } public ItemType ItemType { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/ItemReorder.cs b/Backend/ERPCore/Domain/Entities/ItemReorder.cs index 8a3ecfe..65350c1 100644 --- a/Backend/ERPCore/Domain/Entities/ItemReorder.cs +++ b/Backend/ERPCore/Domain/Entities/ItemReorder.cs @@ -7,12 +7,12 @@ namespace ERPCore.Domain.Entities; /// public class ItemReorder { - public long ReorderId { get; set; } + public int ReorderId { get; set; } - public long ItemId { get; set; } + public int ItemId { get; set; } public Item? Item { get; set; } - public long WarehouseId { get; set; } + public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } public decimal ReorderPoint { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/JournalEntryStub.cs b/Backend/ERPCore/Domain/Entities/JournalEntryStub.cs new file mode 100644 index 0000000..b9dc74f --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/JournalEntryStub.cs @@ -0,0 +1,18 @@ +namespace ERPCore.Domain.Entities; + +/// +/// GL-ready journal entry emitted per stock movement (FR-STK-13) — data only, no +/// posting in Phase 1 (the Accounting module consumes these later). One row per +/// ledger entry, referencing the same source document polymorphically. Account +/// codes are Phase-1 placeholders until a chart of accounts exists. +/// Model: docs/10 Part C.7. +/// +public class JournalEntryStub +{ + public int JournalId { get; set; } + public string SourceDocType { get; set; } = string.Empty; + public int SourceDocId { get; set; } + public string DebitAccount { get; set; } = string.Empty; + public string CreditAccount { get; set; } = string.Empty; + public decimal Amount { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/NumberSequence.cs b/Backend/ERPCore/Domain/Entities/NumberSequence.cs index 6db8de6..c4f7ffc 100644 --- a/Backend/ERPCore/Domain/Entities/NumberSequence.cs +++ b/Backend/ERPCore/Domain/Entities/NumberSequence.cs @@ -8,8 +8,8 @@ namespace ERPCore.Domain.Entities; /// public class NumberSequence { - public long SequenceId { get; set; } + public int SequenceId { get; set; } public string DocType { get; set; } = string.Empty; public int Year { get; set; } - public long LastNumber { get; set; } + public int LastNumber { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs index b564499..1f38169 100644 --- a/Backend/ERPCore/Domain/Entities/PoLine.cs +++ b/Backend/ERPCore/Domain/Entities/PoLine.cs @@ -7,18 +7,18 @@ namespace ERPCore.Domain.Entities; /// public class PoLine { - public long PoLineId { get; set; } + public int PoLineId { get; set; } - public long PoId { get; set; } + public int PoId { get; set; } public PurchaseOrder? PurchaseOrder { get; set; } - public long ItemId { get; set; } + public int ItemId { get; set; } public Item? Item { get; set; } - public long UomId { get; set; } + public int UomId { get; set; } public Uom? Uom { get; set; } - public long WarehouseId { get; set; } + public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } public decimal Qty { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs index 257f67d..a94c0a1 100644 --- a/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs +++ b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs @@ -11,19 +11,19 @@ namespace ERPCore.Domain.Entities; /// public class PurchaseOrder { - public long PoId { get; set; } + public int PoId { get; set; } public string DocNo { get; set; } = string.Empty; - public long VendorId { get; set; } + public int VendorId { get; set; } public Vendor? Vendor { get; set; } - public long? RequisitionId { get; set; } + public int? RequisitionId { get; set; } public Requisition? Requisition { get; set; } public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft; public bool ApprovalRequired { get; set; } - public long CreatedBy { get; set; } + public int CreatedBy { get; set; } public User? Creator { get; set; } public DateTime CreatedAt { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs b/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs new file mode 100644 index 0000000..a663950 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs @@ -0,0 +1,32 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Purchase return header (FR-PROC-08) — returns received goods to a vendor, +/// generating an outbound stock movement. Auto-posts with a mandatory reason code. +/// Model: docs/10 Part C.2. +/// +public class PurchaseReturn +{ + public int ReturnId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public int VendorId { get; set; } + public Vendor? Vendor { get; set; } + + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public int ReasonCodeId { get; set; } + public ReasonCode? ReasonCode { get; set; } + + public ReturnStatus Status { get; set; } = ReturnStatus.Posted; + + public int CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/PurchaseReturnLine.cs b/Backend/ERPCore/Domain/Entities/PurchaseReturnLine.cs new file mode 100644 index 0000000..9fa5f98 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PurchaseReturnLine.cs @@ -0,0 +1,21 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Purchase-return line (FR-PROC-08) referencing the original GRN line for +/// traceability. is in base UOM. Model: docs/10 Part C.2. +/// +public class PurchaseReturnLine +{ + public int ReturnLineId { get; set; } + + public int ReturnId { get; set; } + public PurchaseReturn? Return { get; set; } + + public int? GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public decimal Qty { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/ReasonCode.cs b/Backend/ERPCore/Domain/Entities/ReasonCode.cs new file mode 100644 index 0000000..7bb34e0 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/ReasonCode.cs @@ -0,0 +1,15 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Configurable reason code for adjustments, returns and count variances +/// (FR-X-04). Model: docs/10 Part C.7. +/// +public class ReasonCode +{ + public int ReasonCodeId { get; set; } + public string Code { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public ReasonContext Context { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Requisition.cs b/Backend/ERPCore/Domain/Entities/Requisition.cs index e6cfda6..29103ca 100644 --- a/Backend/ERPCore/Domain/Entities/Requisition.cs +++ b/Backend/ERPCore/Domain/Entities/Requisition.cs @@ -8,10 +8,10 @@ namespace ERPCore.Domain.Entities; /// public class Requisition { - public long RequisitionId { get; set; } + public int RequisitionId { get; set; } public string DocNo { get; set; } = string.Empty; - public long RequestedBy { get; set; } + public int RequestedBy { get; set; } public User? Requester { get; set; } public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft; diff --git a/Backend/ERPCore/Domain/Entities/RequisitionLine.cs b/Backend/ERPCore/Domain/Entities/RequisitionLine.cs index 13afc08..ff1bd80 100644 --- a/Backend/ERPCore/Domain/Entities/RequisitionLine.cs +++ b/Backend/ERPCore/Domain/Entities/RequisitionLine.cs @@ -3,12 +3,12 @@ namespace ERPCore.Domain.Entities; /// Requisition line (FR-PROC-01). Model: docs/10 Part C.2. public class RequisitionLine { - public long ReqLineId { get; set; } + public int ReqLineId { get; set; } - public long RequisitionId { get; set; } + public int RequisitionId { get; set; } public Requisition? Requisition { get; set; } - public long ItemId { get; set; } + public int ItemId { get; set; } public Item? Item { get; set; } public decimal Qty { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/Rfq.cs b/Backend/ERPCore/Domain/Entities/Rfq.cs index e7177fd..5a4e5c1 100644 --- a/Backend/ERPCore/Domain/Entities/Rfq.cs +++ b/Backend/ERPCore/Domain/Entities/Rfq.cs @@ -8,10 +8,10 @@ namespace ERPCore.Domain.Entities; /// public class Rfq { - public long RfqId { get; set; } + public int RfqId { get; set; } public string DocNo { get; set; } = string.Empty; - public long RequisitionId { get; set; } + public int RequisitionId { get; set; } public Requisition? Requisition { get; set; } public RfqStatus Status { get; set; } = RfqStatus.Open; diff --git a/Backend/ERPCore/Domain/Entities/RfqLine.cs b/Backend/ERPCore/Domain/Entities/RfqLine.cs index a91191b..9b73ca5 100644 --- a/Backend/ERPCore/Domain/Entities/RfqLine.cs +++ b/Backend/ERPCore/Domain/Entities/RfqLine.cs @@ -3,12 +3,12 @@ namespace ERPCore.Domain.Entities; /// RFQ line — an item + quantity being quoted (FR-PROC-02). Model: docs/10 Part C.2. public class RfqLine { - public long RfqLineId { get; set; } + public int RfqLineId { get; set; } - public long RfqId { get; set; } + public int RfqId { get; set; } public Rfq? Rfq { get; set; } - public long ItemId { get; set; } + public int ItemId { get; set; } public Item? Item { get; set; } public decimal Qty { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/Serial.cs b/Backend/ERPCore/Domain/Entities/Serial.cs new file mode 100644 index 0000000..a4d1876 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Serial.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04). +/// Model: docs/10 Part C.4. +/// +public class Serial +{ + public int SerialId { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public string SerialNo { get; set; } = string.Empty; + public string Status { get; set; } = "InStock"; +} diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustment.cs b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs new file mode 100644 index 0000000..be583d4 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Stock adjustment header (FR-STK-07) — the highest-risk feature in the phase +/// (02-SECURITY C.5). Auto-posts in Phase 1 with a mandatory reason code and user +/// stamp. Mutable aggregate with an token (docs/10 C.10). +/// Model: docs/10 Part C.6. +/// +public class StockAdjustment +{ + public int AdjustmentId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public int ReasonCodeId { get; set; } + public ReasonCode? ReasonCode { get; set; } + + public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted; + + public int CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs new file mode 100644 index 0000000..8bbc1ad --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs @@ -0,0 +1,23 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Adjustment line (FR-STK-07). is a signed base-UOM +/// quantity: negative consumes FIFO layers, positive creates a layer at last cost. +/// Model: docs/10 Part C.6. +/// +public class StockAdjustmentLine +{ + public int AdjLineId { get; set; } + + public int AdjustmentId { get; set; } + public StockAdjustment? Adjustment { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public int? BinId { get; set; } + public int? BatchId { get; set; } + public int? SerialId { get; set; } + + public decimal QtyDelta { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/StockCount.cs b/Backend/ERPCore/Domain/Entities/StockCount.cs new file mode 100644 index 0000000..2a2a118 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/StockCount.cs @@ -0,0 +1,29 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Cycle/full physical count header (FR-STK-08). System quantities are snapshotted +/// at creation and are immutable once opened (02-SECURITY C.7); posting emits a +/// variance adjustment. Mutable aggregate with an token. +/// Model: docs/10 Part C.6. +/// +public class StockCount +{ + public int CountId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public CountType CountType { get; set; } + public CountStatus Status { get; set; } = CountStatus.Draft; + + public int CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/StockCountLine.cs b/Backend/ERPCore/Domain/Entities/StockCountLine.cs new file mode 100644 index 0000000..debcfb7 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/StockCountLine.cs @@ -0,0 +1,22 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Count line (FR-STK-08). is the immutable snapshot; +/// = counted − system (in base UOM). Model: docs/10 Part C.6. +/// +public class StockCountLine +{ + public int CountLineId { get; set; } + + public int CountId { get; set; } + public StockCount? Count { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public int? BinId { get; set; } + + public decimal SystemQty { get; set; } + public decimal? CountedQty { get; set; } + public decimal? Variance { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/StockLayer.cs b/Backend/ERPCore/Domain/Entities/StockLayer.cs new file mode 100644 index 0000000..b4d1100 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/StockLayer.cs @@ -0,0 +1,33 @@ +namespace ERPCore.Domain.Entities; + +/// +/// FIFO cost layer — a quantity received at a specific unit cost, consumed +/// oldest-first (FR-STK-02). Keyed per item **per warehouse**; quantities and +/// are in the item's base UOM. Answers valuation +/// ("what's on hand and at what cost"). Model: docs/10 Part C.5. +/// +public class StockLayer +{ + public int LayerId { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public int? BatchId { get; set; } + public Batch? Batch { get; set; } + + public int? SerialId { get; set; } + public Serial? Serial { get; set; } + + /// Originating GRN line — carries the inspection hold status for this stock. + public int? GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public decimal QtyReceived { get; set; } + public decimal QtyRemaining { get; set; } + public decimal UnitCost { get; set; } + public DateTime ReceiptDate { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/StockLedger.cs b/Backend/ERPCore/Domain/Entities/StockLedger.cs new file mode 100644 index 0000000..88d3997 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/StockLedger.cs @@ -0,0 +1,32 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Immutable, append-only stock ledger (FR-STK-01, FR-X-05). One row per costed +/// movement; answers history ("what moved, when, by whom"). The originating +/// document is referenced polymorphically via +/// / (no hard FK per type) so +/// new transaction types write here without a schema change. Model: docs/10 Part C.5. +/// +public class StockLedger +{ + public int LedgerId { get; set; } + + public int ItemId { get; set; } + public int WarehouseId { get; set; } + public int? BinId { get; set; } + public int? BatchId { get; set; } + public int? SerialId { get; set; } + public int UserId { get; set; } + + public Direction Direction { get; set; } + public decimal QtyBase { get; set; } + public decimal UnitCost { get; set; } + public decimal Value { get; set; } + public decimal RunningBalance { get; set; } + + public string SourceDocType { get; set; } = string.Empty; + public int SourceDocId { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/StockTransfer.cs b/Backend/ERPCore/Domain/Entities/StockTransfer.cs new file mode 100644 index 0000000..0a17c89 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/StockTransfer.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Inter-warehouse stock transfer header (FR-STK-05/06). Dispatch consumes source +/// FIFO layers into in-transit; receive creates the destination layer at the +/// inherited cost (cost-preserving). Mutable aggregate with an +/// token (docs/10 C.10). Model: docs/10 Part C.6. +/// +public class StockTransfer +{ + public int TransferId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public int SrcWarehouseId { get; set; } + public Warehouse? SrcWarehouse { get; set; } + + public int DestWarehouseId { get; set; } + public Warehouse? DestWarehouse { get; set; } + + public TransferStatus Status { get; set; } = TransferStatus.Draft; + + public int CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/StockTransferLine.cs b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs new file mode 100644 index 0000000..240d815 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs @@ -0,0 +1,35 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Transfer line (FR-STK-05/06). is in base UOM. +/// +/// Deviation note: and extend +/// docs/10 Part C.6's STOCK_TRANSFER_LINE to make the transfer +/// cost-preserving: at dispatch the value-weighted cost of the consumed source +/// layers is stored here, and receive recreates the destination layer at that cost +/// (supports partial receive via ). +/// +/// +public class StockTransferLine +{ + public int TransferLineId { get; set; } + + public int TransferId { get; set; } + public StockTransfer? Transfer { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public int? SrcBinId { get; set; } + public int? DestBinId { get; set; } + public int? BatchId { get; set; } + public int? SerialId { get; set; } + + public decimal Qty { get; set; } + + /// Value-weighted unit cost of the consumed source layers (set at dispatch). + public decimal? UnitCost { get; set; } + + /// Quantity already received at the destination (partial-receive support). + public decimal QtyReceived { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Uom.cs b/Backend/ERPCore/Domain/Entities/Uom.cs index 2200e55..485b16a 100644 --- a/Backend/ERPCore/Domain/Entities/Uom.cs +++ b/Backend/ERPCore/Domain/Entities/Uom.cs @@ -6,6 +6,6 @@ namespace ERPCore.Domain.Entities; /// public class Uom { - public long UomId { get; set; } + public int UomId { get; set; } public string Name { get; set; } = string.Empty; } diff --git a/Backend/ERPCore/Domain/Entities/UomConversion.cs b/Backend/ERPCore/Domain/Entities/UomConversion.cs index fa76ef4..f44a99d 100644 --- a/Backend/ERPCore/Domain/Entities/UomConversion.cs +++ b/Backend/ERPCore/Domain/Entities/UomConversion.cs @@ -7,15 +7,15 @@ namespace ERPCore.Domain.Entities; /// public class UomConversion { - public long ConversionId { get; set; } + public int ConversionId { get; set; } - public long ItemId { get; set; } + public int ItemId { get; set; } public Item? Item { get; set; } - public long FromUomId { get; set; } + public int FromUomId { get; set; } public Uom? FromUom { get; set; } - public long ToUomId { get; set; } + public int ToUomId { get; set; } public Uom? ToUom { get; set; } public decimal Factor { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/User.cs b/Backend/ERPCore/Domain/Entities/User.cs index 24b58db..ddd0048 100644 --- a/Backend/ERPCore/Domain/Entities/User.cs +++ b/Backend/ERPCore/Domain/Entities/User.cs @@ -3,18 +3,23 @@ using ERPCore.Domain.Enums; namespace ERPCore.Domain.Entities; /// -/// Application user (FR-X-01). In Phase 1 authentication/RBAC are deferred; this -/// table exists so mutations can be stamped with an audit actor and documents can -/// carry a `createdBy`/`requestedBy` FK. A seeded system user (id 1) is the -/// fallback actor until `/auth/login` lands (§6). Model: docs/10 Part C.7. +/// Application user (FR-X-01) — a **local shadow/projection** of an AuthHex identity. +/// The local (int) is what every `createdBy`/`requestedBy`/ +/// audit/ledger FK references; maps it to the AuthHex +/// UserId (GUID) and is JIT-provisioned on first authenticated request +/// (docs/10 A.4/C.7). A seeded system user (id 1, null AuthUserId) is the +/// fallback actor for unauthenticated/system operations. Model: docs/10 Part C.7. /// public class User { - /// Seeded fallback actor used while auth is deferred. - public const long SystemUserId = 1; + /// Seeded fallback actor for unauthenticated/system operations. + public const int SystemUserId = 1; - public long UserId { get; set; } + public int UserId { get; set; } public string Username { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public EntityStatus Status { get; set; } = EntityStatus.Active; + + /// AuthHex identity (token UserId GUID); null for the seeded system user. + public Guid? AuthUserId { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/Vendor.cs b/Backend/ERPCore/Domain/Entities/Vendor.cs index 22d1e09..05a85d3 100644 --- a/Backend/ERPCore/Domain/Entities/Vendor.cs +++ b/Backend/ERPCore/Domain/Entities/Vendor.cs @@ -9,7 +9,7 @@ namespace ERPCore.Domain.Entities; /// public class Vendor { - public long VendorId { get; set; } + public int VendorId { get; set; } public string Code { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string? Terms { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/VendorQuotation.cs b/Backend/ERPCore/Domain/Entities/VendorQuotation.cs index 5eab1f4..f8ba021 100644 --- a/Backend/ERPCore/Domain/Entities/VendorQuotation.cs +++ b/Backend/ERPCore/Domain/Entities/VendorQuotation.cs @@ -12,12 +12,12 @@ namespace ERPCore.Domain.Entities; /// public class VendorQuotation { - public long QuotationId { get; set; } + public int QuotationId { get; set; } - public long RfqId { get; set; } + public int RfqId { get; set; } public Rfq? Rfq { get; set; } - public long VendorId { get; set; } + public int VendorId { get; set; } public Vendor? Vendor { get; set; } public DateTime CreatedAt { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs b/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs index 6880524..20e6a1f 100644 --- a/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs +++ b/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs @@ -3,12 +3,12 @@ namespace ERPCore.Domain.Entities; /// Per-item quoted price and lead time within a (docs/11 §3.2). public class VendorQuotationLine { - public long QuotationLineId { get; set; } + public int QuotationLineId { get; set; } - public long QuotationId { get; set; } + public int QuotationId { get; set; } public VendorQuotation? Quotation { get; set; } - public long ItemId { get; set; } + public int ItemId { get; set; } public Item? Item { get; set; } public decimal UnitPrice { get; set; } diff --git a/Backend/ERPCore/Domain/Entities/Warehouse.cs b/Backend/ERPCore/Domain/Entities/Warehouse.cs index fe7a402..bbcd8bf 100644 --- a/Backend/ERPCore/Domain/Entities/Warehouse.cs +++ b/Backend/ERPCore/Domain/Entities/Warehouse.cs @@ -6,7 +6,7 @@ namespace ERPCore.Domain.Entities; /// public class Warehouse { - public long WarehouseId { get; set; } + public int WarehouseId { get; set; } public string Code { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; diff --git a/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs b/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs new file mode 100644 index 0000000..7d56708 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs @@ -0,0 +1,13 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so +/// is reserved for the future threshold-approval +/// workflow (FR-STK-07). Stored as a string. +/// +public enum AdjustmentStatus +{ + Draft, + PendingApproval, + Posted +} diff --git a/Backend/ERPCore/Domain/Enums/AuditAction.cs b/Backend/ERPCore/Domain/Enums/AuditAction.cs new file mode 100644 index 0000000..f7644e8 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/AuditAction.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Kind of mutation recorded in the audit trail (FR-X-02). Stored as a string. +public enum AuditAction +{ + Create, + Update, + Delete +} diff --git a/Backend/ERPCore/Domain/Enums/CountStatus.cs b/Backend/ERPCore/Domain/Enums/CountStatus.cs new file mode 100644 index 0000000..8e3e958 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/CountStatus.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Stock-count lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string. +public enum CountStatus +{ + Draft, + Counted, + Posted +} diff --git a/Backend/ERPCore/Domain/Enums/CountType.cs b/Backend/ERPCore/Domain/Enums/CountType.cs new file mode 100644 index 0000000..29015d8 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/CountType.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Physical-count scope (docs/11 §8; FR-STK-08). Stored as a string. +public enum CountType +{ + Cycle, + Full +} diff --git a/Backend/ERPCore/Domain/Enums/Direction.cs b/Backend/ERPCore/Domain/Enums/Direction.cs new file mode 100644 index 0000000..afee991 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/Direction.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Stock-ledger movement direction (docs/11 §8). Stored as a string. +public enum Direction +{ + In, + Out +} diff --git a/Backend/ERPCore/Domain/Enums/GrnStatus.cs b/Backend/ERPCore/Domain/Enums/GrnStatus.cs new file mode 100644 index 0000000..550cca2 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/GrnStatus.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Goods-receipt-note lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string. +public enum GrnStatus +{ + Draft, + Confirmed, + Closed +} diff --git a/Backend/ERPCore/Domain/Enums/HoldStatus.cs b/Backend/ERPCore/Domain/Enums/HoldStatus.cs new file mode 100644 index 0000000..e38dd1d --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/HoldStatus.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Inspection-hold state of received stock (docs/11 §8; FR-GRN-05). +/// stock is on-hand but not issuable until released (FR-WH-07). Stored as a string. +/// +public enum HoldStatus +{ + Available, + OnHold, + Rejected +} diff --git a/Backend/ERPCore/Domain/Enums/ReasonContext.cs b/Backend/ERPCore/Domain/Enums/ReasonContext.cs new file mode 100644 index 0000000..b569040 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/ReasonContext.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Where a reason code applies (FR-X-04; docs/10 §B.8.3). Stored as a string. +public enum ReasonContext +{ + Adjustment, + Return, + Count +} diff --git a/Backend/ERPCore/Domain/Enums/ReturnStatus.cs b/Backend/ERPCore/Domain/Enums/ReturnStatus.cs new file mode 100644 index 0000000..6214b0b --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/ReturnStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Purchase-return lifecycle (docs/11 §3.4). Auto-posts in Phase 1. Stored as a string. +public enum ReturnStatus +{ + Draft, + Posted +} diff --git a/Backend/ERPCore/Domain/Enums/TransferStatus.cs b/Backend/ERPCore/Domain/Enums/TransferStatus.cs new file mode 100644 index 0000000..0f96724 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/TransferStatus.cs @@ -0,0 +1,10 @@ +namespace ERPCore.Domain.Enums; + +/// Stock-transfer lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string. +public enum TransferStatus +{ + Draft, + InTransit, + Received, + Closed +} diff --git a/Backend/ERPCore/Dtos/Audit/AuditDtos.cs b/Backend/ERPCore/Dtos/Audit/AuditDtos.cs new file mode 100644 index 0000000..1691243 --- /dev/null +++ b/Backend/ERPCore/Dtos/Audit/AuditDtos.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Audit; + +/// An audit-trail entry (FR-X-02). ChangeSet is the stored JSON, inlined. +public sealed record AuditLogDto( + int AuditId, int UserId, string EntityType, int EntityId, AuditAction Action, JsonElement ChangeSet, DateTime CreatedAt); + +/// A GL-ready journal stub emitted per stock movement (FR-STK-13). +public sealed record JournalEntryStubDto( + int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount); diff --git a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs index 856e23b..744026e 100644 --- a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs +++ b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs @@ -3,13 +3,13 @@ using System.ComponentModel.DataAnnotations; namespace ERPCore.Dtos.Categories; /// Flat category resource (docs/11-BACKEND-PHASE1.md §2.3). -public sealed record CategoryDto(long CategoryId, string Name, long? ParentId); +public sealed record CategoryDto(int CategoryId, string Name, int? ParentId); /// Nested category node for GET /categories?tree=true. -public sealed record CategoryTreeDto(long CategoryId, string Name, long? ParentId, IReadOnlyList Children); +public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList Children); public sealed class CreateCategoryRequest { [Required, StringLength(200)] public string Name { get; set; } = string.Empty; - public long? ParentId { get; set; } + public int? ParentId { get; set; } } diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs new file mode 100644 index 0000000..bdcff2b --- /dev/null +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -0,0 +1,63 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Grn; + +// Responses (docs/11 §4) -------------------------------------------------------- + +public sealed record GrnLineDto( + int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId, + decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId); + +public sealed record GrnDto( + int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status, + int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList Lines); + +public sealed record CreatedLayerDto( + int LayerId, int ItemId, int WarehouseId, int? BatchId, + decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate); + +public sealed record GrnConfirmResultDto( + int GrnId, GrnStatus Status, DateTime PostedAt, + IReadOnlyList CreatedLayers, IReadOnlyList LedgerRefs, PurchaseOrderStatus? PoStatus); + +public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus); + +// Requests ---------------------------------------------------------------------- + +public sealed class BatchInput +{ + [Required, StringLength(50)] public string BatchNo { get; set; } = string.Empty; + public DateOnly? ExpiryDate { get; set; } +} + +public sealed class CreateGrnLineInput +{ + /// Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3). + public int? PoLineId { get; set; } + [Required] public int ItemId { get; set; } + [Required] public int UomId { get; set; } + public int? BinId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// Used only for direct (no-PO) receipts; ignored when is set. + [Range(0, double.MaxValue)] public decimal UnitCost { get; set; } + [EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; + public BatchInput? Batch { get; set; } +} + +public sealed class CreateGrnRequest +{ + /// PO to receive against; null for a direct/emergency receipt (FR-GRN-02). + public int? PoId { get; set; } + /// Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO). + public int? VendorId { get; set; } + [Required] public int WarehouseId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class ReleaseLineRequest +{ + /// Release makes the stock available; Reject removes it from on-hand. + [Required, RegularExpression("Release|Reject")] + public string Action { get; set; } = "Release"; +} diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs index c372cad..7bb11e7 100644 --- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs +++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs @@ -7,25 +7,25 @@ namespace ERPCore.Dtos.Items; /// Row shape for GET /items. public sealed record ItemListItemDto( - long ItemId, string Sku, string Name, long CategoryId, long BaseUomId, - long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, + int ItemId, string Sku, string Name, int CategoryId, int BaseUomId, + int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, string? TaxClass, EntityStatus Status); /// A single per-warehouse reorder policy row. -public sealed record ItemReorderDto(long WarehouseId, decimal ReorderPoint, decimal ReorderQty); +public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty); /// Full item resource for GET /items/{id} and create/update responses. public sealed record ItemDetailDto( - long ItemId, string Sku, string Name, string? Description, long CategoryId, - long BaseUomId, long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, + int ItemId, string Sku, string Name, string? Description, int CategoryId, + int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode, string? TaxClass, EntityStatus Status, IReadOnlyList Reorder, DateTime CreatedAt, DateTime? UpdatedAt); /// UOM conversion row (docs/11 §2.2). -public sealed record UomConversionDto(long ConversionId, long FromUom, long ToUom, decimal Factor); +public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor); /// Response body for PUT /items/{id}/uom-conversions. -public sealed record ItemUomConversionsDto(long ItemId, long BaseUomId, IReadOnlyList Conversions); +public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList Conversions); /// Response body for PUT /items/{id}/reorder. public sealed record ItemReorderSettingsDto(IReadOnlyList Settings); @@ -38,9 +38,9 @@ public sealed class CreateItemRequest [Required, StringLength(50)] public string Sku { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(1000)] public string? Description { get; set; } - [Required] public long CategoryId { get; set; } - [Required] public long BaseUomId { get; set; } - public long? DefaultVendorId { get; set; } + [Required] public int CategoryId { get; set; } + [Required] public int BaseUomId { get; set; } + public int? DefaultVendorId { get; set; } [Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [StringLength(20)] public string? TaxClass { get; set; } @@ -51,9 +51,9 @@ public sealed class UpdateItemRequest [Required, StringLength(50)] public string Sku { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(1000)] public string? Description { get; set; } - [Required] public long CategoryId { get; set; } - [Required] public long BaseUomId { get; set; } - public long? DefaultVendorId { get; set; } + [Required] public int CategoryId { get; set; } + [Required] public int BaseUomId { get; set; } + public int? DefaultVendorId { get; set; } [Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; } [EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None; [StringLength(20)] public string? TaxClass { get; set; } @@ -66,7 +66,7 @@ public sealed class UpdateItemStatusRequest public sealed class ReorderSettingInput { - [Required] public long WarehouseId { get; set; } + [Required] public int WarehouseId { get; set; } [Range(0, double.MaxValue)] public decimal ReorderPoint { get; set; } [Range(0, double.MaxValue)] public decimal ReorderQty { get; set; } } @@ -78,8 +78,8 @@ public sealed class UpdateReorderRequest public sealed class UomConversionInput { - [Required] public long FromUom { get; set; } - [Required] public long ToUom { get; set; } + [Required] public int FromUom { get; set; } + [Required] public int ToUom { get; set; } [Range(0.000001, double.MaxValue)] public decimal Factor { get; set; } } diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs index 2c372c8..c70e9a7 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs @@ -6,27 +6,27 @@ namespace ERPCore.Dtos.Procurement; // Responses (docs/11 §3.3) ------------------------------------------------------ public sealed record PoLineDto( - long PoLineId, long ItemId, long UomId, long WarehouseId, + int PoLineId, int ItemId, int UomId, int WarehouseId, decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived); public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency); public sealed record PurchaseOrderDto( - long PoId, string DocNo, long VendorId, long? RequisitionId, PurchaseOrderStatus Status, - bool ApprovalRequired, long CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt, + int PoId, string DocNo, int VendorId, int? RequisitionId, PurchaseOrderStatus Status, + bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt, PoTotalsDto Totals, IReadOnlyList Lines); public sealed record PurchaseOrderSummaryDto( - long PoId, string DocNo, long VendorId, PurchaseOrderStatus Status, + int PoId, string DocNo, int VendorId, PurchaseOrderStatus Status, bool ApprovalRequired, DateTime CreatedAt, PoTotalsDto Totals); // Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals public sealed class CreatePoLineInput { - [Required] public long ItemId { get; set; } - [Required] public long UomId { get; set; } - [Required] public long WarehouseId { get; set; } + [Required] public int ItemId { get; set; } + [Required] public int UomId { get; set; } + [Required] public int WarehouseId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0, double.MaxValue)] public decimal UnitPrice { get; set; } [Range(0, 1)] public decimal Tax { get; set; } @@ -34,15 +34,15 @@ public sealed class CreatePoLineInput public sealed class CreatePurchaseOrderRequest { - [Required] public long VendorId { get; set; } - public long? RequisitionId { get; set; } + [Required] public int VendorId { get; set; } + public int? RequisitionId { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); } public sealed class UpdatePurchaseOrderRequest { - [Required] public long VendorId { get; set; } - public long? RequisitionId { get; set; } + [Required] public int VendorId { get; set; } + public int? RequisitionId { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); } diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs new file mode 100644 index 0000000..f9deb75 --- /dev/null +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Procurement; + +// Responses (docs/11 §3.4) ------------------------------------------------------ + +public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int ItemId, decimal Qty); + +public sealed record PurchaseReturnDto( + int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, + int CreatedBy, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreatePurchaseReturnLineInput +{ + /// Original GRN line, for traceability against the receipt. + public int? GrnLineId { get; set; } + [Required] public int ItemId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } +} + +public sealed class CreatePurchaseReturnRequest +{ + [Required] public int VendorId { get; set; } + [Required] public int WarehouseId { get; set; } + /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error. + public int? ReasonCodeId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs index d9af44f..63b5e0a 100644 --- a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs @@ -5,20 +5,20 @@ namespace ERPCore.Dtos.Procurement; // Responses (docs/11 §3.1) ------------------------------------------------------ -public sealed record RequisitionLineDto(long ReqLineId, long ItemId, decimal Qty, DateOnly? RequiredBy); +public sealed record RequisitionLineDto(int ReqLineId, int ItemId, decimal Qty, DateOnly? RequiredBy); public sealed record RequisitionDto( - long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy, + int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, IReadOnlyList Lines); public sealed record RequisitionSummaryDto( - long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy, DateTime CreatedAt); + int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt); // Requests — server sets docNo, status, requestedBy (audit actor), timestamps ---- public sealed class CreateRequisitionLineInput { - [Required] public long ItemId { get; set; } + [Required] public int ItemId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } public DateOnly? RequiredBy { get; set; } } diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs index 8109941..d54ab90 100644 --- a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs @@ -5,46 +5,46 @@ namespace ERPCore.Dtos.Procurement; // Responses (docs/11 §3.2) ------------------------------------------------------ -public sealed record RfqLineDto(long RfqLineId, long ItemId, decimal Qty); +public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty); public sealed record RfqDto( - long RfqId, string DocNo, long RequisitionId, RfqStatus Status, IReadOnlyList Lines); + int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList Lines); -public sealed record QuotationLineDto(long ItemId, decimal UnitPrice, int LeadDays); +public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays); public sealed record VendorQuotationDto( - long QuotationId, long RfqId, long VendorId, IReadOnlyList Lines); + int QuotationId, int RfqId, int VendorId, IReadOnlyList Lines); /// Per-item, per-vendor price matrix for GET /rfqs/{id}/comparison. -public sealed record RfqComparisonCellDto(long VendorId, long QuotationId, decimal UnitPrice, int LeadDays); -public sealed record RfqComparisonRowDto(long ItemId, decimal Qty, IReadOnlyList Quotes); -public sealed record RfqComparisonDto(long RfqId, IReadOnlyList VendorIds, IReadOnlyList Rows); +public sealed record RfqComparisonCellDto(int VendorId, int QuotationId, decimal UnitPrice, int LeadDays); +public sealed record RfqComparisonRowDto(int ItemId, decimal Qty, IReadOnlyList Quotes); +public sealed record RfqComparisonDto(int RfqId, IReadOnlyList VendorIds, IReadOnlyList Rows); // Requests ---------------------------------------------------------------------- public sealed class CreateRfqLineInput { - [Required] public long ItemId { get; set; } + [Required] public int ItemId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } } public sealed class CreateRfqRequest { - [Required] public long RequisitionId { get; set; } + [Required] public int RequisitionId { get; set; } /// Vendors the RFQ is issued to (validated for existence; quotations reference them). - public List VendorIds { get; set; } = new(); + public List VendorIds { get; set; } = new(); [Required, MinLength(1)] public List Lines { get; set; } = new(); } public sealed class CreateQuotationLineInput { - [Required] public long ItemId { get; set; } + [Required] public int ItemId { get; set; } [Range(0, double.MaxValue)] public decimal UnitPrice { get; set; } [Range(0, int.MaxValue)] public int LeadDays { get; set; } } public sealed class CreateQuotationRequest { - [Required] public long VendorId { get; set; } + [Required] public int VendorId { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); } diff --git a/Backend/ERPCore/Dtos/Reference/ReasonCodeDtos.cs b/Backend/ERPCore/Dtos/Reference/ReasonCodeDtos.cs new file mode 100644 index 0000000..8287289 --- /dev/null +++ b/Backend/ERPCore/Dtos/Reference/ReasonCodeDtos.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Reference; + +/// Reason code (docs/11 §6). +public sealed record ReasonCodeDto(int ReasonCodeId, string Code, string Description, ReasonContext Context); + +public sealed class CreateReasonCodeRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Description { get; set; } = string.Empty; + [Required, EnumDataType(typeof(ReasonContext))] public ReasonContext Context { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs new file mode 100644 index 0000000..a8e9696 --- /dev/null +++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Stock; + +// Responses (docs/11 §5.5) ------------------------------------------------------ + +public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta); + +public sealed record AdjustmentDto( + int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status, + int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreateAdjustmentLineInput +{ + [Required] public int ItemId { get; set; } + public int? BinId { get; set; } + public int? BatchId { get; set; } + /// Signed base-UOM delta: negative consumes FIFO layers, positive adds stock. + public decimal QtyDelta { get; set; } +} + +public sealed class CreateAdjustmentRequest +{ + [Required] public int WarehouseId { get; set; } + /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error, not 0. + public int? ReasonCodeId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Stock/CountDtos.cs b/Backend/ERPCore/Dtos/Stock/CountDtos.cs new file mode 100644 index 0000000..44a67ec --- /dev/null +++ b/Backend/ERPCore/Dtos/Stock/CountDtos.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Stock; + +// Responses (docs/11 §5.6) ------------------------------------------------------ + +public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance); + +public sealed record CountDto( + int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList Lines); + +public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList LedgerRefs); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreateCountRequest +{ + [Required] public int WarehouseId { get; set; } + [Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; } + [Required, MinLength(1)] public List ItemIds { get; set; } = new(); +} + +public sealed class EnterCountLineInput +{ + [Required] public int CountLineId { get; set; } + [Range(0, double.MaxValue)] public decimal CountedQty { get; set; } +} + +public sealed class EnterCountsRequest +{ + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Stock/ReorderDtos.cs b/Backend/ERPCore/Dtos/Stock/ReorderDtos.cs new file mode 100644 index 0000000..d325850 --- /dev/null +++ b/Backend/ERPCore/Dtos/Stock/ReorderDtos.cs @@ -0,0 +1,6 @@ +namespace ERPCore.Dtos.Stock; + +/// An item at/below its reorder point (docs/11 §5.7; FR-STK-10). Computed on read. +public sealed record ReorderAlertDto( + int ItemId, int WarehouseId, decimal Available, + decimal ReorderPoint, decimal ReorderQty, decimal SuggestedRequisitionQty); diff --git a/Backend/ERPCore/Dtos/Stock/StockDtos.cs b/Backend/ERPCore/Dtos/Stock/StockDtos.cs new file mode 100644 index 0000000..9a1b3b2 --- /dev/null +++ b/Backend/ERPCore/Dtos/Stock/StockDtos.cs @@ -0,0 +1,22 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Stock; + +/// Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out). +public sealed record StockOnHandDto( + int ItemId, int WarehouseId, decimal OnHand, decimal Available, + decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf); + +/// A stock-ledger row (docs/11 §5.2). +public sealed record StockLedgerRowDto( + int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId, + Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance, + string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt); + +/// An open FIFO layer in a valuation (docs/11 §5.3). +public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate); + +/// Valuation of on-hand stock from open FIFO layers (docs/11 §5.3). +public sealed record StockValuationDto( + int ItemId, int WarehouseId, IReadOnlyList Layers, + decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod); diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs new file mode 100644 index 0000000..dd91237 --- /dev/null +++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Stock; + +// Responses (docs/11 §5.4) ------------------------------------------------------ + +public sealed record TransferLineDto( + int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived); + +public sealed record TransferDto( + int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, + TransferStatus Status, IReadOnlyList Lines); + +public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost); + +public sealed record DispatchResultDto( + int TransferId, TransferStatus Status, IReadOnlyList ConsumedLayers, IReadOnlyList LedgerRefs); + +public sealed record TransferCreatedLayerDto(int LayerId, int WarehouseId, decimal QtyReceived, decimal UnitCost); + +public sealed record ReceiveResultDto( + int TransferId, TransferStatus Status, IReadOnlyList CreatedLayers, IReadOnlyList LedgerRefs); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreateTransferLineInput +{ + [Required] public int ItemId { get; set; } + public int? SrcBinId { get; set; } + public int? DestBinId { get; set; } + public int? BatchId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } +} + +public sealed class CreateTransferRequest +{ + [Required] public int SrcWarehouseId { get; set; } + [Required] public int DestWarehouseId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class ReceiveTransferLineInput +{ + [Required] public int TransferLineId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } +} + +public sealed class ReceiveTransferRequest +{ + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Uoms/UomDtos.cs b/Backend/ERPCore/Dtos/Uoms/UomDtos.cs index 90f5259..655fd06 100644 --- a/Backend/ERPCore/Dtos/Uoms/UomDtos.cs +++ b/Backend/ERPCore/Dtos/Uoms/UomDtos.cs @@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations; namespace ERPCore.Dtos.Uoms; /// UOM resource (docs/11-BACKEND-PHASE1.md §2.2). -public sealed record UomDto(long UomId, string Name); +public sealed record UomDto(int UomId, string Name); public sealed class CreateUomRequest { diff --git a/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs b/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs index 731099c..36c0145 100644 --- a/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs +++ b/Backend/ERPCore/Dtos/Vendors/VendorDtos.cs @@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Vendors; /// Vendor resource (docs/11-BACKEND-PHASE1.md §2.4). public sealed record VendorDto( - long VendorId, string Code, string Name, string? Terms, string? TaxReg, + int VendorId, string Code, string Name, string? Terms, string? TaxReg, string Currency, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt); public sealed class CreateVendorRequest diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs index ad91678..37dea5c 100644 --- a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs +++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs @@ -3,10 +3,10 @@ using System.ComponentModel.DataAnnotations; namespace ERPCore.Dtos.Warehouses; /// Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5). -public sealed record WarehouseDto(long WarehouseId, string Code, string Name); +public sealed record WarehouseDto(int WarehouseId, string Code, string Name); /// Bin/location resource (docs/11 §2.5). -public sealed record BinDto(long BinId, long WarehouseId, string Code, string? BinType); +public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType); public sealed class CreateWarehouseRequest { diff --git a/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs b/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs new file mode 100644 index 0000000..e35400a --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/AuthHexClaims.cs @@ -0,0 +1,17 @@ +namespace ERPCore.Infra.Auth; + +/// +/// Claim type names emitted by the AuthHex IdP (see its JwtTokenHelper). +/// AuthHex uses no standard sub/nameid; identity is the custom +/// (GUID). These are read verbatim (JWT bearer is configured +/// with MapInboundClaims = false). +/// +public static class AuthHexClaims +{ + public const string UserId = "UserId"; + public const string UserTypeId = "UserTypeId"; + public const string UserTypeCode = "UserTypeCode"; + public const string RoleId = "RoleId"; + public const string RoleCode = "RoleCode"; + public const string Nic = "NIC"; +} diff --git a/Backend/ERPCore/Infra/Auth/CurrentUser.cs b/Backend/ERPCore/Infra/Auth/CurrentUser.cs index 06ff7ba..9e0a0b1 100644 --- a/Backend/ERPCore/Infra/Auth/CurrentUser.cs +++ b/Backend/ERPCore/Infra/Auth/CurrentUser.cs @@ -30,5 +30,5 @@ public sealed class CurrentUser : ICurrentUser } } - public long AuditUserId => long.TryParse(UserId, out var id) ? id : User.SystemUserId; + public int AuditUserId => int.TryParse(UserId, out var id) ? id : User.SystemUserId; } diff --git a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs index be7317f..1c103a2 100644 --- a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs +++ b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs @@ -15,7 +15,7 @@ public interface ICurrentUser /// Resolves the token sub to a user id; falls back to the seeded system /// user () while auth is deferred (§6). /// - long AuditUserId { get; } + int AuditUserId { get; } /// True when the request carries an authenticated principal. bool IsAuthenticated { get; } diff --git a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs index 10ca70f..ea879ca 100644 --- a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs +++ b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs @@ -1,25 +1,42 @@ -using System.Text; +using System.Security.Cryptography; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; namespace ERPCore.Infra.Auth; /// -/// JWT bearer wiring. Authentication only — RBAC/authorization policies are -/// deferred for Phase 1; the validated principal exists solely so that -/// can stamp the audit actor. +/// Auth wiring for ERPCore as a **resource server** for the external AuthHex IdP +/// (docs/10 A.4). Validates AuthHex's **RS256** tokens against AuthHex's RSA public +/// key (configured statically — no JWKS), issuer AuthHex, audience +/// AuthHexClient. A single door policy () admits +/// only ERP UserType/Role holders when those codes are configured; +/// per-endpoint RBAC stays deferred. Identity → audit actor is resolved by +/// + . /// public static class JwtAuthExtensions { + /// Authorization policy applied to every v1 controller (via ApiControllerBase). + public const string ErpAccessPolicy = "ErpAccess"; + public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config) { - var issuer = config["Jwt:Issuer"]; - var audience = config["Jwt:Audience"]; - var signingKey = config["Jwt:SigningKey"] ?? string.Empty; + var issuer = config["Auth:Issuer"]; + var audience = config["Auth:Audience"]; + var publicKeyXml = config["Auth:RsaPublicKeyXml"] + ?? throw new InvalidOperationException("Auth:RsaPublicKeyXml (AuthHex RSA public key) is not configured."); + var requiredUserType = config["Auth:RequiredUserTypeCode"]; + var requiredRole = config["Auth:RequiredRoleCode"]; + + // AuthHex publishes no JWKS; the RSA public key is configured statically. + var rsa = RSA.Create(); + rsa.FromXmlString(publicKeyXml); + var signingKey = new RsaSecurityKey(rsa); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { + // Keep AuthHex's claim names verbatim (UserId, UserTypeCode, RoleCode …). + options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -28,12 +45,26 @@ public static class JwtAuthExtensions ValidAudience = audience, ValidateLifetime = true, ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey)), + IssuerSigningKey = signingKey, + ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 }, ClockSkew = TimeSpan.FromSeconds(30) }; }); - services.AddAuthorization(); + services.AddAuthorization(options => + { + options.AddPolicy(ErpAccessPolicy, policy => + { + policy.RequireAuthenticatedUser(); + // Door gate: only enforce a UserType/Role when configured (AuthHex is a + // shared IdP). Empty config = require a valid ERP token only. + if (!string.IsNullOrWhiteSpace(requiredUserType)) + policy.RequireClaim(AuthHexClaims.UserTypeCode, requiredUserType); + if (!string.IsNullOrWhiteSpace(requiredRole)) + policy.RequireClaim(AuthHexClaims.RoleCode, requiredRole); + }); + }); + return services; } } diff --git a/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs b/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs new file mode 100644 index 0000000..e163895 --- /dev/null +++ b/Backend/ERPCore/Infra/Auth/ShadowUserClaimsTransformation.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Persistence; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Infra.Auth; + +/// +/// Maps an authenticated AuthHex principal to ERPCore's local identity (docs/10 A.4/A.5). +/// AuthHex tokens carry the user as a custom UserId (GUID) claim and no +/// sub/nameid. This transformation JIT-provisions a local shadow +/// (keyed by auth_user_id) and injects the local +/// int id as , so +/// /AuditUserId resolve the real user unchanged. +/// Idempotent — may run several times per request. +/// +public sealed class ShadowUserClaimsTransformation : IClaimsTransformation +{ + private readonly ErpDbContext _db; + + public ShadowUserClaimsTransformation(ErpDbContext db) => _db = db; + + public async Task TransformAsync(ClaimsPrincipal principal) + { + if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated) + return principal; + if (identity.HasClaim(c => c.Type == ClaimTypes.NameIdentifier)) + return principal; // already resolved this request + + var raw = principal.FindFirstValue(AuthHexClaims.UserId); + if (!Guid.TryParse(raw, out var authUserId)) + return principal; // no mappable identity → CurrentUser falls back to system + + var nic = principal.FindFirstValue(AuthHexClaims.Nic); + var localId = await ResolveOrProvisionAsync(authUserId, nic); + + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, localId.ToString())); + return principal; + } + + private async Task ResolveOrProvisionAsync(Guid authUserId, string? nic) + { + var existing = await _db.Users.AsNoTracking() + .Where(u => u.AuthUserId == authUserId) + .Select(u => u.UserId) + .FirstOrDefaultAsync(); + if (existing != 0) return existing; + + var label = string.IsNullOrWhiteSpace(nic) ? authUserId.ToString() : nic.Trim(); + var user = new User + { + AuthUserId = authUserId, + Username = label, + DisplayName = string.IsNullOrWhiteSpace(nic) ? "AuthHex User" : nic.Trim(), + Status = EntityStatus.Active + }; + + try + { + _db.Users.Add(user); + await _db.SaveChangesAsync(); + return user.UserId; + } + catch (DbUpdateException) + { + // Lost a race (unique auth_user_id) — the row now exists; re-read it. + _db.Entry(user).State = EntityState.Detached; + return await _db.Users.AsNoTracking() + .Where(u => u.AuthUserId == authUserId) + .Select(u => u.UserId) + .FirstAsync(); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs b/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs new file mode 100644 index 0000000..8f231b0 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace ERPCore.Infra.Persistence.Auditing; + +/// A mutation captured before save, awaiting its (possibly generated) key. +public sealed record PendingAudit(EntityEntry Entry, string EntityType, AuditAction Action, string ChangeSet, int CapturedId, bool IsAdded); + +/// +/// Builds audit-trail rows from the EF change tracker (FR-X-02). High-volume / +/// derived / self-referential tables are excluded (the stock ledger is itself the +/// stock movement audit). Change sets are captured before save so old→new is +/// accurate; generated keys for inserts are read after save. +/// +public static class AuditScribe +{ + private static readonly HashSet Excluded = + [ + typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence), + typeof(StockLedger), typeof(StockLayer), + ]; + + private static readonly JsonSerializerOptions Json = new() + { + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static List Capture(ChangeTracker tracker) + { + var pending = new List(); + foreach (var entry in tracker.Entries()) + { + if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) continue; + if (Excluded.Contains(entry.Entity.GetType())) continue; + + var action = entry.State switch + { + EntityState.Added => AuditAction.Create, + EntityState.Deleted => AuditAction.Delete, + _ => AuditAction.Update, + }; + + var changeSet = BuildChangeSet(entry, action); + if (action == AuditAction.Update && changeSet == "{}") continue; // only concurrency token touched, etc. + + var isAdded = entry.State == EntityState.Added; + pending.Add(new PendingAudit(entry, entry.Entity.GetType().Name, action, changeSet, isAdded ? 0 : ReadKey(entry), isAdded)); + } + return pending; + } + + public static AuditLog ToLog(PendingAudit p, int userId, DateTime now) => new() + { + UserId = userId, + EntityType = p.EntityType, + EntityId = p.IsAdded ? ReadKey(p.Entry) : p.CapturedId, + Action = p.Action, + ChangeSet = p.ChangeSet, + CreatedAt = now, + }; + + private static int ReadKey(EntityEntry entry) + { + var pk = entry.Metadata.FindPrimaryKey(); + if (pk is null || pk.Properties.Count != 1) return 0; + var value = entry.Property(pk.Properties[0].Name).CurrentValue; + return value is null ? 0 : Convert.ToInt32(value); + } + + private static string BuildChangeSet(EntityEntry entry, AuditAction action) + { + var set = new Dictionary(); + foreach (var p in entry.Properties) + { + if (p.Metadata.IsPrimaryKey()) continue; + if (p.Metadata.Name == nameof(Item.RowVersion)) continue; + + switch (action) + { + case AuditAction.Create when p.CurrentValue is not null: + set[p.Metadata.Name] = p.CurrentValue; + break; + case AuditAction.Delete: + set[p.Metadata.Name] = p.OriginalValue; + break; + case AuditAction.Update when p.IsModified && !Equals(p.OriginalValue, p.CurrentValue): + set[p.Metadata.Name] = new Dictionary { ["old"] = p.OriginalValue, ["new"] = p.CurrentValue }; + break; + } + } + return JsonSerializer.Serialize(set, Json); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs new file mode 100644 index 0000000..a93681c --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs @@ -0,0 +1,41 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class AuditLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("audit_logs"); + builder.HasKey(a => a.AuditId); + + builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80); + builder.Property(a => a.Action).HasConversion().HasMaxLength(10).IsRequired(); + builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb"); + builder.Property(a => a.CreatedAt).IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(a => new { a.EntityType, a.EntityId }); + builder.HasIndex(a => a.CreatedAt); + builder.HasIndex(a => a.UserId); + } +} + +public sealed class JournalEntryStubConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("journal_entry_stubs"); + builder.HasKey(j => j.JournalId); + + builder.Property(j => j.SourceDocType).IsRequired().HasMaxLength(10); + builder.Property(j => j.DebitAccount).IsRequired().HasMaxLength(20); + builder.Property(j => j.CreditAccount).IsRequired().HasMaxLength(20); + builder.Property(j => j.Amount).HasPrecision(18, 4); + + builder.HasIndex(j => new { j.SourceDocType, j.SourceDocId }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs new file mode 100644 index 0000000..8de6adf --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs @@ -0,0 +1,43 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class BatchConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("batches"); + builder.HasKey(b => b.BatchId); + + builder.Property(b => b.BatchNo).IsRequired().HasMaxLength(50); + + builder.HasOne(b => b.Item) + .WithMany() + .HasForeignKey(b => b.ItemId) + .OnDelete(DeleteBehavior.Restrict); + + // Batch number unique within an item. + builder.HasIndex(b => new { b.ItemId, b.BatchNo }).IsUnique(); + } +} + +public sealed class SerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("serials"); + builder.HasKey(s => s.SerialId); + + builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100); + builder.Property(s => s.Status).IsRequired().HasMaxLength(20); + + builder.HasOne(s => s.Item) + .WithMany() + .HasForeignKey(s => s.ItemId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs new file mode 100644 index 0000000..087ea19 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -0,0 +1,50 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class GrnConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("grns"); + builder.HasKey(g => g.GrnId); + + builder.Property(g => g.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(g => g.DocNo).IsUnique(); + + builder.Property(g => g.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(g => g.CreatedAt).IsRequired(); + builder.Property(g => g.RowVersion).IsRowVersion(); + + builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(g => g.Status); + builder.HasIndex(g => g.PoId); + } +} + +public sealed class GrnLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("grn_lines"); + builder.HasKey(l => l.GrnLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.ReceivedValue).HasPrecision(18, 4); + builder.Property(l => l.HoldStatus).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs new file mode 100644 index 0000000..00add0a --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs @@ -0,0 +1,40 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("purchase_returns"); + builder.HasKey(r => r.ReturnId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + + builder.Property(r => r.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(r => r.CreatedAt).IsRequired(); + + builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class PurchaseReturnLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("purchase_return_lines"); + builder.HasKey(l => l.ReturnLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + + builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs new file mode 100644 index 0000000..4746889 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs @@ -0,0 +1,20 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class ReasonCodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("reason_codes"); + builder.HasKey(r => r.ReasonCodeId); + + builder.Property(r => r.Code).IsRequired().HasMaxLength(20); + builder.Property(r => r.Description).IsRequired().HasMaxLength(200); + builder.Property(r => r.Context).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasIndex(r => new { r.Context, r.Code }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs new file mode 100644 index 0000000..d50a0e6 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs @@ -0,0 +1,42 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_adjustments"); + builder.HasKey(a => a.AdjustmentId); + + builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(a => a.DocNo).IsUnique(); + + builder.Property(a => a.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(a => a.CreatedAt).IsRequired(); + builder.Property(a => a.RowVersion).IsRowVersion(); + + builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_adjustment_lines"); + builder.HasKey(l => l.AdjLineId); + + builder.Property(l => l.QtyDelta).HasPrecision(18, 4); + + builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs new file mode 100644 index 0000000..57d51a6 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs @@ -0,0 +1,60 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class StockLayerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_layers"); + builder.HasKey(l => l.LayerId); + + builder.Property(l => l.QtyReceived).HasPrecision(18, 4); + builder.Property(l => l.QtyRemaining).HasPrecision(18, 4); + builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.ReceiptDate).IsRequired(); + + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Warehouse).WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict); + + // FIFO consumption orders by receipt date then layer id, scoped per item+warehouse. + builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.ReceiptDate, l.LayerId }); + builder.HasIndex(l => l.GrnLineId); + } +} + +public sealed class StockLedgerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Append-only (FR-STK-01/FR-X-05): the app never updates/deletes ledger rows. + // DB-level revocation of UPDATE/DELETE is a deferred hardening step (02-SECURITY B.3). + builder.ToTable("stock_ledger"); + builder.HasKey(l => l.LedgerId); + + builder.Property(l => l.Direction).HasConversion().HasMaxLength(5).IsRequired(); + builder.Property(l => l.QtyBase).HasPrecision(18, 4); + builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.Value).HasPrecision(18, 4); + builder.Property(l => l.RunningBalance).HasPrecision(18, 4); + builder.Property(l => l.SourceDocType).IsRequired().HasMaxLength(10); + builder.Property(l => l.CreatedAt).IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + + // Time-series query paths (NFR-06) and polymorphic source tracing. + builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.LedgerId }); + builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.CreatedAt }); + builder.HasIndex(l => new { l.SourceDocType, l.SourceDocId }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs new file mode 100644 index 0000000..fa44f42 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs @@ -0,0 +1,44 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class StockCountConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_counts"); + builder.HasKey(c => c.CountId); + + builder.Property(c => c.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(c => c.DocNo).IsUnique(); + + builder.Property(c => c.CountType).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(c => c.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(c => c.CreatedAt).IsRequired(); + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(c => c.Status); + } +} + +public sealed class StockCountLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_count_lines"); + builder.HasKey(l => l.CountLineId); + + builder.Property(l => l.SystemQty).HasPrecision(18, 4); + builder.Property(l => l.CountedQty).HasPrecision(18, 4); + builder.Property(l => l.Variance).HasPrecision(18, 4); + + builder.HasOne(l => l.Count).WithMany(c => c.Lines).HasForeignKey(l => l.CountId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs new file mode 100644 index 0000000..2aae8bb --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs @@ -0,0 +1,47 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class StockTransferConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_transfers"); + builder.HasKey(t => t.TransferId); + + builder.Property(t => t.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(t => t.DocNo).IsUnique(); + + builder.Property(t => t.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(t => t.CreatedAt).IsRequired(); + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(t => t.Status); + } +} + +public sealed class StockTransferLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_transfer_lines"); + builder.HasKey(l => l.TransferLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.QtyReceived).HasPrecision(18, 4); + + builder.HasOne(l => l.Transfer).WithMany(t => t.Lines).HasForeignKey(l => l.TransferId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.SrcBinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.DestBinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs index 7a1c48b..eee8827 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs @@ -18,6 +18,11 @@ public sealed class UserConfiguration : IEntityTypeConfiguration builder.Property(u => u.Status) .HasConversion().HasMaxLength(20).IsRequired(); + // Maps the local shadow user to its AuthHex identity (unique; NULL for the + // system user — Postgres allows multiple NULLs in a unique index). + builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id"); + builder.HasIndex(u => u.AuthUserId).IsUnique(); + // Seeded fallback audit actor while auth is deferred (§6). builder.HasData(new User { diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs new file mode 100644 index 0000000..59a8803 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -0,0 +1,45 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Infra.Persistence; + +/// +/// Idempotent startup seeding of configurable reference data (docs/10 §B.8.3). +/// Reason codes are seeded at runtime (not via HasData) so the identity +/// sequence advances normally and later admin POST /reason-codes calls +/// cannot collide with seeded ids. +/// +public static class DataSeeder +{ + private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes = + [ + ("DMG", "Damage", ReasonContext.Adjustment), + ("THEFT", "Theft/Loss", ReasonContext.Adjustment), + ("VAR", "Count Variance", ReasonContext.Adjustment), + ("EXP", "Expiry Write-off", ReasonContext.Adjustment), + ("SYS", "System Correction", ReasonContext.Adjustment), + ("DEF", "Defective", ReasonContext.Return), + ("WRONG", "Wrong Item", ReasonContext.Return), + ("OVER", "Over-supply", ReasonContext.Return), + ("QREJ", "Quality Reject", ReasonContext.Return), + ]; + + public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default) + { + var existing = await db.ReasonCodes + .Select(r => new { r.Context, r.Code }) + .ToListAsync(ct); + var have = existing.Select(x => (x.Context, x.Code)).ToHashSet(); + + var toAdd = StandardReasonCodes + .Where(r => !have.Contains((r.Context, r.Code))) + .Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context }) + .ToList(); + + if (toAdd.Count == 0) return; + + db.ReasonCodes.AddRange(toAdd); + await db.SaveChangesAsync(ct); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index d8bf5d5..6a7d7e6 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -1,4 +1,6 @@ using ERPCore.Domain.Entities; +using ERPCore.Infra.Auth; +using ERPCore.Infra.Persistence.Auditing; using Microsoft.EntityFrameworkCore; namespace ERPCore.Infra.Persistence; @@ -8,11 +10,15 @@ namespace ERPCore.Infra.Persistence; /// configurations are added under /// Domain/Entities and Infra/Persistence/Configurations as they are implemented. /// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here. +/// Every save writes an immutable audit trail (FR-X-02) via . /// public class ErpDbContext : DbContext { - public ErpDbContext(DbContextOptions options) : base(options) + private readonly ICurrentUser _currentUser; + + public ErpDbContext(DbContextOptions options, ICurrentUser currentUser) : base(options) { + _currentUser = currentUser; } // --- Master Data (docs/10 Part C.1) --- @@ -39,6 +45,37 @@ public class ErpDbContext : DbContext public DbSet PurchaseOrders => Set(); public DbSet PoLines => Set(); + // --- Goods Receipt (docs/10 Part C.3) --- + public DbSet Grns => Set(); + public DbSet GrnLines => Set(); + + // --- Batch / Serial (docs/10 Part C.4) --- + public DbSet Batches => Set(); + public DbSet Serials => Set(); + + // --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) --- + public DbSet StockLayers => Set(); + public DbSet StockLedger => Set(); + + // --- Stock transactions (docs/10 Part C.6) --- + public DbSet StockTransfers => Set(); + public DbSet StockTransferLines => Set(); + public DbSet StockAdjustments => Set(); + public DbSet StockAdjustmentLines => Set(); + public DbSet StockCounts => Set(); + public DbSet StockCountLines => Set(); + + // --- Purchase returns (docs/10 Part C.2) --- + public DbSet PurchaseReturns => Set(); + public DbSet PurchaseReturnLines => Set(); + + // --- Reference data (docs/10 Part C.7) --- + public DbSet ReasonCodes => Set(); + + // --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) --- + public DbSet AuditLogs => Set(); + public DbSet JournalEntryStubs => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -47,4 +84,39 @@ public class ErpDbContext : DbContext // (Infra/Persistence/Configurations/*). modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly); } + + // Audit trail (FR-X-02): capture mutations before save (accurate old→new), then + // write the log rows once inserts have their generated keys. A second base save + // persists the logs without re-auditing them. + public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default) + { + var pending = AuditScribe.Capture(ChangeTracker); + var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); + if (pending.Count > 0) + { + WriteAuditLogs(pending); + await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); + } + return result; + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + var pending = AuditScribe.Capture(ChangeTracker); + var result = base.SaveChanges(acceptAllChangesOnSuccess); + if (pending.Count > 0) + { + WriteAuditLogs(pending); + base.SaveChanges(acceptAllChangesOnSuccess); + } + return result; + } + + private void WriteAuditLogs(IReadOnlyList pending) + { + var now = DateTime.UtcNow; + var userId = _currentUser.AuditUserId; + foreach (var p in pending) + AuditLogs.Add(AuditScribe.ToLog(p, userId, now)); + } } diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs deleted file mode 100644 index 4d6fe75..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.Designer.cs +++ /dev/null @@ -1,445 +0,0 @@ -// -using System; -using ERPCore.Infra.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - [DbContext(typeof(ErpDbContext))] - [Migration("20260709095653_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.Property("BinId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); - - b.Property("BinType") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("WarehouseId") - .HasColumnType("bigint"); - - b.HasKey("BinId"); - - b.HasIndex("WarehouseId", "Code") - .IsUnique(); - - b.ToTable("bins", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Property("CategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ParentId") - .HasColumnType("bigint"); - - b.HasKey("CategoryId"); - - b.HasIndex("ParentId"); - - b.ToTable("categories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Property("ItemId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("bigint"); - - b.Property("CategoryId") - .HasColumnType("bigint"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DefaultVendorId") - .HasColumnType("bigint"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("ItemType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Sku") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("TaxClass") - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("TrackingMode") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("ItemId"); - - b.HasIndex("BaseUomId"); - - b.HasIndex("CategoryId"); - - b.HasIndex("DefaultVendorId"); - - b.HasIndex("Sku") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("items", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.Property("ReorderId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("ReorderPoint") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReorderQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("bigint"); - - b.HasKey("ReorderId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId") - .IsUnique(); - - b.ToTable("item_reorders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => - { - b.Property("UomId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("UomId"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("uoms", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => - { - b.Property("ConversionId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); - - b.Property("Factor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("FromUomId") - .HasColumnType("bigint"); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("ToUomId") - .HasColumnType("bigint"); - - b.HasKey("ConversionId"); - - b.HasIndex("FromUomId"); - - b.HasIndex("ToUomId"); - - b.HasIndex("ItemId", "FromUomId", "ToUomId") - .IsUnique(); - - b.ToTable("uom_conversions", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => - { - b.Property("VendorId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Currency") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(3) - .HasColumnType("character varying(3)") - .HasDefaultValue("LKR"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("TaxReg") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Terms") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("VendorId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("vendors", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => - { - b.Property("WarehouseId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.HasKey("WarehouseId"); - - b.HasIndex("Code") - .IsUnique(); - - b.ToTable("warehouses", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany("Bins") - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Parent") - .WithMany("Children") - .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Parent"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") - .WithMany() - .HasForeignKey("BaseUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Category", "Category") - .WithMany() - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") - .WithMany() - .HasForeignKey("DefaultVendorId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("BaseUom"); - - b.Navigation("Category"); - - b.Navigation("DefaultVendor"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany("ReorderSettings") - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => - { - b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") - .WithMany() - .HasForeignKey("FromUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany("UomConversions") - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") - .WithMany() - .HasForeignKey("ToUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("FromUom"); - - b.Navigation("Item"); - - b.Navigation("ToUom"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Navigation("Children"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Navigation("ReorderSettings"); - - b.Navigation("UomConversions"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => - { - b.Navigation("Bins"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.cs deleted file mode 100644 index 7ba3005..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260709095653_InitialCreate.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "categories", - columns: table => new - { - CategoryId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - ParentId = table.Column(type: "bigint", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_categories", x => x.CategoryId); - table.ForeignKey( - name: "FK_categories_categories_ParentId", - column: x => x.ParentId, - principalTable: "categories", - principalColumn: "CategoryId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "uoms", - columns: table => new - { - UomId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_uoms", x => x.UomId); - }); - - migrationBuilder.CreateTable( - name: "vendors", - columns: table => new - { - VendorId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Terms = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), - TaxReg = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), - Currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false, defaultValue: "LKR"), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_vendors", x => x.VendorId); - }); - - migrationBuilder.CreateTable( - name: "warehouses", - columns: table => new - { - WarehouseId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_warehouses", x => x.WarehouseId); - }); - - migrationBuilder.CreateTable( - name: "items", - columns: table => new - { - ItemId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Sku = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), - CategoryId = table.Column(type: "bigint", nullable: false), - BaseUomId = table.Column(type: "bigint", nullable: false), - DefaultVendorId = table.Column(type: "bigint", nullable: true), - ItemType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - TrackingMode = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - TaxClass = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_items", x => x.ItemId); - table.ForeignKey( - name: "FK_items_categories_CategoryId", - column: x => x.CategoryId, - principalTable: "categories", - principalColumn: "CategoryId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_items_uoms_BaseUomId", - column: x => x.BaseUomId, - principalTable: "uoms", - principalColumn: "UomId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_items_vendors_DefaultVendorId", - column: x => x.DefaultVendorId, - principalTable: "vendors", - principalColumn: "VendorId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "bins", - columns: table => new - { - BinId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - WarehouseId = table.Column(type: "bigint", nullable: false), - Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), - BinType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_bins", x => x.BinId); - table.ForeignKey( - name: "FK_bins_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "item_reorders", - columns: table => new - { - ReorderId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ItemId = table.Column(type: "bigint", nullable: false), - WarehouseId = table.Column(type: "bigint", nullable: false), - ReorderPoint = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - ReorderQty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_item_reorders", x => x.ReorderId); - table.ForeignKey( - name: "FK_item_reorders_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_item_reorders_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "uom_conversions", - columns: table => new - { - ConversionId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - ItemId = table.Column(type: "bigint", nullable: false), - FromUomId = table.Column(type: "bigint", nullable: false), - ToUomId = table.Column(type: "bigint", nullable: false), - Factor = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_uom_conversions", x => x.ConversionId); - table.ForeignKey( - name: "FK_uom_conversions_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_uom_conversions_uoms_FromUomId", - column: x => x.FromUomId, - principalTable: "uoms", - principalColumn: "UomId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_uom_conversions_uoms_ToUomId", - column: x => x.ToUomId, - principalTable: "uoms", - principalColumn: "UomId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateIndex( - name: "IX_bins_WarehouseId_Code", - table: "bins", - columns: new[] { "WarehouseId", "Code" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_categories_ParentId", - table: "categories", - column: "ParentId"); - - migrationBuilder.CreateIndex( - name: "IX_item_reorders_ItemId_WarehouseId", - table: "item_reorders", - columns: new[] { "ItemId", "WarehouseId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_item_reorders_WarehouseId", - table: "item_reorders", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_items_BaseUomId", - table: "items", - column: "BaseUomId"); - - migrationBuilder.CreateIndex( - name: "IX_items_CategoryId", - table: "items", - column: "CategoryId"); - - migrationBuilder.CreateIndex( - name: "IX_items_DefaultVendorId", - table: "items", - column: "DefaultVendorId"); - - migrationBuilder.CreateIndex( - name: "IX_items_Sku", - table: "items", - column: "Sku", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_items_Status", - table: "items", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_uom_conversions_FromUomId", - table: "uom_conversions", - column: "FromUomId"); - - migrationBuilder.CreateIndex( - name: "IX_uom_conversions_ItemId_FromUomId_ToUomId", - table: "uom_conversions", - columns: new[] { "ItemId", "FromUomId", "ToUomId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_uom_conversions_ToUomId", - table: "uom_conversions", - column: "ToUomId"); - - migrationBuilder.CreateIndex( - name: "IX_uoms_Name", - table: "uoms", - column: "Name", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_vendors_Code", - table: "vendors", - column: "Code", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_vendors_Status", - table: "vendors", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_warehouses_Code", - table: "warehouses", - column: "Code", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "bins"); - - migrationBuilder.DropTable( - name: "item_reorders"); - - migrationBuilder.DropTable( - name: "uom_conversions"); - - migrationBuilder.DropTable( - name: "warehouses"); - - migrationBuilder.DropTable( - name: "items"); - - migrationBuilder.DropTable( - name: "categories"); - - migrationBuilder.DropTable( - name: "uoms"); - - migrationBuilder.DropTable( - name: "vendors"); - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.Designer.cs deleted file mode 100644 index ee80b14..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.Designer.cs +++ /dev/null @@ -1,445 +0,0 @@ -// -using System; -using ERPCore.Infra.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - [DbContext(typeof(ErpDbContext))] - [Migration("20260709124415_initial")] - partial class initial - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.Property("BinId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); - - b.Property("BinType") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("WarehouseId") - .HasColumnType("bigint"); - - b.HasKey("BinId"); - - b.HasIndex("WarehouseId", "Code") - .IsUnique(); - - b.ToTable("bins", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Property("CategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ParentId") - .HasColumnType("bigint"); - - b.HasKey("CategoryId"); - - b.HasIndex("ParentId"); - - b.ToTable("categories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Property("ItemId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("bigint"); - - b.Property("CategoryId") - .HasColumnType("bigint"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DefaultVendorId") - .HasColumnType("bigint"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("ItemType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Sku") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("TaxClass") - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("TrackingMode") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("ItemId"); - - b.HasIndex("BaseUomId"); - - b.HasIndex("CategoryId"); - - b.HasIndex("DefaultVendorId"); - - b.HasIndex("Sku") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("items", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.Property("ReorderId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("ReorderPoint") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReorderQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("bigint"); - - b.HasKey("ReorderId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId") - .IsUnique(); - - b.ToTable("item_reorders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => - { - b.Property("UomId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("UomId"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("uoms", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => - { - b.Property("ConversionId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); - - b.Property("Factor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("FromUomId") - .HasColumnType("bigint"); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("ToUomId") - .HasColumnType("bigint"); - - b.HasKey("ConversionId"); - - b.HasIndex("FromUomId"); - - b.HasIndex("ToUomId"); - - b.HasIndex("ItemId", "FromUomId", "ToUomId") - .IsUnique(); - - b.ToTable("uom_conversions", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => - { - b.Property("VendorId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Currency") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(3) - .HasColumnType("character varying(3)") - .HasDefaultValue("LKR"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("TaxReg") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Terms") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("VendorId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("vendors", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => - { - b.Property("WarehouseId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.HasKey("WarehouseId"); - - b.HasIndex("Code") - .IsUnique(); - - b.ToTable("warehouses", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany("Bins") - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Parent") - .WithMany("Children") - .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Parent"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") - .WithMany() - .HasForeignKey("BaseUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Category", "Category") - .WithMany() - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") - .WithMany() - .HasForeignKey("DefaultVendorId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("BaseUom"); - - b.Navigation("Category"); - - b.Navigation("DefaultVendor"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany("ReorderSettings") - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => - { - b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") - .WithMany() - .HasForeignKey("FromUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany("UomConversions") - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") - .WithMany() - .HasForeignKey("ToUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("FromUom"); - - b.Navigation("Item"); - - b.Navigation("ToUom"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Navigation("Children"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Navigation("ReorderSettings"); - - b.Navigation("UomConversions"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => - { - b.Navigation("Bins"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.cs deleted file mode 100644 index f83a4b3..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260709124415_initial.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - /// - public partial class initial : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.Designer.cs deleted file mode 100644 index 9ce865a..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.Designer.cs +++ /dev/null @@ -1,987 +0,0 @@ -// -using System; -using ERPCore.Infra.Persistence; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - [DbContext(typeof(ErpDbContext))] - [Migration("20260710090753_AddProcurement")] - partial class AddProcurement - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.9") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.Property("BinId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); - - b.Property("BinType") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("WarehouseId") - .HasColumnType("bigint"); - - b.HasKey("BinId"); - - b.HasIndex("WarehouseId", "Code") - .IsUnique(); - - b.ToTable("bins", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Property("CategoryId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("ParentId") - .HasColumnType("bigint"); - - b.HasKey("CategoryId"); - - b.HasIndex("ParentId"); - - b.ToTable("categories", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Property("ItemId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("bigint"); - - b.Property("CategoryId") - .HasColumnType("bigint"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DefaultVendorId") - .HasColumnType("bigint"); - - b.Property("Description") - .HasMaxLength(1000) - .HasColumnType("character varying(1000)"); - - b.Property("ItemType") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Sku") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("TaxClass") - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("TrackingMode") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("ItemId"); - - b.HasIndex("BaseUomId"); - - b.HasIndex("CategoryId"); - - b.HasIndex("DefaultVendorId"); - - b.HasIndex("Sku") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("items", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.Property("ReorderId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("ReorderPoint") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("ReorderQty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("WarehouseId") - .HasColumnType("bigint"); - - b.HasKey("ReorderId"); - - b.HasIndex("WarehouseId"); - - b.HasIndex("ItemId", "WarehouseId") - .IsUnique(); - - b.ToTable("item_reorders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => - { - b.Property("SequenceId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); - - b.Property("DocType") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)") - .HasColumnName("doc_type"); - - b.Property("LastNumber") - .HasColumnType("bigint") - .HasColumnName("last_number"); - - b.Property("Year") - .HasColumnType("integer") - .HasColumnName("year"); - - b.HasKey("SequenceId"); - - b.HasIndex("DocType", "Year") - .IsUnique(); - - b.ToTable("number_sequences", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => - { - b.Property("PoLineId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("PoId") - .HasColumnType("bigint"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("QtyReceived") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("Tax") - .HasPrecision(9, 4) - .HasColumnType("numeric(9,4)"); - - b.Property("UnitPrice") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("UomId") - .HasColumnType("bigint"); - - b.Property("WarehouseId") - .HasColumnType("bigint"); - - b.HasKey("PoLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("PoId"); - - b.HasIndex("UomId"); - - b.HasIndex("WarehouseId"); - - b.ToTable("po_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.Property("PoId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); - - b.Property("ApprovalRequired") - .HasColumnType("boolean"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("CreatedBy") - .HasColumnType("bigint"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RequisitionId") - .HasColumnType("bigint"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("VendorId") - .HasColumnType("bigint"); - - b.HasKey("PoId"); - - b.HasIndex("CreatedBy"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("RequisitionId"); - - b.HasIndex("Status"); - - b.HasIndex("VendorId"); - - b.ToTable("purchase_orders", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => - { - b.Property("RequisitionId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RequestedBy") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("RequisitionId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("RequestedBy"); - - b.HasIndex("Status"); - - b.ToTable("requisitions", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => - { - b.Property("ReqLineId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RequiredBy") - .HasColumnType("date"); - - b.Property("RequisitionId") - .HasColumnType("bigint"); - - b.HasKey("ReqLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RequisitionId"); - - b.ToTable("requisition_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => - { - b.Property("RfqId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("DocNo") - .IsRequired() - .HasMaxLength(30) - .HasColumnType("character varying(30)"); - - b.Property("RequisitionId") - .HasColumnType("bigint"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.HasKey("RfqId"); - - b.HasIndex("DocNo") - .IsUnique(); - - b.HasIndex("RequisitionId"); - - b.ToTable("rfqs", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => - { - b.Property("RfqLineId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("Qty") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.Property("RfqId") - .HasColumnType("bigint"); - - b.HasKey("RfqLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("RfqId"); - - b.ToTable("rfq_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => - { - b.Property("UomId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); - - b.Property("Name") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.HasKey("UomId"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("uoms", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => - { - b.Property("ConversionId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); - - b.Property("Factor") - .HasPrecision(18, 6) - .HasColumnType("numeric(18,6)"); - - b.Property("FromUomId") - .HasColumnType("bigint"); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("ToUomId") - .HasColumnType("bigint"); - - b.HasKey("ConversionId"); - - b.HasIndex("FromUomId"); - - b.HasIndex("ToUomId"); - - b.HasIndex("ItemId", "FromUomId", "ToUomId") - .IsUnique(); - - b.ToTable("uom_conversions", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.User", b => - { - b.Property("UserId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); - - b.Property("DisplayName") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("Status") - .IsRequired() - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.HasKey("UserId"); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("users", (string)null); - - b.HasData( - new - { - UserId = 1L, - DisplayName = "System", - Status = "Active", - Username = "system" - }); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => - { - b.Property("VendorId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Currency") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(3) - .HasColumnType("character varying(3)") - .HasDefaultValue("LKR"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .ValueGeneratedOnAddOrUpdate() - .HasColumnType("xid") - .HasColumnName("xmin"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("character varying(20)") - .HasDefaultValue("Active"); - - b.Property("TaxReg") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Terms") - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone"); - - b.HasKey("VendorId"); - - b.HasIndex("Code") - .IsUnique(); - - b.HasIndex("Status"); - - b.ToTable("vendors", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => - { - b.Property("QuotationId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RfqId") - .HasColumnType("bigint"); - - b.Property("VendorId") - .HasColumnType("bigint"); - - b.HasKey("QuotationId"); - - b.HasIndex("VendorId"); - - b.HasIndex("RfqId", "VendorId") - .IsUnique(); - - b.ToTable("vendor_quotations", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => - { - b.Property("QuotationLineId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); - - b.Property("ItemId") - .HasColumnType("bigint"); - - b.Property("LeadDays") - .HasColumnType("integer"); - - b.Property("QuotationId") - .HasColumnType("bigint"); - - b.Property("UnitPrice") - .HasPrecision(18, 4) - .HasColumnType("numeric(18,4)"); - - b.HasKey("QuotationLineId"); - - b.HasIndex("ItemId"); - - b.HasIndex("QuotationId"); - - b.ToTable("vendor_quotation_lines", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => - { - b.Property("WarehouseId") - .ValueGeneratedOnAdd() - .HasColumnType("bigint"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); - - b.Property("Code") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("character varying(50)"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("character varying(200)"); - - b.HasKey("WarehouseId"); - - b.HasIndex("Code") - .IsUnique(); - - b.ToTable("warehouses", (string)null); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => - { - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany("Bins") - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.HasOne("ERPCore.Domain.Entities.Category", "Parent") - .WithMany("Children") - .HasForeignKey("ParentId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("Parent"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") - .WithMany() - .HasForeignKey("BaseUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Category", "Category") - .WithMany() - .HasForeignKey("CategoryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") - .WithMany() - .HasForeignKey("DefaultVendorId") - .OnDelete(DeleteBehavior.Restrict); - - b.Navigation("BaseUom"); - - b.Navigation("Category"); - - b.Navigation("DefaultVendor"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany("ReorderSettings") - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") - .WithMany("Lines") - .HasForeignKey("PoId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") - .WithMany() - .HasForeignKey("UomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") - .WithMany() - .HasForeignKey("WarehouseId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("PurchaseOrder"); - - b.Navigation("Uom"); - - b.Navigation("Warehouse"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Creator") - .WithMany() - .HasForeignKey("CreatedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") - .WithMany() - .HasForeignKey("RequisitionId") - .OnDelete(DeleteBehavior.Restrict); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Creator"); - - b.Navigation("Requisition"); - - b.Navigation("Vendor"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => - { - b.HasOne("ERPCore.Domain.Entities.User", "Requester") - .WithMany() - .HasForeignKey("RequestedBy") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Requester"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") - .WithMany("Lines") - .HasForeignKey("RequisitionId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Requisition"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => - { - b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") - .WithMany() - .HasForeignKey("RequisitionId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Requisition"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") - .WithMany("Lines") - .HasForeignKey("RfqId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Rfq"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => - { - b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") - .WithMany() - .HasForeignKey("FromUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany("UomConversions") - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") - .WithMany() - .HasForeignKey("ToUomId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("FromUom"); - - b.Navigation("Item"); - - b.Navigation("ToUom"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => - { - b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") - .WithMany("Quotations") - .HasForeignKey("RfqId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") - .WithMany() - .HasForeignKey("VendorId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Rfq"); - - b.Navigation("Vendor"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => - { - b.HasOne("ERPCore.Domain.Entities.Item", "Item") - .WithMany() - .HasForeignKey("ItemId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") - .WithMany("Lines") - .HasForeignKey("QuotationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Item"); - - b.Navigation("Quotation"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => - { - b.Navigation("Children"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => - { - b.Navigation("ReorderSettings"); - - b.Navigation("UomConversions"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => - { - b.Navigation("Lines"); - - b.Navigation("Quotations"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => - { - b.Navigation("Lines"); - }); - - modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => - { - b.Navigation("Bins"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.cs deleted file mode 100644 index ee2d903..0000000 --- a/Backend/ERPCore/Infra/Persistence/Migrations/20260710090753_AddProcurement.cs +++ /dev/null @@ -1,448 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ERPCore.Infra.Persistence.Migrations -{ - /// - public partial class AddProcurement : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "number_sequences", - columns: table => new - { - SequenceId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - doc_type = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), - year = table.Column(type: "integer", nullable: false), - last_number = table.Column(type: "bigint", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_number_sequences", x => x.SequenceId); - }); - - migrationBuilder.CreateTable( - name: "users", - columns: table => new - { - UserId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - Username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), - DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_users", x => x.UserId); - }); - - migrationBuilder.CreateTable( - name: "requisitions", - columns: table => new - { - RequisitionId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - RequestedBy = table.Column(type: "bigint", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_requisitions", x => x.RequisitionId); - table.ForeignKey( - name: "FK_requisitions_users_RequestedBy", - column: x => x.RequestedBy, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "purchase_orders", - columns: table => new - { - PoId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - VendorId = table.Column(type: "bigint", nullable: false), - RequisitionId = table.Column(type: "bigint", nullable: true), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - ApprovalRequired = table.Column(type: "boolean", nullable: false), - CreatedBy = table.Column(type: "bigint", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), - UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), - xmin = table.Column(type: "xid", rowVersion: true, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_purchase_orders", x => x.PoId); - table.ForeignKey( - name: "FK_purchase_orders_requisitions_RequisitionId", - column: x => x.RequisitionId, - principalTable: "requisitions", - principalColumn: "RequisitionId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_purchase_orders_users_CreatedBy", - column: x => x.CreatedBy, - principalTable: "users", - principalColumn: "UserId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_purchase_orders_vendors_VendorId", - column: x => x.VendorId, - principalTable: "vendors", - principalColumn: "VendorId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "requisition_lines", - columns: table => new - { - ReqLineId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RequisitionId = table.Column(type: "bigint", nullable: false), - ItemId = table.Column(type: "bigint", nullable: false), - Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - RequiredBy = table.Column(type: "date", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_requisition_lines", x => x.ReqLineId); - table.ForeignKey( - name: "FK_requisition_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_requisition_lines_requisitions_RequisitionId", - column: x => x.RequisitionId, - principalTable: "requisitions", - principalColumn: "RequisitionId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "rfqs", - columns: table => new - { - RfqId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), - RequisitionId = table.Column(type: "bigint", nullable: false), - Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_rfqs", x => x.RfqId); - table.ForeignKey( - name: "FK_rfqs_requisitions_RequisitionId", - column: x => x.RequisitionId, - principalTable: "requisitions", - principalColumn: "RequisitionId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "po_lines", - columns: table => new - { - PoLineId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - PoId = table.Column(type: "bigint", nullable: false), - ItemId = table.Column(type: "bigint", nullable: false), - UomId = table.Column(type: "bigint", nullable: false), - WarehouseId = table.Column(type: "bigint", nullable: false), - Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - UnitPrice = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - Tax = table.Column(type: "numeric(9,4)", precision: 9, scale: 4, nullable: false), - QtyReceived = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_po_lines", x => x.PoLineId); - table.ForeignKey( - name: "FK_po_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_po_lines_purchase_orders_PoId", - column: x => x.PoId, - principalTable: "purchase_orders", - principalColumn: "PoId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_po_lines_uoms_UomId", - column: x => x.UomId, - principalTable: "uoms", - principalColumn: "UomId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_po_lines_warehouses_WarehouseId", - column: x => x.WarehouseId, - principalTable: "warehouses", - principalColumn: "WarehouseId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "rfq_lines", - columns: table => new - { - RfqLineId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RfqId = table.Column(type: "bigint", nullable: false), - ItemId = table.Column(type: "bigint", nullable: false), - Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_rfq_lines", x => x.RfqLineId); - table.ForeignKey( - name: "FK_rfq_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_rfq_lines_rfqs_RfqId", - column: x => x.RfqId, - principalTable: "rfqs", - principalColumn: "RfqId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "vendor_quotations", - columns: table => new - { - QuotationId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - RfqId = table.Column(type: "bigint", nullable: false), - VendorId = table.Column(type: "bigint", nullable: false), - CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_vendor_quotations", x => x.QuotationId); - table.ForeignKey( - name: "FK_vendor_quotations_rfqs_RfqId", - column: x => x.RfqId, - principalTable: "rfqs", - principalColumn: "RfqId", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_vendor_quotations_vendors_VendorId", - column: x => x.VendorId, - principalTable: "vendors", - principalColumn: "VendorId", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "vendor_quotation_lines", - columns: table => new - { - QuotationLineId = table.Column(type: "bigint", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - QuotationId = table.Column(type: "bigint", nullable: false), - ItemId = table.Column(type: "bigint", nullable: false), - UnitPrice = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), - LeadDays = table.Column(type: "integer", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_vendor_quotation_lines", x => x.QuotationLineId); - table.ForeignKey( - name: "FK_vendor_quotation_lines_items_ItemId", - column: x => x.ItemId, - principalTable: "items", - principalColumn: "ItemId", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_vendor_quotation_lines_vendor_quotations_QuotationId", - column: x => x.QuotationId, - principalTable: "vendor_quotations", - principalColumn: "QuotationId", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.InsertData( - table: "users", - columns: new[] { "UserId", "DisplayName", "Status", "Username" }, - values: new object[] { 1L, "System", "Active", "system" }); - - migrationBuilder.CreateIndex( - name: "IX_number_sequences_doc_type_year", - table: "number_sequences", - columns: new[] { "doc_type", "year" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_po_lines_ItemId", - table: "po_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_po_lines_PoId", - table: "po_lines", - column: "PoId"); - - migrationBuilder.CreateIndex( - name: "IX_po_lines_UomId", - table: "po_lines", - column: "UomId"); - - migrationBuilder.CreateIndex( - name: "IX_po_lines_WarehouseId", - table: "po_lines", - column: "WarehouseId"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_orders_CreatedBy", - table: "purchase_orders", - column: "CreatedBy"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_orders_DocNo", - table: "purchase_orders", - column: "DocNo", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_purchase_orders_RequisitionId", - table: "purchase_orders", - column: "RequisitionId"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_orders_Status", - table: "purchase_orders", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_purchase_orders_VendorId", - table: "purchase_orders", - column: "VendorId"); - - migrationBuilder.CreateIndex( - name: "IX_requisition_lines_ItemId", - table: "requisition_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_requisition_lines_RequisitionId", - table: "requisition_lines", - column: "RequisitionId"); - - migrationBuilder.CreateIndex( - name: "IX_requisitions_DocNo", - table: "requisitions", - column: "DocNo", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_requisitions_RequestedBy", - table: "requisitions", - column: "RequestedBy"); - - migrationBuilder.CreateIndex( - name: "IX_requisitions_Status", - table: "requisitions", - column: "Status"); - - migrationBuilder.CreateIndex( - name: "IX_rfq_lines_ItemId", - table: "rfq_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_rfq_lines_RfqId", - table: "rfq_lines", - column: "RfqId"); - - migrationBuilder.CreateIndex( - name: "IX_rfqs_DocNo", - table: "rfqs", - column: "DocNo", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_rfqs_RequisitionId", - table: "rfqs", - column: "RequisitionId"); - - migrationBuilder.CreateIndex( - name: "IX_users_Username", - table: "users", - column: "Username", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_vendor_quotation_lines_ItemId", - table: "vendor_quotation_lines", - column: "ItemId"); - - migrationBuilder.CreateIndex( - name: "IX_vendor_quotation_lines_QuotationId", - table: "vendor_quotation_lines", - column: "QuotationId"); - - migrationBuilder.CreateIndex( - name: "IX_vendor_quotations_RfqId_VendorId", - table: "vendor_quotations", - columns: new[] { "RfqId", "VendorId" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_vendor_quotations_VendorId", - table: "vendor_quotations", - column: "VendorId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "number_sequences"); - - migrationBuilder.DropTable( - name: "po_lines"); - - migrationBuilder.DropTable( - name: "requisition_lines"); - - migrationBuilder.DropTable( - name: "rfq_lines"); - - migrationBuilder.DropTable( - name: "vendor_quotation_lines"); - - migrationBuilder.DropTable( - name: "purchase_orders"); - - migrationBuilder.DropTable( - name: "vendor_quotations"); - - migrationBuilder.DropTable( - name: "rfqs"); - - migrationBuilder.DropTable( - name: "requisitions"); - - migrationBuilder.DropTable( - name: "users"); - } - } -} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.Designer.cs new file mode 100644 index 0000000..0406326 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.Designer.cs @@ -0,0 +1,2229 @@ +// +using System; +using ERPCore.Infra.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + [DbContext(typeof(ErpDbContext))] + [Migration("20260715093552_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ChangeSet") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("integer"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.HasKey("BatchId"); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.Property("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("BinId"); + + b.HasIndex("WarehouseId", "Code") + .IsUnique(); + + b.ToTable("bins", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("integer"); + + b.HasKey("CategoryId"); + + b.HasIndex("ParentId"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("GrnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("GrnId") + .HasColumnType("integer"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoLineId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.HasIndex("UomId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("integer") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("integer"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ToUomId") + .HasColumnType("integer"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .HasForeignKey("DefaultVendorId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("BaseUom"); + + b.Navigation("Category"); + + b.Navigation("DefaultVendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.cs new file mode 100644 index 0000000..1d0bd52 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260715093552_InitialCreate.cs @@ -0,0 +1,1791 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "categories", + columns: table => new + { + CategoryId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ParentId = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_categories", x => x.CategoryId); + table.ForeignKey( + name: "FK_categories_categories_ParentId", + column: x => x.ParentId, + principalTable: "categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "journal_entry_stubs", + columns: table => new + { + JournalId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SourceDocType = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + SourceDocId = table.Column(type: "integer", nullable: false), + DebitAccount = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreditAccount = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Amount = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_journal_entry_stubs", x => x.JournalId); + }); + + migrationBuilder.CreateTable( + name: "number_sequences", + columns: table => new + { + SequenceId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + doc_type = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + year = table.Column(type: "integer", nullable: false), + last_number = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_number_sequences", x => x.SequenceId); + }); + + migrationBuilder.CreateTable( + name: "reason_codes", + columns: table => new + { + ReasonCodeId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Description = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Context = table.Column(type: "character varying(20)", maxLength: 20, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_reason_codes", x => x.ReasonCodeId); + }); + + migrationBuilder.CreateTable( + name: "uoms", + columns: table => new + { + UomId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_uoms", x => x.UomId); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + UserId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + auth_user_id = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.UserId); + }); + + migrationBuilder.CreateTable( + name: "vendors", + columns: table => new + { + VendorId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Terms = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + TaxReg = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Currency = table.Column(type: "character varying(3)", maxLength: 3, nullable: false, defaultValue: "LKR"), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_vendors", x => x.VendorId); + }); + + migrationBuilder.CreateTable( + name: "warehouses", + columns: table => new + { + WarehouseId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_warehouses", x => x.WarehouseId); + }); + + migrationBuilder.CreateTable( + name: "audit_logs", + columns: table => new + { + AuditId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + EntityType = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + EntityId = table.Column(type: "integer", nullable: false), + Action = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + ChangeSet = table.Column(type: "jsonb", nullable: false), + CreatedAt = table.Column(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: "requisitions", + columns: table => new + { + RequisitionId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + RequestedBy = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_requisitions", x => x.RequisitionId); + table.ForeignKey( + name: "FK_requisitions_users_RequestedBy", + column: x => x.RequestedBy, + principalTable: "users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "items", + columns: table => new + { + ItemId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Sku = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + CategoryId = table.Column(type: "integer", nullable: false), + BaseUomId = table.Column(type: "integer", nullable: false), + DefaultVendorId = table.Column(type: "integer", nullable: true), + ItemType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + TrackingMode = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + TaxClass = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_items", x => x.ItemId); + table.ForeignKey( + name: "FK_items_categories_CategoryId", + column: x => x.CategoryId, + principalTable: "categories", + principalColumn: "CategoryId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_items_uoms_BaseUomId", + column: x => x.BaseUomId, + principalTable: "uoms", + principalColumn: "UomId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_items_vendors_DefaultVendorId", + column: x => x.DefaultVendorId, + principalTable: "vendors", + principalColumn: "VendorId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "bins", + columns: table => new + { + BinId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + WarehouseId = table.Column(type: "integer", nullable: false), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + BinType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_bins", x => x.BinId); + table.ForeignKey( + name: "FK_bins_warehouses_WarehouseId", + column: x => x.WarehouseId, + principalTable: "warehouses", + principalColumn: "WarehouseId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "purchase_returns", + columns: table => new + { + ReturnId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + VendorId = table.Column(type: "integer", nullable: false), + WarehouseId = table.Column(type: "integer", nullable: false), + ReasonCodeId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(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_adjustments", + columns: table => new + { + AdjustmentId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + WarehouseId = table.Column(type: "integer", nullable: false), + ReasonCodeId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + xmin = table.Column(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_counts", + columns: table => new + { + CountId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + WarehouseId = table.Column(type: "integer", nullable: false), + CountType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + xmin = table.Column(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: "stock_transfers", + columns: table => new + { + TransferId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + SrcWarehouseId = table.Column(type: "integer", nullable: false), + DestWarehouseId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + xmin = table.Column(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: "purchase_orders", + columns: table => new + { + PoId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + VendorId = table.Column(type: "integer", nullable: false), + RequisitionId = table.Column(type: "integer", nullable: true), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ApprovalRequired = table.Column(type: "boolean", nullable: false), + CreatedBy = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_purchase_orders", x => x.PoId); + table.ForeignKey( + name: "FK_purchase_orders_requisitions_RequisitionId", + column: x => x.RequisitionId, + principalTable: "requisitions", + principalColumn: "RequisitionId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_purchase_orders_users_CreatedBy", + column: x => x.CreatedBy, + principalTable: "users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_purchase_orders_vendors_VendorId", + column: x => x.VendorId, + principalTable: "vendors", + principalColumn: "VendorId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "rfqs", + columns: table => new + { + RfqId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + RequisitionId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_rfqs", x => x.RfqId); + table.ForeignKey( + name: "FK_rfqs_requisitions_RequisitionId", + column: x => x.RequisitionId, + principalTable: "requisitions", + principalColumn: "RequisitionId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "batches", + columns: table => new + { + BatchId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "integer", nullable: false), + BatchNo = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + ExpiryDate = table.Column(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: "item_reorders", + columns: table => new + { + ReorderId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "integer", nullable: false), + WarehouseId = table.Column(type: "integer", nullable: false), + ReorderPoint = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + ReorderQty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_item_reorders", x => x.ReorderId); + table.ForeignKey( + name: "FK_item_reorders_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_item_reorders_warehouses_WarehouseId", + column: x => x.WarehouseId, + principalTable: "warehouses", + principalColumn: "WarehouseId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "requisition_lines", + columns: table => new + { + ReqLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RequisitionId = table.Column(type: "integer", nullable: false), + ItemId = table.Column(type: "integer", nullable: false), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + RequiredBy = table.Column(type: "date", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_requisition_lines", x => x.ReqLineId); + table.ForeignKey( + name: "FK_requisition_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_requisition_lines_requisitions_RequisitionId", + column: x => x.RequisitionId, + principalTable: "requisitions", + principalColumn: "RequisitionId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "serials", + columns: table => new + { + SerialId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "integer", nullable: false), + SerialNo = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Status = table.Column(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: "uom_conversions", + columns: table => new + { + ConversionId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "integer", nullable: false), + FromUomId = table.Column(type: "integer", nullable: false), + ToUomId = table.Column(type: "integer", nullable: false), + Factor = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_uom_conversions", x => x.ConversionId); + table.ForeignKey( + name: "FK_uom_conversions_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_uom_conversions_uoms_FromUomId", + column: x => x.FromUomId, + principalTable: "uoms", + principalColumn: "UomId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_uom_conversions_uoms_ToUomId", + column: x => x.ToUomId, + principalTable: "uoms", + principalColumn: "UomId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "stock_count_lines", + columns: table => new + { + CountLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CountId = table.Column(type: "integer", nullable: false), + ItemId = table.Column(type: "integer", nullable: false), + BinId = table.Column(type: "integer", nullable: true), + SystemQty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + CountedQty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: true), + Variance = table.Column(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.CreateTable( + name: "grns", + columns: table => new + { + GrnId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + PoId = table.Column(type: "integer", nullable: true), + VendorId = table.Column(type: "integer", nullable: false), + WarehouseId = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + PostedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(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: "po_lines", + columns: table => new + { + PoLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PoId = table.Column(type: "integer", nullable: false), + ItemId = table.Column(type: "integer", nullable: false), + UomId = table.Column(type: "integer", nullable: false), + WarehouseId = table.Column(type: "integer", nullable: false), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + UnitPrice = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + Tax = table.Column(type: "numeric(9,4)", precision: 9, scale: 4, nullable: false), + QtyReceived = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_po_lines", x => x.PoLineId); + table.ForeignKey( + name: "FK_po_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_po_lines_purchase_orders_PoId", + column: x => x.PoId, + principalTable: "purchase_orders", + principalColumn: "PoId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_po_lines_uoms_UomId", + column: x => x.UomId, + principalTable: "uoms", + principalColumn: "UomId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_po_lines_warehouses_WarehouseId", + column: x => x.WarehouseId, + principalTable: "warehouses", + principalColumn: "WarehouseId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "rfq_lines", + columns: table => new + { + RfqLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RfqId = table.Column(type: "integer", nullable: false), + ItemId = table.Column(type: "integer", nullable: false), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_rfq_lines", x => x.RfqLineId); + table.ForeignKey( + name: "FK_rfq_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_rfq_lines_rfqs_RfqId", + column: x => x.RfqId, + principalTable: "rfqs", + principalColumn: "RfqId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "vendor_quotations", + columns: table => new + { + QuotationId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RfqId = table.Column(type: "integer", nullable: false), + VendorId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_vendor_quotations", x => x.QuotationId); + table.ForeignKey( + name: "FK_vendor_quotations_rfqs_RfqId", + column: x => x.RfqId, + principalTable: "rfqs", + principalColumn: "RfqId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_vendor_quotations_vendors_VendorId", + column: x => x.VendorId, + principalTable: "vendors", + principalColumn: "VendorId", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "stock_adjustment_lines", + columns: table => new + { + AdjLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AdjustmentId = table.Column(type: "integer", nullable: false), + ItemId = table.Column(type: "integer", nullable: false), + BinId = table.Column(type: "integer", nullable: true), + BatchId = table.Column(type: "integer", nullable: true), + SerialId = table.Column(type: "integer", nullable: true), + QtyDelta = table.Column(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.CreateTable( + name: "stock_ledger", + columns: table => new + { + LedgerId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "integer", nullable: false), + WarehouseId = table.Column(type: "integer", nullable: false), + BinId = table.Column(type: "integer", nullable: true), + BatchId = table.Column(type: "integer", nullable: true), + SerialId = table.Column(type: "integer", nullable: true), + UserId = table.Column(type: "integer", nullable: false), + Direction = table.Column(type: "character varying(5)", maxLength: 5, nullable: false), + QtyBase = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + UnitCost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false), + Value = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + RunningBalance = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + SourceDocType = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + SourceDocId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(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_transfer_lines", + columns: table => new + { + TransferLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TransferId = table.Column(type: "integer", nullable: false), + ItemId = table.Column(type: "integer", nullable: false), + SrcBinId = table.Column(type: "integer", nullable: true), + DestBinId = table.Column(type: "integer", nullable: true), + BatchId = table.Column(type: "integer", nullable: true), + SerialId = table.Column(type: "integer", nullable: true), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + UnitCost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: true), + QtyReceived = table.Column(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: "grn_lines", + columns: table => new + { + GrnLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + GrnId = table.Column(type: "integer", nullable: false), + PoLineId = table.Column(type: "integer", nullable: true), + ItemId = table.Column(type: "integer", nullable: false), + UomId = table.Column(type: "integer", nullable: false), + BinId = table.Column(type: "integer", nullable: true), + BatchId = table.Column(type: "integer", nullable: true), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + UnitCost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false), + ReceivedValue = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + HoldStatus = table.Column(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: "vendor_quotation_lines", + columns: table => new + { + QuotationLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + QuotationId = table.Column(type: "integer", nullable: false), + ItemId = table.Column(type: "integer", nullable: false), + UnitPrice = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + LeadDays = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_vendor_quotation_lines", x => x.QuotationLineId); + table.ForeignKey( + name: "FK_vendor_quotation_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_vendor_quotation_lines_vendor_quotations_QuotationId", + column: x => x.QuotationId, + principalTable: "vendor_quotations", + principalColumn: "QuotationId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "purchase_return_lines", + columns: table => new + { + ReturnLineId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ReturnId = table.Column(type: "integer", nullable: false), + GrnLineId = table.Column(type: "integer", nullable: true), + ItemId = table.Column(type: "integer", nullable: false), + Qty = table.Column(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_layers", + columns: table => new + { + LayerId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "integer", nullable: false), + WarehouseId = table.Column(type: "integer", nullable: false), + BatchId = table.Column(type: "integer", nullable: true), + SerialId = table.Column(type: "integer", nullable: true), + GrnLineId = table.Column(type: "integer", nullable: true), + QtyReceived = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + QtyRemaining = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false), + UnitCost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false), + ReceiptDate = table.Column(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.InsertData( + table: "users", + columns: new[] { "UserId", "auth_user_id", "DisplayName", "Status", "Username" }, + values: new object[] { 1, null, "System", "Active", "system" }); + + 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_batches_ItemId_BatchNo", + table: "batches", + columns: new[] { "ItemId", "BatchNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_bins_WarehouseId_Code", + table: "bins", + columns: new[] { "WarehouseId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_categories_ParentId", + table: "categories", + column: "ParentId"); + + 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_item_reorders_ItemId_WarehouseId", + table: "item_reorders", + columns: new[] { "ItemId", "WarehouseId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_item_reorders_WarehouseId", + table: "item_reorders", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_items_BaseUomId", + table: "items", + column: "BaseUomId"); + + migrationBuilder.CreateIndex( + name: "IX_items_CategoryId", + table: "items", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_items_DefaultVendorId", + table: "items", + column: "DefaultVendorId"); + + migrationBuilder.CreateIndex( + name: "IX_items_Sku", + table: "items", + column: "Sku", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_items_Status", + table: "items", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_journal_entry_stubs_SourceDocType_SourceDocId", + table: "journal_entry_stubs", + columns: new[] { "SourceDocType", "SourceDocId" }); + + migrationBuilder.CreateIndex( + name: "IX_number_sequences_doc_type_year", + table: "number_sequences", + columns: new[] { "doc_type", "year" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_po_lines_ItemId", + table: "po_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_po_lines_PoId", + table: "po_lines", + column: "PoId"); + + migrationBuilder.CreateIndex( + name: "IX_po_lines_UomId", + table: "po_lines", + column: "UomId"); + + migrationBuilder.CreateIndex( + name: "IX_po_lines_WarehouseId", + table: "po_lines", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_CreatedBy", + table: "purchase_orders", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_DocNo", + table: "purchase_orders", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_RequisitionId", + table: "purchase_orders", + column: "RequisitionId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_Status", + table: "purchase_orders", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_orders_VendorId", + table: "purchase_orders", + column: "VendorId"); + + 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_reason_codes_Context_Code", + table: "reason_codes", + columns: new[] { "Context", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_requisition_lines_ItemId", + table: "requisition_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_requisition_lines_RequisitionId", + table: "requisition_lines", + column: "RequisitionId"); + + migrationBuilder.CreateIndex( + name: "IX_requisitions_DocNo", + table: "requisitions", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_requisitions_RequestedBy", + table: "requisitions", + column: "RequestedBy"); + + migrationBuilder.CreateIndex( + name: "IX_requisitions_Status", + table: "requisitions", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_rfq_lines_ItemId", + table: "rfq_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_rfq_lines_RfqId", + table: "rfq_lines", + column: "RfqId"); + + migrationBuilder.CreateIndex( + name: "IX_rfqs_DocNo", + table: "rfqs", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_rfqs_RequisitionId", + table: "rfqs", + column: "RequisitionId"); + + migrationBuilder.CreateIndex( + name: "IX_serials_ItemId_SerialNo", + table: "serials", + columns: new[] { "ItemId", "SerialNo" }, + 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_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"); + + 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"); + + 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"); + + migrationBuilder.CreateIndex( + name: "IX_uom_conversions_FromUomId", + table: "uom_conversions", + column: "FromUomId"); + + migrationBuilder.CreateIndex( + name: "IX_uom_conversions_ItemId_FromUomId_ToUomId", + table: "uom_conversions", + columns: new[] { "ItemId", "FromUomId", "ToUomId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_uom_conversions_ToUomId", + table: "uom_conversions", + column: "ToUomId"); + + migrationBuilder.CreateIndex( + name: "IX_uoms_Name", + table: "uoms", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_users_auth_user_id", + table: "users", + column: "auth_user_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_users_Username", + table: "users", + column: "Username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_vendor_quotation_lines_ItemId", + table: "vendor_quotation_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_vendor_quotation_lines_QuotationId", + table: "vendor_quotation_lines", + column: "QuotationId"); + + migrationBuilder.CreateIndex( + name: "IX_vendor_quotations_RfqId_VendorId", + table: "vendor_quotations", + columns: new[] { "RfqId", "VendorId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_vendor_quotations_VendorId", + table: "vendor_quotations", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_vendors_Code", + table: "vendors", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_vendors_Status", + table: "vendors", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_warehouses_Code", + table: "warehouses", + column: "Code", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "audit_logs"); + + migrationBuilder.DropTable( + name: "item_reorders"); + + migrationBuilder.DropTable( + name: "journal_entry_stubs"); + + migrationBuilder.DropTable( + name: "number_sequences"); + + migrationBuilder.DropTable( + name: "purchase_return_lines"); + + migrationBuilder.DropTable( + name: "requisition_lines"); + + migrationBuilder.DropTable( + name: "rfq_lines"); + + migrationBuilder.DropTable( + name: "stock_adjustment_lines"); + + migrationBuilder.DropTable( + name: "stock_count_lines"); + + migrationBuilder.DropTable( + name: "stock_layers"); + + migrationBuilder.DropTable( + name: "stock_ledger"); + + migrationBuilder.DropTable( + name: "stock_transfer_lines"); + + migrationBuilder.DropTable( + name: "uom_conversions"); + + migrationBuilder.DropTable( + name: "vendor_quotation_lines"); + + migrationBuilder.DropTable( + name: "purchase_returns"); + + migrationBuilder.DropTable( + name: "stock_adjustments"); + + migrationBuilder.DropTable( + name: "stock_counts"); + + migrationBuilder.DropTable( + name: "grn_lines"); + + migrationBuilder.DropTable( + name: "serials"); + + migrationBuilder.DropTable( + name: "stock_transfers"); + + migrationBuilder.DropTable( + name: "vendor_quotations"); + + migrationBuilder.DropTable( + name: "reason_codes"); + + migrationBuilder.DropTable( + name: "batches"); + + migrationBuilder.DropTable( + name: "bins"); + + migrationBuilder.DropTable( + name: "grns"); + + migrationBuilder.DropTable( + name: "po_lines"); + + migrationBuilder.DropTable( + name: "rfqs"); + + migrationBuilder.DropTable( + name: "items"); + + migrationBuilder.DropTable( + name: "purchase_orders"); + + migrationBuilder.DropTable( + name: "warehouses"); + + migrationBuilder.DropTable( + name: "categories"); + + migrationBuilder.DropTable( + name: "uoms"); + + migrationBuilder.DropTable( + name: "requisitions"); + + migrationBuilder.DropTable( + name: "vendors"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index bf2c3f1..92abc54 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -22,13 +22,82 @@ namespace ERPCore.Infra.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("ChangeSet") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityId") + .HasColumnType("integer"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("AuditId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId"); + + b.HasIndex("EntityType", "EntityId"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.HasKey("BatchId"); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => { - b.Property("BinId") + b.Property("BinId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId")); b.Property("BinType") .HasMaxLength(50) @@ -39,8 +108,8 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(50) .HasColumnType("character varying(50)"); - b.Property("WarehouseId") - .HasColumnType("bigint"); + b.Property("WarehouseId") + .HasColumnType("integer"); b.HasKey("BinId"); @@ -52,19 +121,19 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => { - b.Property("CategoryId") + b.Property("CategoryId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); - b.Property("ParentId") - .HasColumnType("bigint"); + b.Property("ParentId") + .HasColumnType("integer"); b.HasKey("CategoryId"); @@ -73,25 +142,145 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("categories", (string)null); }); - modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { - b.Property("ItemId") + b.Property("GrnId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); - - b.Property("BaseUomId") - .HasColumnType("bigint"); - - b.Property("CategoryId") - .HasColumnType("bigint"); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("DefaultVendorId") - .HasColumnType("bigint"); + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("integer"); + + b.Property("PostedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("GrnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("GrnId") + .HasColumnType("integer"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PoLineId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.HasIndex("UomId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("integer"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("integer"); b.Property("Description") .HasMaxLength(1000) @@ -155,14 +344,14 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => { - b.Property("ReorderId") + b.Property("ReorderId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); - b.Property("ItemId") - .HasColumnType("bigint"); + b.Property("ItemId") + .HasColumnType("integer"); b.Property("ReorderPoint") .HasPrecision(18, 4) @@ -172,8 +361,8 @@ namespace ERPCore.Infra.Persistence.Migrations .HasPrecision(18, 4) .HasColumnType("numeric(18,4)"); - b.Property("WarehouseId") - .HasColumnType("bigint"); + b.Property("WarehouseId") + .HasColumnType("integer"); b.HasKey("ReorderId"); @@ -185,13 +374,50 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("item_reorders", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => { - b.Property("SequenceId") + b.Property("SequenceId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); b.Property("DocType") .IsRequired() @@ -199,8 +425,8 @@ namespace ERPCore.Infra.Persistence.Migrations .HasColumnType("character varying(10)") .HasColumnName("doc_type"); - b.Property("LastNumber") - .HasColumnType("bigint") + b.Property("LastNumber") + .HasColumnType("integer") .HasColumnName("last_number"); b.Property("Year") @@ -217,17 +443,17 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => { - b.Property("PoLineId") + b.Property("PoLineId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); - b.Property("ItemId") - .HasColumnType("bigint"); + b.Property("ItemId") + .HasColumnType("integer"); - b.Property("PoId") - .HasColumnType("bigint"); + b.Property("PoId") + .HasColumnType("integer"); b.Property("Qty") .HasPrecision(18, 4) @@ -245,11 +471,11 @@ namespace ERPCore.Infra.Persistence.Migrations .HasPrecision(18, 4) .HasColumnType("numeric(18,4)"); - b.Property("UomId") - .HasColumnType("bigint"); + b.Property("UomId") + .HasColumnType("integer"); - b.Property("WarehouseId") - .HasColumnType("bigint"); + b.Property("WarehouseId") + .HasColumnType("integer"); b.HasKey("PoLineId"); @@ -266,11 +492,11 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { - b.Property("PoId") + b.Property("PoId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); b.Property("ApprovalRequired") .HasColumnType("boolean"); @@ -278,16 +504,16 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("CreatedBy") - .HasColumnType("bigint"); + b.Property("CreatedBy") + .HasColumnType("integer"); b.Property("DocNo") .IsRequired() .HasMaxLength(30) .HasColumnType("character varying(30)"); - b.Property("RequisitionId") - .HasColumnType("bigint"); + b.Property("RequisitionId") + .HasColumnType("integer"); b.Property("RowVersion") .IsConcurrencyToken() @@ -303,8 +529,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); - b.Property("VendorId") - .HasColumnType("bigint"); + b.Property("VendorId") + .HasColumnType("integer"); b.HasKey("PoId"); @@ -322,13 +548,125 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("purchase_orders", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("integer"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => { - b.Property("RequisitionId") + b.Property("RequisitionId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -338,8 +676,8 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(30) .HasColumnType("character varying(30)"); - b.Property("RequestedBy") - .HasColumnType("bigint"); + b.Property("RequestedBy") + .HasColumnType("integer"); b.Property("Status") .IsRequired() @@ -360,14 +698,14 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => { - b.Property("ReqLineId") + b.Property("ReqLineId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); - b.Property("ItemId") - .HasColumnType("bigint"); + b.Property("ItemId") + .HasColumnType("integer"); b.Property("Qty") .HasPrecision(18, 4) @@ -376,8 +714,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.Property("RequiredBy") .HasColumnType("date"); - b.Property("RequisitionId") - .HasColumnType("bigint"); + b.Property("RequisitionId") + .HasColumnType("integer"); b.HasKey("ReqLineId"); @@ -390,11 +728,11 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => { - b.Property("RfqId") + b.Property("RfqId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); @@ -404,8 +742,8 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(30) .HasColumnType("character varying(30)"); - b.Property("RequisitionId") - .HasColumnType("bigint"); + b.Property("RequisitionId") + .HasColumnType("integer"); b.Property("Status") .IsRequired() @@ -424,21 +762,21 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => { - b.Property("RfqLineId") + b.Property("RfqLineId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); - b.Property("ItemId") - .HasColumnType("bigint"); + b.Property("ItemId") + .HasColumnType("integer"); b.Property("Qty") .HasPrecision(18, 4) .HasColumnType("numeric(18,4)"); - b.Property("RfqId") - .HasColumnType("bigint"); + b.Property("RfqId") + .HasColumnType("integer"); b.HasKey("RfqLineId"); @@ -449,13 +787,465 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("rfq_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => { - b.Property("UomId") + b.Property("UomId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); b.Property("Name") .IsRequired() @@ -472,24 +1262,24 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => { - b.Property("ConversionId") + b.Property("ConversionId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); b.Property("Factor") .HasPrecision(18, 6) .HasColumnType("numeric(18,6)"); - b.Property("FromUomId") - .HasColumnType("bigint"); + b.Property("FromUomId") + .HasColumnType("integer"); - b.Property("ItemId") - .HasColumnType("bigint"); + b.Property("ItemId") + .HasColumnType("integer"); - b.Property("ToUomId") - .HasColumnType("bigint"); + b.Property("ToUomId") + .HasColumnType("integer"); b.HasKey("ConversionId"); @@ -505,11 +1295,15 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.User", b => { - b.Property("UserId") + b.Property("UserId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); b.Property("DisplayName") .IsRequired() @@ -528,6 +1322,9 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasKey("UserId"); + b.HasIndex("AuthUserId") + .IsUnique(); + b.HasIndex("Username") .IsUnique(); @@ -536,7 +1333,7 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasData( new { - UserId = 1L, + UserId = 1, DisplayName = "System", Status = "Active", Username = "system" @@ -545,11 +1342,11 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => { - b.Property("VendorId") + b.Property("VendorId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); b.Property("Code") .IsRequired() @@ -607,20 +1404,20 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => { - b.Property("QuotationId") + b.Property("QuotationId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); - b.Property("RfqId") - .HasColumnType("bigint"); + b.Property("RfqId") + .HasColumnType("integer"); - b.Property("VendorId") - .HasColumnType("bigint"); + b.Property("VendorId") + .HasColumnType("integer"); b.HasKey("QuotationId"); @@ -634,20 +1431,20 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => { - b.Property("QuotationLineId") + b.Property("QuotationLineId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); - b.Property("ItemId") - .HasColumnType("bigint"); + b.Property("ItemId") + .HasColumnType("integer"); b.Property("LeadDays") .HasColumnType("integer"); - b.Property("QuotationId") - .HasColumnType("bigint"); + b.Property("QuotationId") + .HasColumnType("integer"); b.Property("UnitPrice") .HasPrecision(18, 4) @@ -664,11 +1461,11 @@ namespace ERPCore.Infra.Persistence.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => { - b.Property("WarehouseId") + b.Property("WarehouseId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); b.Property("Code") .IsRequired() @@ -688,6 +1485,26 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("warehouses", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => { b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") @@ -709,6 +1526,88 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Parent"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") @@ -815,6 +1714,67 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Vendor"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => { b.HasOne("ERPCore.Domain.Entities.User", "Requester") @@ -875,6 +1835,263 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Rfq"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") @@ -945,6 +2162,11 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Children"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => { b.Navigation("ReorderSettings"); @@ -957,6 +2179,11 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => { b.Navigation("Lines"); @@ -969,6 +2196,21 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Quotations"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => { b.Navigation("Lines"); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 73b5f1a..635ec52 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -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(o => builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); -// 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 `int` id as `nameid`. builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Unit of work + generic repository base builder.Services.AddScoped(); @@ -54,6 +58,23 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Stock core + goods receipt (docs/11 §4–5) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Stock transactions + reference data (docs/11 §5–6) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13) +builder.Services.AddScoped(); + // Health checks (EF Core DB) builder.Services.AddHealthChecks().AddDbContextCheck(); @@ -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(); + await DataSeeder.SeedAsync(db); +} + app.UseSerilogRequestLogging(); if (app.Environment.IsDevelopment()) diff --git a/Backend/ERPCore/Properties/launchSettings.json b/Backend/ERPCore/Properties/launchSettings.json index 3d53c17..05cca77 100644 --- a/Backend/ERPCore/Properties/launchSettings.json +++ b/Backend/ERPCore/Properties/launchSettings.json @@ -4,7 +4,8 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, + "launchUrl": "swagger", "applicationUrl": "http://localhost:5224", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -13,7 +14,8 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": false, + "launchBrowser": true, + "launchUrl": "swagger", "applicationUrl": "https://localhost:7112;http://localhost:5224", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs new file mode 100644 index 0000000..53604f7 --- /dev/null +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -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; + +/// +/// 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 +/// . Runs in a single UoW transaction (NFR-02/05). +/// +public sealed class AdjustmentService : IAdjustmentService +{ + private readonly IRepository _adjustments; + private readonly IRepository _warehouses; + private readonly IRepository _items; + private readonly IRepository _reasonCodes; + private readonly IStockMutator _mutator; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public AdjustmentService( + IRepository adjustments, IRepository warehouses, IRepository items, + IRepository 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 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()); + } +} diff --git a/Backend/ERPCore/Services/AuditService.cs b/Backend/ERPCore/Services/AuditService.cs new file mode 100644 index 0000000..7492591 --- /dev/null +++ b/Backend/ERPCore/Services/AuditService.cs @@ -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 _logs; + private readonly IRepository _journal; + + public AuditService(IRepository logs, IRepository journal) + { + _logs = logs; + _journal = journal; + } + + public async Task> ListLogsAsync( + string? entityType, int? entityId, int? 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(l.ChangeSet), l.CreatedAt)).ToList(); + + return PagedResponse.Create(dtos, query.Page, query.PageSize, total); + } + + public async Task> ListJournalAsync( + string? sourceDocType, int? 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.Create(rows, query.Page, query.PageSize, total); + } +} diff --git a/Backend/ERPCore/Services/CategoryService.cs b/Backend/ERPCore/Services/CategoryService.cs index 178aa36..ca0f05d 100644 --- a/Backend/ERPCore/Services/CategoryService.cs +++ b/Backend/ERPCore/Services/CategoryService.cs @@ -47,7 +47,7 @@ public sealed class CategoryService : ICategoryService var byParent = all.ToLookup(c => c.ParentId); - List Build(long? parentId) => + List Build(int? parentId) => byParent[parentId] .Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId))) .ToList(); diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs new file mode 100644 index 0000000..1c62ecb --- /dev/null +++ b/Backend/ERPCore/Services/CountService.cs @@ -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; + +/// +/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable, +/// 02-SECURITY C.7); posting emits a variance via the +/// shared (a variance is an adjustment in disguise, C.7) +/// and closes the count — all in one UoW transaction. +/// +public sealed class CountService : ICountService +{ + private const string VarianceReasonCode = "VAR"; + + private readonly IRepository _counts; + private readonly IRepository _adjustments; + private readonly IRepository _warehouses; + private readonly IRepository _items; + private readonly IRepository _reasonCodes; + private readonly IFifoCostingService _fifo; + private readonly IStockMutator _mutator; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public CountService( + IRepository counts, IRepository adjustments, IRepository warehouses, + IRepository items, IRepository 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 GetAsync(int 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 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(); + 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 EnterCountsAsync(int 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 PostAsync(int 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()); + } + + 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()); +} diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs new file mode 100644 index 0000000..891163f --- /dev/null +++ b/Backend/ERPCore/Services/GrnService.cs @@ -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; + +/// +/// 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). +/// +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 _grns; + private readonly IRepository _pos; + private readonly IRepository _poLines; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly IRepository _bins; + private readonly IRepository _vendors; + private readonly IRepository _batches; + private readonly IRepository _conversions; + private readonly IRepository _layers; + private readonly IRepository _ledger; + private readonly IFifoCostingService _fifo; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public GrnService( + IRepository grns, IRepository pos, IRepository poLines, + IRepository items, IRepository uoms, IRepository warehouses, + IRepository bins, IRepository vendors, IRepository batches, + IRepository conversions, IRepository layers, IRepository 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 GetAsync(int 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 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; + int 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(); + var batchCache = new Dictionary<(int 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 ConfirmAsync(int 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<(int, int), decimal>(); + var createdLayers = new List(); + var ledgerRefs = new List(); + + 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 ReleaseLineAsync(int grnId, int 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 ResolveBatchAsync( + Item item, BatchInput? batch, Dictionary<(int, 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, int 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(int? 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 GetPoStatusAsync(int? 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 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()); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs b/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs new file mode 100644 index 0000000..c2f3c29 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAdjustmentService.cs @@ -0,0 +1,9 @@ +using ERPCore.Dtos.Stock; + +namespace ERPCore.Services.Interfaces; + +/// Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5). +public interface IAdjustmentService +{ + Task CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAuditService.cs b/Backend/ERPCore/Services/Interfaces/IAuditService.cs new file mode 100644 index 0000000..737aaeb --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAuditService.cs @@ -0,0 +1,17 @@ +using ERPCore.Dtos.Audit; +using ERPCore.Dtos.Common; + +namespace ERPCore.Services.Interfaces; + +/// +/// 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. +/// +public interface IAuditService +{ + Task> ListLogsAsync( + string? entityType, int? entityId, int? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default); + + Task> ListJournalAsync( + string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ICountService.cs b/Backend/ERPCore/Services/Interfaces/ICountService.cs new file mode 100644 index 0000000..667796e --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ICountService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Stock; + +namespace ERPCore.Services.Interfaces; + +/// Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08). +public interface ICountService +{ + Task GetAsync(int countId, CancellationToken ct = default); + Task CreateAsync(CreateCountRequest request, CancellationToken ct = default); + Task EnterCountsAsync(int countId, EnterCountsRequest request, CancellationToken ct = default); + Task PostAsync(int countId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs b/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs new file mode 100644 index 0000000..0f85c37 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Stock; + +namespace ERPCore.Services.Interfaces; + +/// +/// 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). +/// +public interface IFifoCostingService +{ + /// Create an inbound FIFO layer (qty and unit cost in the item's base UOM). + Task CreateInboundLayerAsync( + int itemId, int warehouseId, int? batchId, int? serialId, int? grnLineId, + decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default); + + /// + /// Consume from open layers oldest-first, row-locking + /// the affected layers for the transaction (NFR-02). Skips on-hold and expired + /// stock. Throws STOCK_NEGATIVE_BLOCKED if issuable stock is insufficient, + /// EXPIRED_BATCH_BLOCKED/ONHOLD_NOT_ISSUABLE 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. + /// + Task> ConsumeAsync( + int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default); + + /// Append an immutable ledger entry (value = qtyBase × unitCost). + Task PostLedgerAsync( + int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId, + Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, + string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default); + + /// Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse. + Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default); + + /// Valuation over open layers: Σ(qtyRemaining × unitCost) (FR-STK-04). + Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default); +} + +/// A quantity consumed from one FIFO layer at that layer's unit cost. +public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost); diff --git a/Backend/ERPCore/Services/Interfaces/IGrnService.cs b/Backend/ERPCore/Services/Interfaces/IGrnService.cs new file mode 100644 index 0000000..92c0dc7 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IGrnService.cs @@ -0,0 +1,15 @@ +using ERPCore.Dtos.Grn; + +namespace ERPCore.Services.Interfaces; + +/// Goods-receipt business logic (docs/11 §4; FR-GRN-01..08). +public interface IGrnService +{ + Task GetAsync(int grnId, CancellationToken ct = default); + Task CreateAsync(CreateGrnRequest request, CancellationToken ct = default); + + /// Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically. + Task ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default); + + Task ReleaseLineAsync(int grnId, int grnLineId, string action, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IItemService.cs b/Backend/ERPCore/Services/Interfaces/IItemService.cs index 662db49..b405f27 100644 --- a/Backend/ERPCore/Services/Interfaces/IItemService.cs +++ b/Backend/ERPCore/Services/Interfaces/IItemService.cs @@ -12,17 +12,17 @@ namespace ERPCore.Services.Interfaces; public interface IItemService { Task> ListAsync( - PageQuery query, EntityStatus? status, long? categoryId, TrackingMode? trackingMode, CancellationToken ct = default); + PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default); - Task?> GetAsync(long itemId, CancellationToken ct = default); + Task?> GetAsync(int itemId, CancellationToken ct = default); Task> CreateAsync(CreateItemRequest request, CancellationToken ct = default); - Task> UpdateAsync(long itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task> UpdateAsync(int itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default); - Task SetStatusAsync(long itemId, EntityStatus status, CancellationToken ct = default); + Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default); - Task UpdateReorderAsync(long itemId, UpdateReorderRequest request, CancellationToken ct = default); + Task UpdateReorderAsync(int itemId, UpdateReorderRequest request, CancellationToken ct = default); - Task UpdateUomConversionsAsync(long itemId, UpdateUomConversionsRequest request, CancellationToken ct = default); + Task UpdateUomConversionsAsync(int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs index b430a03..898588b 100644 --- a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs @@ -9,11 +9,11 @@ namespace ERPCore.Services.Interfaces; public interface IPurchaseOrderService { Task> ListAsync( - PageQuery query, PurchaseOrderStatus? status, long? vendorId, CancellationToken ct = default); + PageQuery query, PurchaseOrderStatus? status, int? vendorId, CancellationToken ct = default); - Task?> GetAsync(long poId, CancellationToken ct = default); + Task?> GetAsync(int poId, CancellationToken ct = default); Task> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default); - Task> UpdateAsync(long poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default); - Task ApproveAsync(long poId, CancellationToken ct = default); - Task CancelAsync(long poId, string? reason, CancellationToken ct = default); + Task> UpdateAsync(int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task ApproveAsync(int poId, CancellationToken ct = default); + Task CancelAsync(int poId, string? reason, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs new file mode 100644 index 0000000..8dc20b7 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseReturnService.cs @@ -0,0 +1,9 @@ +using ERPCore.Dtos.Procurement; + +namespace ERPCore.Services.Interfaces; + +/// Purchase-return business logic (docs/11 §3.4; FR-PROC-08). +public interface IPurchaseReturnService +{ + Task CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IReasonCodeService.cs b/Backend/ERPCore/Services/Interfaces/IReasonCodeService.cs new file mode 100644 index 0000000..41c8d14 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IReasonCodeService.cs @@ -0,0 +1,12 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Reference; + +namespace ERPCore.Services.Interfaces; + +/// Reason-code reference data (docs/11 §6; FR-X-04). +public interface IReasonCodeService +{ + Task> ListAsync(ReasonContext? context, PageQuery query, CancellationToken ct = default); + Task CreateAsync(CreateReasonCodeRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IReorderService.cs b/Backend/ERPCore/Services/Interfaces/IReorderService.cs new file mode 100644 index 0000000..f41258c --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IReorderService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; +using ERPCore.Dtos.Stock; + +namespace ERPCore.Services.Interfaces; + +/// Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10). +public interface IReorderService +{ + Task> GetAlertsAsync(int? warehouseId, PageQuery query, CancellationToken ct = default); + Task CreateSuggestedRequisitionAsync(int itemId, int warehouseId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs index 5c708db..309645d 100644 --- a/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs +++ b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs @@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces; public interface IRequisitionService { Task> ListAsync(PageQuery query, CancellationToken ct = default); - Task GetAsync(long requisitionId, CancellationToken ct = default); + Task GetAsync(int requisitionId, CancellationToken ct = default); Task CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default); - Task SubmitAsync(long requisitionId, CancellationToken ct = default); + Task SubmitAsync(int requisitionId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IRfqService.cs b/Backend/ERPCore/Services/Interfaces/IRfqService.cs index bdeef9a..9074dd1 100644 --- a/Backend/ERPCore/Services/Interfaces/IRfqService.cs +++ b/Backend/ERPCore/Services/Interfaces/IRfqService.cs @@ -5,8 +5,8 @@ namespace ERPCore.Services.Interfaces; /// RFQ & vendor-quotation business logic (docs/11 §3.2). public interface IRfqService { - Task GetAsync(long rfqId, CancellationToken ct = default); + Task GetAsync(int rfqId, CancellationToken ct = default); Task CreateAsync(CreateRfqRequest request, CancellationToken ct = default); - Task AddQuotationAsync(long rfqId, CreateQuotationRequest request, CancellationToken ct = default); - Task GetComparisonAsync(long rfqId, CancellationToken ct = default); + Task AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default); + Task GetComparisonAsync(int rfqId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IStockMutator.cs b/Backend/ERPCore/Services/Interfaces/IStockMutator.cs new file mode 100644 index 0000000..1614d19 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IStockMutator.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Services.Interfaces; + +/// A signed base-UOM change to an item's stock at one warehouse. +public sealed record StockDelta(int ItemId, int? BinId, int? BatchId, decimal QtyDelta); + +/// +/// 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 sourceDocId. +/// +public interface IStockMutator +{ + Task> ApplyAsync( + int warehouseId, string sourceDocType, int sourceDocId, DateTime now, + IReadOnlyList deltas, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IStockService.cs b/Backend/ERPCore/Services/Interfaces/IStockService.cs new file mode 100644 index 0000000..3fdaf78 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IStockService.cs @@ -0,0 +1,15 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; + +namespace ERPCore.Services.Interfaces; + +/// Read-side stock enquiry, ledger and valuation (docs/11 §5.1–5.3). +public interface IStockService +{ + Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default); + + Task> GetLedgerAsync( + int? itemId, int? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default); + + Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ITransferService.cs b/Backend/ERPCore/Services/Interfaces/ITransferService.cs new file mode 100644 index 0000000..44707e2 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ITransferService.cs @@ -0,0 +1,16 @@ +using ERPCore.Dtos.Stock; + +namespace ERPCore.Services.Interfaces; + +/// Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06). +public interface ITransferService +{ + Task GetAsync(int transferId, CancellationToken ct = default); + Task CreateAsync(CreateTransferRequest request, CancellationToken ct = default); + + /// Dispatch: consume source FIFO layers into in-transit (row-locked). + Task DispatchAsync(int transferId, CancellationToken ct = default); + + /// Receive: create the destination layer at the inherited (cost-preserving) cost. + Task ReceiveAsync(int transferId, ReceiveTransferRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IVendorService.cs b/Backend/ERPCore/Services/Interfaces/IVendorService.cs index 9d2c59b..bc78b41 100644 --- a/Backend/ERPCore/Services/Interfaces/IVendorService.cs +++ b/Backend/ERPCore/Services/Interfaces/IVendorService.cs @@ -9,8 +9,8 @@ namespace ERPCore.Services.Interfaces; public interface IVendorService { Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); - Task?> GetAsync(long vendorId, CancellationToken ct = default); + Task?> GetAsync(int vendorId, CancellationToken ct = default); Task> CreateAsync(CreateVendorRequest request, CancellationToken ct = default); - Task> UpdateAsync(long vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default); - Task SetStatusAsync(long vendorId, EntityStatus status, CancellationToken ct = default); + Task> UpdateAsync(int vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int vendorId, EntityStatus status, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/Interfaces/IWarehouseService.cs b/Backend/ERPCore/Services/Interfaces/IWarehouseService.cs index a1ab0ee..0e3b96b 100644 --- a/Backend/ERPCore/Services/Interfaces/IWarehouseService.cs +++ b/Backend/ERPCore/Services/Interfaces/IWarehouseService.cs @@ -7,9 +7,9 @@ namespace ERPCore.Services.Interfaces; public interface IWarehouseService { Task> ListAsync(PageQuery query, CancellationToken ct = default); - Task GetAsync(long warehouseId, CancellationToken ct = default); + Task GetAsync(int warehouseId, CancellationToken ct = default); Task CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default); - Task> ListBinsAsync(long warehouseId, CancellationToken ct = default); - Task CreateBinAsync(long warehouseId, CreateBinRequest request, CancellationToken ct = default); + Task> ListBinsAsync(int warehouseId, CancellationToken ct = default); + Task CreateBinAsync(int warehouseId, CreateBinRequest request, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index 487c65d..3a54e1c 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -42,7 +42,7 @@ public sealed class ItemService : IItemService } public async Task> ListAsync( - PageQuery query, EntityStatus? status, long? categoryId, TrackingMode? trackingMode, CancellationToken ct = default) + PageQuery query, EntityStatus? status, int? categoryId, TrackingMode? trackingMode, CancellationToken ct = default) { var q = _items.Query().AsNoTracking(); @@ -66,7 +66,7 @@ public sealed class ItemService : IItemService return PagedResponse.Create(rows, query.Page, query.PageSize, total); } - public async Task?> GetAsync(long itemId, CancellationToken ct = default) + public async Task?> GetAsync(int itemId, CancellationToken ct = default) { var item = await _items.Query().AsNoTracking() .Include(i => i.ReorderSettings) @@ -104,7 +104,7 @@ public sealed class ItemService : IItemService } public async Task> UpdateAsync( - long itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default) + int itemId, UpdateItemRequest request, uint expectedRowVersion, CancellationToken ct = default) { var item = await _items.Query() .Include(i => i.ReorderSettings) @@ -135,7 +135,7 @@ public sealed class ItemService : IItemService return new ETagged(ToDetail(item), item.RowVersion); } - public async Task SetStatusAsync(long itemId, EntityStatus status, CancellationToken ct = default) + public async Task SetStatusAsync(int itemId, EntityStatus status, CancellationToken ct = default) { var item = await _items.GetByIdAsync(itemId, ct) ?? throw new NotFoundException($"Item {itemId} was not found."); @@ -146,7 +146,7 @@ public sealed class ItemService : IItemService } public async Task UpdateReorderAsync( - long itemId, UpdateReorderRequest request, CancellationToken ct = default) + int itemId, UpdateReorderRequest request, CancellationToken ct = default) { if (request.Settings.Select(s => s.WarehouseId).Distinct().Count() != request.Settings.Count) throw new DomainException(ErrorCodes.Validation, "Duplicate warehouseId in reorder settings.", 400); @@ -194,7 +194,7 @@ public sealed class ItemService : IItemService } public async Task UpdateUomConversionsAsync( - long itemId, UpdateUomConversionsRequest request, CancellationToken ct = default) + int itemId, UpdateUomConversionsRequest request, CancellationToken ct = default) { var pairs = request.Conversions.Select(c => (c.FromUom, c.ToUom)).ToList(); if (pairs.Distinct().Count() != pairs.Count) @@ -240,7 +240,7 @@ public sealed class ItemService : IItemService return new ItemUomConversionsDto(item.ItemId, item.BaseUomId, conversions); } - private async Task ValidateReferencesAsync(long categoryId, long baseUomId, long? defaultVendorId, CancellationToken ct) + private async Task ValidateReferencesAsync(int categoryId, int baseUomId, int? defaultVendorId, CancellationToken ct) { if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct)) throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422); diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs index 9b74f93..cae9d35 100644 --- a/Backend/ERPCore/Services/PurchaseOrderService.cs +++ b/Backend/ERPCore/Services/PurchaseOrderService.cs @@ -49,7 +49,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService } public async Task> ListAsync( - PageQuery query, PurchaseOrderStatus? status, long? vendorId, CancellationToken ct = default) + PageQuery query, PurchaseOrderStatus? status, int? vendorId, CancellationToken ct = default) { var q = _pos.Query().AsNoTracking().Include(p => p.Lines).AsQueryable(); if (!string.IsNullOrWhiteSpace(query.Q)) @@ -71,7 +71,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService return PagedResponse.Create(summaries, query.Page, query.PageSize, total); } - public async Task?> GetAsync(long poId, CancellationToken ct = default) + public async Task?> GetAsync(int poId, CancellationToken ct = default) { var po = await _pos.Query().AsNoTracking() .Include(p => p.Lines) @@ -107,7 +107,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService } public async Task> UpdateAsync( - long poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default) + int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default) { var po = await _pos.Query() .Include(p => p.Lines) @@ -143,7 +143,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService return new ETagged(Map(po), po.RowVersion); } - public async Task ApproveAsync(long poId, CancellationToken ct = default) + public async Task ApproveAsync(int poId, CancellationToken ct = default) { var po = await _pos.Query() .Include(p => p.Lines) @@ -162,7 +162,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService return Map(po); } - public async Task CancelAsync(long poId, string? reason, CancellationToken ct = default) + public async Task CancelAsync(int poId, string? reason, CancellationToken ct = default) { var po = await _pos.Query() .Include(p => p.Lines) @@ -211,7 +211,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService } private async Task ValidateReferencesAsync( - long vendorId, long? requisitionId, IReadOnlyCollection lines, CancellationToken ct) + int vendorId, int? requisitionId, IReadOnlyCollection lines, CancellationToken ct) { var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); if (vendor is null) @@ -229,7 +229,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService } private static async Task EnsureAllExistAsync( - IQueryable keySource, IEnumerable requested, string label, CancellationToken ct) + IQueryable keySource, IEnumerable requested, string label, CancellationToken ct) { var ids = requested.Distinct().ToList(); var found = await keySource.Where(k => ids.Contains(k)).ToListAsync(ct); diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs new file mode 100644 index 0000000..4e0dd11 --- /dev/null +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -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; + +/// +/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason +/// code and generates an outbound stock movement via the shared +/// (FIFO consume, row-locked; over-return beyond +/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction. +/// +public sealed class PurchaseReturnService : IPurchaseReturnService +{ + private readonly IRepository _returns; + private readonly IRepository _vendors; + private readonly IRepository _warehouses; + private readonly IRepository _items; + private readonly IRepository _reasonCodes; + private readonly IRepository _grnLines; + private readonly IStockMutator _mutator; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public PurchaseReturnService( + IRepository returns, IRepository vendors, IRepository warehouses, + IRepository items, IRepository reasonCodes, IRepository 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 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()); + } +} diff --git a/Backend/ERPCore/Services/ReasonCodeService.cs b/Backend/ERPCore/Services/ReasonCodeService.cs new file mode 100644 index 0000000..3dfd342 --- /dev/null +++ b/Backend/ERPCore/Services/ReasonCodeService.cs @@ -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 _codes; + private readonly IUnitOfWork _uow; + + public ReasonCodeService(IRepository codes, IUnitOfWork uow) + { + _codes = codes; + _uow = uow; + } + + public async Task> 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.Create(rows, query.Page, query.PageSize, total); + } + + public async Task 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); + } +} diff --git a/Backend/ERPCore/Services/ReorderService.cs b/Backend/ERPCore/Services/ReorderService.cs new file mode 100644 index 0000000..83bc8b1 --- /dev/null +++ b/Backend/ERPCore/Services/ReorderService.cs @@ -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; + +/// +/// Reorder alerts are a query, not a stored entity (docs/10 C.9): available stock +/// (from the FIFO layers) is compared to policy on read. +/// +public sealed class ReorderService : IReorderService +{ + private readonly IRepository _reorders; + private readonly IStockService _stock; + private readonly IRequisitionService _requisitions; + + public ReorderService(IRepository reorders, IStockService stock, IRequisitionService requisitions) + { + _reorders = reorders; + _stock = stock; + _requisitions = requisitions; + } + + public async Task> GetAlertsAsync(int? 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(); + 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.Create(page, query.Page, query.PageSize, alerts.Count); + } + + public async Task CreateSuggestedRequisitionAsync(int itemId, int 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); + } +} diff --git a/Backend/ERPCore/Services/RequisitionService.cs b/Backend/ERPCore/Services/RequisitionService.cs index 08b88d6..8390609 100644 --- a/Backend/ERPCore/Services/RequisitionService.cs +++ b/Backend/ERPCore/Services/RequisitionService.cs @@ -49,7 +49,7 @@ public sealed class RequisitionService : IRequisitionService return PagedResponse.Create(rows, query.Page, query.PageSize, total); } - public async Task GetAsync(long requisitionId, CancellationToken ct = default) + public async Task GetAsync(int requisitionId, CancellationToken ct = default) { var req = await _requisitions.Query().AsNoTracking() .Include(r => r.Lines) @@ -85,7 +85,7 @@ public sealed class RequisitionService : IRequisitionService return Map(req); } - public async Task SubmitAsync(long requisitionId, CancellationToken ct = default) + public async Task SubmitAsync(int requisitionId, CancellationToken ct = default) { var req = await _requisitions.Query() .Include(r => r.Lines) @@ -101,7 +101,7 @@ public sealed class RequisitionService : IRequisitionService return Map(req); } - private async Task EnsureItemsExistAsync(IEnumerable itemIds, CancellationToken ct) + private async Task EnsureItemsExistAsync(IEnumerable itemIds, CancellationToken ct) { var ids = itemIds.Distinct().ToList(); var found = await _items.Query().AsNoTracking() diff --git a/Backend/ERPCore/Services/RfqService.cs b/Backend/ERPCore/Services/RfqService.cs index defcab6..b73db6e 100644 --- a/Backend/ERPCore/Services/RfqService.cs +++ b/Backend/ERPCore/Services/RfqService.cs @@ -34,7 +34,7 @@ public sealed class RfqService : IRfqService _uow = uow; } - public async Task GetAsync(long rfqId, CancellationToken ct = default) + public async Task GetAsync(int rfqId, CancellationToken ct = default) { var rfq = await _rfqs.Query().AsNoTracking() .Include(r => r.Lines) @@ -68,7 +68,7 @@ public sealed class RfqService : IRfqService return MapRfq(rfq); } - public async Task AddQuotationAsync(long rfqId, CreateQuotationRequest request, CancellationToken ct = default) + public async Task AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default) { var rfq = await _rfqs.Query().AsNoTracking() .Include(r => r.Lines) @@ -106,7 +106,7 @@ public sealed class RfqService : IRfqService quotation.Lines.Select(l => new QuotationLineDto(l.ItemId, l.UnitPrice, l.LeadDays)).ToList()); } - public async Task GetComparisonAsync(long rfqId, CancellationToken ct = default) + public async Task GetComparisonAsync(int rfqId, CancellationToken ct = default) { var rfq = await _rfqs.Query().AsNoTracking() .Include(r => r.Lines) @@ -130,7 +130,7 @@ public sealed class RfqService : IRfqService return new RfqComparisonDto(rfqId, vendorIds, rows); } - private async Task EnsureItemsExistAsync(IEnumerable itemIds, CancellationToken ct) + private async Task EnsureItemsExistAsync(IEnumerable itemIds, CancellationToken ct) { var ids = itemIds.Distinct().ToList(); var found = await _items.Query().AsNoTracking() @@ -140,7 +140,7 @@ public sealed class RfqService : IRfqService throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422); } - private async Task EnsureVendorsExistAsync(IEnumerable vendorIds, CancellationToken ct) + private async Task EnsureVendorsExistAsync(IEnumerable vendorIds, CancellationToken ct) { var ids = vendorIds.Distinct().ToList(); if (ids.Count == 0) return; diff --git a/Backend/ERPCore/Services/Stock/FifoCostingService.cs b/Backend/ERPCore/Services/Stock/FifoCostingService.cs new file mode 100644 index 0000000..5deadbc --- /dev/null +++ b/Backend/ERPCore/Services/Stock/FifoCostingService.cs @@ -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; + +/// +/// 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. +/// +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 _layers; + private readonly IRepository _ledger; + private readonly IRepository _journal; + private readonly ErpDbContext _db; + + public FifoCostingService( + IRepository layers, IRepository ledger, + IRepository journal, ErpDbContext db) + { + _layers = layers; + _ledger = ledger; + _journal = journal; + _db = db; + } + + public async Task CreateInboundLayerAsync( + int itemId, int warehouseId, int? batchId, int? serialId, int? 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> ConsumeAsync( + int itemId, int warehouseId, int? 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(); + 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 PostLedgerAsync( + int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId, + Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, + string sourceDocType, int 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 GetOnHandAsync(int itemId, int 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 GetValuationAsync(int itemId, int 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); + } +} diff --git a/Backend/ERPCore/Services/Stock/StockMutator.cs b/Backend/ERPCore/Services/Stock/StockMutator.cs new file mode 100644 index 0000000..7a1b9ae --- /dev/null +++ b/Backend/ERPCore/Services/Stock/StockMutator.cs @@ -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; + +/// +/// Applies signed stock deltas for a document and posts the ledger (see +/// ). 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. +/// +public sealed class StockMutator : IStockMutator +{ + private readonly IFifoCostingService _fifo; + private readonly IRepository _layers; + private readonly ICurrentUser _currentUser; + + public StockMutator(IFifoCostingService fifo, IRepository layers, ICurrentUser currentUser) + { + _fifo = fifo; + _layers = layers; + _currentUser = currentUser; + } + + public async Task> ApplyAsync( + int warehouseId, string sourceDocType, int sourceDocId, DateTime now, + IReadOnlyList deltas, CancellationToken ct = default) + { + var actor = _currentUser.AuditUserId; + var balances = new Dictionary(); + var entries = new List(); + + 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 LastCostAsync(int itemId, int 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; +} diff --git a/Backend/ERPCore/Services/Stock/StockService.cs b/Backend/ERPCore/Services/Stock/StockService.cs new file mode 100644 index 0000000..2eaedab --- /dev/null +++ b/Backend/ERPCore/Services/Stock/StockService.cs @@ -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 _layers; + private readonly IRepository _ledger; + private readonly IRepository _transferLines; + + public StockService( + IFifoCostingService fifo, IRepository layers, + IRepository ledger, IRepository transferLines) + { + _fifo = fifo; + _layers = layers; + _ledger = ledger; + _transferLines = transferLines; + } + + public async Task GetOnHandAsync(int itemId, int 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> GetLedgerAsync( + int? itemId, int? 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.Create(rows, query.Page, query.PageSize, total); + } + + public Task GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default) + => _fifo.GetValuationAsync(itemId, warehouseId, ct); +} diff --git a/Backend/ERPCore/Services/TransferService.cs b/Backend/ERPCore/Services/TransferService.cs new file mode 100644 index 0000000..6429811 --- /dev/null +++ b/Backend/ERPCore/Services/TransferService.cs @@ -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; + +/// +/// 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. +/// +public sealed class TransferService : ITransferService +{ + private readonly IRepository _transfers; + private readonly IRepository _warehouses; + private readonly IRepository _items; + private readonly IFifoCostingService _fifo; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public TransferService( + IRepository transfers, IRepository warehouses, IRepository 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 GetAsync(int 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 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 DispatchAsync(int 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(); + var ledgerRefs = new List(); + var balances = new Dictionary(); + + 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 ReceiveAsync(int 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(); + var ledgerRefs = new List(); + var balances = new Dictionary(); + + 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(int 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()); +} diff --git a/Backend/ERPCore/Services/VendorService.cs b/Backend/ERPCore/Services/VendorService.cs index 7c5277d..cbe0306 100644 --- a/Backend/ERPCore/Services/VendorService.cs +++ b/Backend/ERPCore/Services/VendorService.cs @@ -40,7 +40,7 @@ public sealed class VendorService : IVendorService return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); } - public async Task?> GetAsync(long vendorId, CancellationToken ct = default) + public async Task?> GetAsync(int vendorId, CancellationToken ct = default) { var vendor = await _vendors.Query().AsNoTracking() .FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); @@ -71,7 +71,7 @@ public sealed class VendorService : IVendorService } public async Task> UpdateAsync( - long vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default) + int vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default) { var vendor = await _vendors.GetByIdAsync(vendorId, ct) ?? throw new NotFoundException($"Vendor {vendorId} was not found."); @@ -103,7 +103,7 @@ public sealed class VendorService : IVendorService return new ETagged(Map(vendor), vendor.RowVersion); } - public async Task SetStatusAsync(long vendorId, EntityStatus status, CancellationToken ct = default) + public async Task SetStatusAsync(int vendorId, EntityStatus status, CancellationToken ct = default) { var vendor = await _vendors.GetByIdAsync(vendorId, ct) ?? throw new NotFoundException($"Vendor {vendorId} was not found."); diff --git a/Backend/ERPCore/Services/WarehouseService.cs b/Backend/ERPCore/Services/WarehouseService.cs index a77e82b..da10aae 100644 --- a/Backend/ERPCore/Services/WarehouseService.cs +++ b/Backend/ERPCore/Services/WarehouseService.cs @@ -40,7 +40,7 @@ public sealed class WarehouseService : IWarehouseService return PagedResponse.Create(rows, query.Page, query.PageSize, total); } - public async Task GetAsync(long warehouseId, CancellationToken ct = default) + public async Task GetAsync(int warehouseId, CancellationToken ct = default) { var w = await _warehouses.Query().AsNoTracking() .FirstOrDefaultAsync(x => x.WarehouseId == warehouseId, ct); @@ -60,7 +60,7 @@ public sealed class WarehouseService : IWarehouseService return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name); } - public async Task> ListBinsAsync(long warehouseId, CancellationToken ct = default) + public async Task> ListBinsAsync(int warehouseId, CancellationToken ct = default) { await EnsureWarehouseExistsAsync(warehouseId, ct); @@ -71,7 +71,7 @@ public sealed class WarehouseService : IWarehouseService .ToListAsync(ct); } - public async Task CreateBinAsync(long warehouseId, CreateBinRequest request, CancellationToken ct = default) + public async Task CreateBinAsync(int warehouseId, CreateBinRequest request, CancellationToken ct = default) { await EnsureWarehouseExistsAsync(warehouseId, ct); @@ -86,7 +86,7 @@ public sealed class WarehouseService : IWarehouseService return new BinDto(bin.BinId, bin.WarehouseId, bin.Code, bin.BinType); } - private async Task EnsureWarehouseExistsAsync(long warehouseId, CancellationToken ct) + private async Task EnsureWarehouseExistsAsync(int warehouseId, CancellationToken ct) { if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct)) throw new NotFoundException($"Warehouse {warehouseId} was not found."); diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index d8c99f7..3d13631 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -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" } } diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index 0b08225..d49cb2d 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -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": "1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==AQAB", + "RequiredUserTypeCode": "", + "RequiredRoleCode": "" }, "AllowedHosts": "*" } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 66d7ab5..7e710c6 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -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 `int` 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 `int` 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 `int` 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. diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index 8a1ce63..591e8cb 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -3,6 +3,7 @@ > **Authoritative for:** backend architecture, business rules, and the data model (the 38-entity schema). > **Navigation:** you arrived here from `00-CORE.md`. API request/response contracts are in `11-BACKEND-PHASE1.md`. Frontend rules are in `20-FRONTEND.md`. Record work in `Backend/PROGRESS.md`. > **Scope basis:** SRS v1.1. Costing = FIFO · Multi-warehouse · Single-tenant · RBAC deferred (user identity stamped) · approvals auto/config-gated · vendor invoice + 3-way match deferred to Accounting. +> **Authentication:** delegated to the **external AuthHex identity provider** (separate service). ERPCore is a **resource server** that only *validates* AuthHex's RS256 JWTs — it does not issue tokens or own a login endpoint. See A.4 (Authentication / Audit actor). RBAC (per-endpoint) still deferred. --- @@ -48,7 +49,8 @@ HTTP ─► Controller ─► Service ─► Repository ─► UnitOfWork / ErpD ## A.4 Cross-cutting - **Errors:** RFC 7807 `ProblemDetails` (framework default). Domain exceptions in `System/Errors` carry a stable `code`; a middleware maps them to `ProblemDetails`. Catalog in `11-BACKEND-PHASE1.md §7`. -- **Audit actor:** an `ICurrentUser` abstraction (`Infra/Auth`) resolves the acting user from the JWT `sub`. Services stamp mutations with it. **Never** trust a `createdBy` from the request body. +- **Authentication:** ERPCore is a **resource server**. It validates JWTs issued by the **external AuthHex IdP** — algorithm **RS256** (asymmetric RSA), issuer `AuthHex`, audience `AuthHexClient`. AuthHex exposes **no JWKS/OIDC discovery**, so ERPCore is configured with AuthHex's **RSA public key statically** (rotation is a manual config update). Tokens live ~1000 min (prod) / 60 min (dev). A single **door authorization policy** requires an ERP `UserTypeCode`/`RoleCode` claim (AuthHex is a shared IdP, so a valid token alone is not enough); **per-endpoint RBAC stays deferred**. +- **Audit actor:** the token carries no `sub`/`nameid`; identity is AuthHex's custom **`UserId` (GUID)** claim. An `ICurrentUser` abstraction (`Infra/Auth`) resolves the acting user from a **local shadow user** — the GUID is mapped (JIT-provisioned) to a local `int` `users.user_id` that all FKs reference (see C.7). Services stamp mutations with it. **Never** trust a `createdBy` from the request body. - **Concurrency:** mutable resources carry a `RowVersion` (`[Timestamp] byte[]`), surfaced as `ETag`; `PUT`/`PATCH` require `If-Match` → `412` on mismatch. - **Numbering:** document numbers come from `NumberSequence` (per doc type, per year), issued inside the same transaction as the document. @@ -56,6 +58,7 @@ HTTP ─► Controller ─► Service ─► Repository ─► UnitOfWork / ErpD - `ErpDbContext`: scoped (default). - `IUnitOfWork`, repositories, services, `ICurrentUser`, `FifoCostingService`: **scoped**. - Register in `Program.cs` (or an `AddApplication()` extension) after `AddDbContext`. +- **Auth wiring:** JWT bearer validation is built from AuthHex's **RSA public key** (config XML → `RsaSecurityKey`) with `ValidIssuer=AuthHex`, `ValidAudience=AuthHexClient`. A scoped **`IClaimsTransformation`** provisions/looks up the local shadow user (by `auth_user_id` = token `UserId` GUID) and injects the resolved local `int` id as a `ClaimTypes.NameIdentifier` (`nameid`) claim, so `ICurrentUser`/`AuditUserId` resolve a real user unchanged (falling back to the seeded system user only when unauthenticated). --- @@ -96,7 +99,7 @@ Foundation of a modular ERP. **Single-tenant**, **multi-warehouse**. All later m Maintain master data; raise/approve procurement through PO and return; receive goods with inspection hold; track stock movements in a costed FIFO ledger across warehouses; perform counts/transfers/adjustments with audit; track batch/expiry/serial and locate by bin; raise reorder alerts. ### B.2.3 User classes -> **Phase-1 note:** role-based permissions are **deferred** (FR-X-01). Phase 1 runs a **single operational user context** — any user may perform any action, but each action is stamped with the authenticated user's identity for audit. The roles below are the functional blueprint for future RBAC, **not** enforced boundaries. +> **Phase-1 note:** role-based permissions are **deferred** (FR-X-01). Identity is supplied by the **external AuthHex IdP** (see A.4); a single door policy admits only ERP `UserType`/`Role` holders, but beyond that any admitted user may perform any action, with each action stamped with the authenticated user's identity for audit. The roles below are the functional blueprint for future per-endpoint RBAC, **not** enforced boundaries. Storekeeper/Warehouse operator (receive, count, transfer, pick) · Procurement officer (requisitions, POs, vendors) · Approver/Manager (authorizes once approvals enabled) · Inventory controller (valuation, adjustments, reorder policy) · Auditor (read-only) · System administrator (users, numbering, config). @@ -180,7 +183,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users ### B.3.6 Cross-cutting (FR-X) | ID | Requirement | Pri | |---|---|---| -| FR-X-01 | **[Phase 1: user identity only]** Authenticate users and **stamp every transaction with the acting user's identity** for audit. Full RBAC (role→permission matrix, from which approvals derive) is **deferred**; reserve role/permission structures for no-migration enablement. | M | +| FR-X-01 | **[Phase 1: external IdP, user identity only]** Authentication is **delegated to the external AuthHex IdP** (ERPCore validates its RS256 tokens; no local login). ERPCore **provisions a local shadow user** (`auth_user_id` GUID → local `int`) and **stamps every transaction with the acting user's identity** for audit. AuthHex now also supplies `RoleCode`/`UserTypeCode` claims, used only for the door gate; full per-endpoint RBAC (role→permission matrix) remains **deferred** — reserve role/permission structures for no-migration enablement. | M | | FR-X-02 | Maintain an **immutable audit trail** for every create/update/delete and stock movement (who/when/old→new/reason). | M | | FR-X-03 | Generate **document numbers** from configurable sequences (per type, per year), unique and gap-controlled. | M | | FR-X-04 | Maintain configurable **reason-code** lists (adjustments, returns, count variances). | M | @@ -197,7 +200,7 @@ UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-f |---|---|---| | NFR-01 | Performance | Single item/warehouse enquiry + valuation < 2s under normal load; ledger posting transactional, < 1s per line. | | NFR-02 | Integrity | FIFO layer consumption atomic and concurrency-safe; no double-consumption of remaining qty. | -| NFR-03 | Security | Users authenticated; passwords hashed; every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) | +| NFR-03 | Security | Users authenticated via the external AuthHex IdP; **password hashing (BCrypt) is AuthHex's responsibility** — ERPCore validates tokens only. Every action attributed to a user and logged. (Role-based enforcement deferred, FR-X-01.) | | NFR-04 | Auditability | Audit trail immutable, retained per policy; ledger append-only. | | NFR-05 | Reliability | No stock transaction partially commits; full rollback on failure. | | NFR-06 | Scalability | Growth in items/warehouses/ledger without redesign; ledger indexed for time-series queries. | @@ -239,6 +242,7 @@ Adjustment: Damage, Theft/Loss, Count Variance, Expiry Write-off, System Correct | 7 | PO amendments | **Resolved:** Option B, edit-while-open | | 8 | Costing method | **Resolved:** FIFO | | 9 | Tenancy | **Resolved:** single-tenant | +| 10 | Authentication | **Resolved:** external **AuthHex** IdP (RS256; ERPCore validates only), **shadow-user** provisioning (`auth_user_id` GUID → local `int`), door-gated by ERP `UserType`/`Role`; per-endpoint RBAC deferred. *Open sub-item:* exact ERP `UserTypeCode`/`RoleCode` + RSA-key rotation process. | --- @@ -318,7 +322,7 @@ STOCK_COUNT_LINE(count_line_id PK, count_id FK→STOCK_COUNT, item_id FK→ITEM, ## C.7 Cross-cutting ``` -USER(user_id PK, username, display_name, status) +USER(user_id PK, username, display_name, status, auth_user_id [GUID, unique] → AuthHex identity) -- local shadow/projection of AuthHex users; user_id (int) is what all FKs reference REASON_CODE(reason_code_id PK, code, description, context) NUMBER_SEQUENCE(sequence_id PK, doc_type, year, last_number) AUDIT_LOG(audit_id PK, user_id FK→USER, entity_type, entity_id, action, change_set, created_at) @@ -338,7 +342,8 @@ ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION) - **Polymorphic source.** `STOCK_LEDGER.source_doc_type/source_doc_id` (and `AUDIT_LOG`, `JOURNAL_ENTRY_STUB`) reference the originating document without a hard FK per type — new transaction types (Sales, Manufacturing) write to the ledger without a schema change. - **In-transit + cost-preserving transfer.** `STOCK_TRANSFER` holds `src`/`dest` warehouse; dispatch consumes source layers into in-transit, receive creates the destination layer at the **inherited** source cost. - **FEFO ≠ FIFO.** FIFO governs *costing*; FEFO governs *physical picking* of perishables via `BATCH.expiry_date`. -- **Reserved RBAC.** Role/Permission/UserRole/RolePermission exist for schema-completeness only; only `USER` is live (audit stamp). +- **External IdP + shadow user.** Authentication is delegated to **AuthHex** (RS256, issuer `AuthHex`/audience `AuthHexClient`, static public key). `USER` is a **local shadow** of AuthHex identities: `auth_user_id` (GUID from the token's `UserId` claim) is JIT-mapped to the local `int` `user_id` that every `created_by`/`requested_by`/`AUDIT_LOG.user_id`/`STOCK_LEDGER.user_id` FK references — no FK type change. A door policy admits only ERP `UserType`/`Role` holders. +- **Reserved RBAC.** Role/Permission/UserRole/RolePermission exist for schema-completeness only; only `USER` is live (audit stamp). AuthHex's `RoleCode`/`UserTypeCode` claims drive the door gate today; per-endpoint RBAC is future work. - **Reorder alerts are a query**, not an entity — computed from `ITEM_REORDER` vs available. Add a table only if alert history is required. ## C.10 Entity → implementation mapping diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index 240ed7d..8d1a94d 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -18,10 +18,9 @@ Path-based versioning. Breaking changes bump the major version. ``` Authorization: Bearer ``` -- Every endpoint requires a valid **Bearer JWT**; unauthenticated → `401`. -- **RBAC is NOT enforced in Phase 1** (FR-X-01): any authenticated user may call any endpoint. -- The token subject (`sub`) is the **audit actor** on every mutation. Clients never send `createdBy`; the server derives it. -- Tokens are issued by a simple in-app login (`/auth/login`, §2.0). +- Every endpoint requires a valid **Bearer JWT**; unauthenticated → `401`. Tokens are issued by the **external AuthHex IdP** (not ERPCore) — **RS256**, issuer `AuthHex`, audience `AuthHexClient`. ERPCore validates them against AuthHex's static RSA public key (no JWKS) and admits only holders of the configured ERP `UserType`/`Role` (door policy) → otherwise `403`. +- **Per-endpoint RBAC is NOT enforced in Phase 1** (FR-X-01): any ERP-admitted user may call any endpoint. +- The **audit actor** is AuthHex's custom **`UserId` (GUID)** claim, mapped to a local shadow user (`int`). Clients never send `createdBy`; the server derives it (docs/10 A.4). ### 1.3 Content type & encoding `application/json`, UTF-8, **camelCase**. Timestamps ISO 8601 UTC (`2026-07-07T09:30:00Z`); dates `YYYY-MM-DD`. Base currency **LKR** in Phase 1. @@ -60,16 +59,15 @@ Domain errors add a stable `code` (catalog §7): ## 2. Master Data -### 2.0 Auth -#### `POST /auth/login` +### 2.0 Auth — **external (AuthHex IdP); not an ERPCore endpoint** +> **Superseded (2026-07-14).** ERPCore no longer exposes `/auth/login`. Login, registration and recovery are owned by the +> separate **AuthHex** service (e.g. `POST /api/loginUser` with `{ identifier, password }`), which returns an **RS256** JWT +> (issuer `AuthHex`, audience `AuthHexClient`; claims `UserId` (GUID), `UserTypeCode`, `RoleCode`, `NIC`, …). ERPCore only +> **validates** that Bearer token and provisions a local shadow user (docs/10 A.4). The old shape is retained here for history: ```json -{ "username": "storekeeper01", "password": "••••••••" } +POST {AuthHex}/api/loginUser → { "identifier": "…", "password": "••••••••" } +// 200 → an RS256 access token (Bearer). Bad credentials → 401. Token → ERPCore Authorization: Bearer . ``` -**200 OK** -```json -{ "accessToken": "eyJhbGciOi...", "tokenType": "Bearer", "expiresInMinutes": 120, "userId": 17, "displayName": "Nimal Perera" } -``` -`401` on bad credentials. ### 2.1 Items #### `GET /items`