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 new file mode 100644 index 0000000..a1bf9b9 --- /dev/null +++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs @@ -0,0 +1,74 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Purchase-order endpoints (docs/11 §3.3). +[Route("api/v1/purchase-orders")] +public sealed class PurchaseOrdersController : ApiControllerBase +{ + private readonly IPurchaseOrderService _pos; + + public PurchaseOrdersController(IPurchaseOrderService pos) => _pos = pos; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct) + => Ok(await _pos.ListAsync(query, status, vendorId, ct)); + + [HttpGet("{poId:int}")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int poId, CancellationToken ct) + { + var result = await _pos.GetAsync(poId, ct); + if (result is null) return NotFound(); + + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side. + [HttpPost] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreatePurchaseOrderRequest request, CancellationToken ct) + { + var result = await _pos.CreateAsync(request, ct); + SetETag(result.RowVersion); + return Created($"/api/v1/purchase-orders/{result.Value.PoId}", result.Value); + } + + /// Edit while open (FR-PROC-05); requires If-Match. 409 PO_NOT_EDITABLE if closed. + [HttpPut("{poId:int}")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status412PreconditionFailed)] + public async Task> Update(int poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct) + { + var expected = RequireIfMatch(); + var result = await _pos.UpdateAsync(poId, request, expected, ct); + SetETag(result.RowVersion); + return Ok(result.Value); + } + + /// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled. + [HttpPost("{poId:int}/approve")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + 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:int}/cancel")] + [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + 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 new file mode 100644 index 0000000..ab849b9 --- /dev/null +++ b/Backend/ERPCore/Controllers/RequisitionsController.cs @@ -0,0 +1,44 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Purchase-requisition endpoints (docs/11 §3.1). +[Route("api/v1/requisitions")] +public sealed class RequisitionsController : ApiControllerBase +{ + private readonly IRequisitionService _requisitions; + + public RequisitionsController(IRequisitionService requisitions) => _requisitions = requisitions; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List([FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _requisitions.ListAsync(query, ct)); + + [HttpGet("{requisitionId:int}")] + [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int requisitionId, CancellationToken ct) + { + var dto = await _requisitions.GetAsync(requisitionId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateRequisitionRequest request, CancellationToken ct) + { + var dto = await _requisitions.CreateAsync(request, ct); + return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto); + } + + [HttpPost("{requisitionId:int}/submit")] + [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + 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 new file mode 100644 index 0000000..7cc0e1f --- /dev/null +++ b/Backend/ERPCore/Controllers/RfqsController.cs @@ -0,0 +1,49 @@ +using ERPCore.Dtos.Procurement; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// RFQ & quotation endpoints (docs/11 §3.2). +[Route("api/v1/rfqs")] +public sealed class RfqsController : ApiControllerBase +{ + private readonly IRfqService _rfqs; + + public RfqsController(IRfqService rfqs) => _rfqs = rfqs; + + [HttpGet("{rfqId:int}")] + [ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(int rfqId, CancellationToken ct) + { + var dto = await _rfqs.GetAsync(rfqId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + [ProducesResponseType(typeof(RfqDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Create([FromBody] CreateRfqRequest request, CancellationToken ct) + { + var dto = await _rfqs.CreateAsync(request, ct); + return Created($"/api/v1/rfqs/{dto.RfqId}", dto); + } + + [HttpPost("{rfqId:int}/quotations")] + [ProducesResponseType(typeof(VendorQuotationDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + 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:int}/comparison")] + [ProducesResponseType(typeof(RfqComparisonDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + 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/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs new file mode 100644 index 0000000..0a84d94 --- /dev/null +++ b/Backend/ERPCore/Domain/DocumentTypes.cs @@ -0,0 +1,17 @@ +namespace ERPCore.Domain; + +/// +/// Document-type prefixes for and the +/// generated document numbers (docs/10 §B.8.2). One prefix per numbered document. +/// +public static class DocumentTypes +{ + public const string Requisition = "PR"; + public const string Rfq = "RFQ"; + public const string PurchaseOrder = "PO"; + public const string Grn = "GRN"; + public const string Transfer = "TRF"; + public const string Adjustment = "ADJ"; + public const string Count = "CNT"; + public const string PurchaseReturn = "PRET"; +} 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 new file mode 100644 index 0000000..c4f7ffc --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/NumberSequence.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Per-document-type, per-year running counter behind human document numbers +/// (FR-X-03): PR-2026-00001, PO-2026-00042, … Numbers are issued +/// inside the document's transaction so they are unique and gap-controlled. +/// Model: docs/10 Part C.7. +/// +public class NumberSequence +{ + public int SequenceId { get; set; } + public string DocType { get; set; } = string.Empty; + public int Year { get; set; } + public int LastNumber { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs new file mode 100644 index 0000000..1f38169 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PoLine.cs @@ -0,0 +1,28 @@ +namespace ERPCore.Domain.Entities; + +/// +/// Purchase-order line (FR-PROC-03). is the line tax rate +/// (e.g. 0.18); accrues as GRNs confirm (FR-PROC-07). +/// Model: docs/10 Part C.2. +/// +public class PoLine +{ + public int PoLineId { get; set; } + + public int PoId { get; set; } + public PurchaseOrder? PurchaseOrder { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public int UomId { get; set; } + public Uom? Uom { get; set; } + + public int WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public decimal Qty { get; set; } + public decimal UnitPrice { get; set; } + public decimal Tax { get; set; } + public decimal QtyReceived { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs new file mode 100644 index 0000000..a94c0a1 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/PurchaseOrder.cs @@ -0,0 +1,36 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Purchase order header (FR-PROC-03..06). Mutable aggregate with a +/// ETag token; editable while open (FR-PROC-05). +/// Phase 1 auto-approves on creation; is retained +/// for the future approval workflow. Totals are computed server-side from lines +/// (not stored). Model: docs/10 Part C.2. +/// +public class PurchaseOrder +{ + public int PoId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public int VendorId { get; set; } + public Vendor? Vendor { 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 int CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + /// PostgreSQL xmin-backed optimistic concurrency token (ETag source). + public uint RowVersion { get; set; } + + public ICollection Lines { get; set; } = new List(); +} 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 new file mode 100644 index 0000000..29103ca --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Requisition.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Purchase requisition header (FR-PROC-01). is the audit +/// actor from the token (never the body). Model: docs/10 Part C.2. +/// +public class Requisition +{ + public int RequisitionId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public int RequestedBy { get; set; } + public User? Requester { get; set; } + + public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft; + public DateTime CreatedAt { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/RequisitionLine.cs b/Backend/ERPCore/Domain/Entities/RequisitionLine.cs new file mode 100644 index 0000000..ff1bd80 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/RequisitionLine.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Entities; + +/// Requisition line (FR-PROC-01). Model: docs/10 Part C.2. +public class RequisitionLine +{ + public int ReqLineId { get; set; } + + public int RequisitionId { get; set; } + public Requisition? Requisition { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public decimal Qty { get; set; } + public DateOnly? RequiredBy { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Rfq.cs b/Backend/ERPCore/Domain/Entities/Rfq.cs new file mode 100644 index 0000000..5a4e5c1 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/Rfq.cs @@ -0,0 +1,22 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Request for Quotation header (FR-PROC-02) raised from a requisition. Vendor +/// quotations attach for comparison. Model: docs/10 Part C.2. +/// +public class Rfq +{ + public int RfqId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public int RequisitionId { get; set; } + public Requisition? Requisition { get; set; } + + public RfqStatus Status { get; set; } = RfqStatus.Open; + public DateTime CreatedAt { get; set; } + + public ICollection Lines { get; set; } = new List(); + public ICollection Quotations { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/RfqLine.cs b/Backend/ERPCore/Domain/Entities/RfqLine.cs new file mode 100644 index 0000000..9b73ca5 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/RfqLine.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Domain.Entities; + +/// RFQ line — an item + quantity being quoted (FR-PROC-02). Model: docs/10 Part C.2. +public class RfqLine +{ + public int RfqLineId { get; set; } + + public int RfqId { get; set; } + public Rfq? Rfq { 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 new file mode 100644 index 0000000..ddd0048 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/User.cs @@ -0,0 +1,25 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// 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 for unauthenticated/system operations. + public const int SystemUserId = 1; + + 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 new file mode 100644 index 0000000..f8ba021 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/VendorQuotation.cs @@ -0,0 +1,26 @@ +namespace ERPCore.Domain.Entities; + +/// +/// A vendor's quotation against an RFQ (FR-PROC-02). Per-item pricing lives in +/// . +/// +/// Deviation note: docs/10 Part C.2 models VENDOR_QUOTATION with scalar +/// unit_price/lead_days and no item reference, which cannot represent +/// the per-line pricing the API contract requires (docs/11 §3.2). This header + +/// split follows the authoritative API shape. +/// +/// +public class VendorQuotation +{ + public int QuotationId { get; set; } + + public int RfqId { get; set; } + public Rfq? Rfq { get; set; } + + public int VendorId { get; set; } + public Vendor? Vendor { get; set; } + + public DateTime CreatedAt { get; set; } + + public ICollection Lines { get; set; } = new List(); +} diff --git a/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs b/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs new file mode 100644 index 0000000..20e6a1f --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/VendorQuotationLine.cs @@ -0,0 +1,16 @@ +namespace ERPCore.Domain.Entities; + +/// Per-item quoted price and lead time within a (docs/11 §3.2). +public class VendorQuotationLine +{ + public int QuotationLineId { get; set; } + + public int QuotationId { get; set; } + public VendorQuotation? Quotation { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public decimal UnitPrice { get; set; } + public int LeadDays { 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/PurchaseOrderStatus.cs b/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs new file mode 100644 index 0000000..9cb7c49 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/PurchaseOrderStatus.cs @@ -0,0 +1,17 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Phase 1 auto-approves on +/// creation, so is reserved (not entered) until the +/// approval workflow is enabled (FR-PROC-04). Stored as a string. +/// +public enum PurchaseOrderStatus +{ + Draft, + PendingApproval, + Approved, + PartiallyReceived, + FullyReceived, + Closed, + Cancelled +} 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/RequisitionStatus.cs b/Backend/ERPCore/Domain/Enums/RequisitionStatus.cs new file mode 100644 index 0000000..8e9d65e --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/RequisitionStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Purchase-requisition lifecycle (docs/11 §3.1; docs/10 §B.8.1). Stored as a string. +public enum RequisitionStatus +{ + Draft, + Submitted +} 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/RfqStatus.cs b/Backend/ERPCore/Domain/Enums/RfqStatus.cs new file mode 100644 index 0000000..89c85b8 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/RfqStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// RFQ lifecycle (docs/11 §3.2). Stored as a string. +public enum RfqStatus +{ + Open, + Closed +} 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 new file mode 100644 index 0000000..c70e9a7 --- /dev/null +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Procurement; + +// Responses (docs/11 §3.3) ------------------------------------------------------ + +public sealed record PoLineDto( + 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( + 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( + 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 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; } +} + +public sealed class CreatePurchaseOrderRequest +{ + [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 int VendorId { get; set; } + public int? RequisitionId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class CancelPurchaseOrderRequest +{ + [StringLength(500)] public string? Reason { get; set; } +} 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 new file mode 100644 index 0000000..63b5e0a --- /dev/null +++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Procurement; + +// Responses (docs/11 §3.1) ------------------------------------------------------ + +public sealed record RequisitionLineDto(int ReqLineId, int ItemId, decimal Qty, DateOnly? RequiredBy); + +public sealed record RequisitionDto( + int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, + DateTime CreatedAt, IReadOnlyList Lines); + +public sealed record RequisitionSummaryDto( + 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 int ItemId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + public DateOnly? RequiredBy { get; set; } +} + +public sealed class CreateRequisitionRequest +{ + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs new file mode 100644 index 0000000..d54ab90 --- /dev/null +++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Procurement; + +// Responses (docs/11 §3.2) ------------------------------------------------------ + +public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty); + +public sealed record RfqDto( + int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList Lines); + +public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays); + +public sealed record VendorQuotationDto( + int QuotationId, int RfqId, int VendorId, IReadOnlyList Lines); + +/// Per-item, per-vendor price matrix for GET /rfqs/{id}/comparison. +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 int ItemId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } +} + +public sealed class CreateRfqRequest +{ + [Required] public int RequisitionId { get; set; } + /// Vendors the RFQ is issued to (validated for existence; quotations reference them). + public List VendorIds { get; set; } = new(); + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class CreateQuotationLineInput +{ + [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 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 780f200..9e0a0b1 100644 --- a/Backend/ERPCore/Infra/Auth/CurrentUser.cs +++ b/Backend/ERPCore/Infra/Auth/CurrentUser.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using ERPCore.Domain.Entities; namespace ERPCore.Infra.Auth; @@ -28,4 +29,6 @@ public sealed class CurrentUser : ICurrentUser return string.IsNullOrWhiteSpace(sub) ? SystemActor : sub; } } + + 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 ad7860d..1c103a2 100644 --- a/Backend/ERPCore/Infra/Auth/ICurrentUser.cs +++ b/Backend/ERPCore/Infra/Auth/ICurrentUser.cs @@ -10,6 +10,13 @@ public interface ICurrentUser /// The audit actor identity (token `sub`), or "system" when unauthenticated. string UserId { get; } + /// + /// Numeric audit actor for stamping document createdBy/requestedBy FKs. + /// Resolves the token sub to a user id; falls back to the seeded system + /// user () while auth is deferred (§6). + /// + 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/NumberSequenceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs new file mode 100644 index 0000000..45282a2 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/NumberSequenceConfiguration.cs @@ -0,0 +1,21 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class NumberSequenceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("number_sequences"); + builder.HasKey(s => s.SequenceId); + + builder.Property(s => s.DocType).HasColumnName("doc_type").IsRequired().HasMaxLength(10); + builder.Property(s => s.Year).HasColumnName("year").IsRequired(); + builder.Property(s => s.LastNumber).HasColumnName("last_number").IsRequired(); + + // One counter per (doc type, year); also the ON CONFLICT target for atomic issue. + builder.HasIndex(s => new { s.DocType, s.Year }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs new file mode 100644 index 0000000..8d452e0 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseOrderConfiguration.cs @@ -0,0 +1,76 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PurchaseOrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("purchase_orders"); + builder.HasKey(p => p.PoId); + + builder.Property(p => p.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(p => p.DocNo).IsUnique(); + + builder.Property(p => p.Status) + .HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(p => p.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(p => p.RowVersion).IsRowVersion(); + + builder.HasOne(p => p.Vendor) + .WithMany() + .HasForeignKey(p => p.VendorId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(p => p.Requisition) + .WithMany() + .HasForeignKey(p => p.RequisitionId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(p => p.Creator) + .WithMany() + .HasForeignKey(p => p.CreatedBy) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(p => p.Status); + builder.HasIndex(p => p.VendorId); + } +} + +public sealed class PoLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("po_lines"); + builder.HasKey(l => l.PoLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + builder.Property(l => l.UnitPrice).HasPrecision(18, 4); + builder.Property(l => l.Tax).HasPrecision(9, 4); + builder.Property(l => l.QtyReceived).HasPrecision(18, 4); + + builder.HasOne(l => l.PurchaseOrder) + .WithMany(p => p.Lines) + .HasForeignKey(l => l.PoId) + .OnDelete(DeleteBehavior.Cascade); + + 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.Warehouse) + .WithMany() + .HasForeignKey(l => l.WarehouseId) + .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/RequisitionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs new file mode 100644 index 0000000..492d786 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/RequisitionConfiguration.cs @@ -0,0 +1,49 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class RequisitionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("requisitions"); + builder.HasKey(r => r.RequisitionId); + + 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.Requester) + .WithMany() + .HasForeignKey(r => r.RequestedBy) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(r => r.Status); + } +} + +public sealed class RequisitionLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("requisition_lines"); + builder.HasKey(l => l.ReqLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + + builder.HasOne(l => l.Requisition) + .WithMany(r => r.Lines) + .HasForeignKey(l => l.RequisitionId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(l => l.Item) + .WithMany() + .HasForeignKey(l => l.ItemId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs new file mode 100644 index 0000000..6a95ef3 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/RfqConfiguration.cs @@ -0,0 +1,92 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class RfqConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("rfqs"); + builder.HasKey(r => r.RfqId); + + 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.Requisition) + .WithMany() + .HasForeignKey(r => r.RequisitionId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class RfqLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("rfq_lines"); + builder.HasKey(l => l.RfqLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + + builder.HasOne(l => l.Rfq) + .WithMany(r => r.Lines) + .HasForeignKey(l => l.RfqId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(l => l.Item) + .WithMany() + .HasForeignKey(l => l.ItemId) + .OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class VendorQuotationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("vendor_quotations"); + builder.HasKey(q => q.QuotationId); + + builder.Property(q => q.CreatedAt).IsRequired(); + + builder.HasOne(q => q.Rfq) + .WithMany(r => r.Quotations) + .HasForeignKey(q => q.RfqId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(q => q.Vendor) + .WithMany() + .HasForeignKey(q => q.VendorId) + .OnDelete(DeleteBehavior.Restrict); + + // One quotation per vendor per RFQ. + builder.HasIndex(q => new { q.RfqId, q.VendorId }).IsUnique(); + } +} + +public sealed class VendorQuotationLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("vendor_quotation_lines"); + builder.HasKey(l => l.QuotationLineId); + + builder.Property(l => l.UnitPrice).HasPrecision(18, 4); + + builder.HasOne(l => l.Quotation) + .WithMany(q => q.Lines) + .HasForeignKey(l => l.QuotationId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(l => l.Item) + .WithMany() + .HasForeignKey(l => l.ItemId) + .OnDelete(DeleteBehavior.Restrict); + } +} 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 new file mode 100644 index 0000000..eee8827 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,35 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + builder.HasKey(u => u.UserId); + + builder.Property(u => u.Username).IsRequired().HasMaxLength(100); + builder.HasIndex(u => u.Username).IsUnique(); + builder.Property(u => u.DisplayName).IsRequired().HasMaxLength(200); + 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 + { + UserId = User.SystemUserId, + Username = "system", + DisplayName = "System", + Status = EntityStatus.Active + }); + } +} 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 e55b00f..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) --- @@ -25,6 +31,51 @@ public class ErpDbContext : DbContext public DbSet Warehouses => Set(); public DbSet Bins => Set(); + // --- Cross-cutting (docs/10 Part C.7) --- + public DbSet Users => Set(); + public DbSet NumberSequences => Set(); + + // --- Procurement (docs/10 Part C.2) --- + public DbSet Requisitions => Set(); + public DbSet RequisitionLines => Set(); + public DbSet Rfqs => Set(); + public DbSet RfqLines => Set(); + public DbSet VendorQuotations => Set(); + public DbSet VendorQuotationLines => Set(); + 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); @@ -33,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/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 c608573..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,878 @@ 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") + .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") + b.Property("UomId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); b.Property("Name") .IsRequired() @@ -208,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"); @@ -239,13 +1293,60 @@ namespace ERPCore.Infra.Persistence.Migrations 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") + b.Property("VendorId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); b.Property("Code") .IsRequired() @@ -301,13 +1402,70 @@ namespace ERPCore.Infra.Persistence.Migrations 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") + b.Property("WarehouseId") .ValueGeneratedOnAdd() - .HasColumnType("bigint"); + .HasColumnType("integer"); - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); b.Property("Code") .IsRequired() @@ -327,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") @@ -348,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") @@ -393,6 +1653,445 @@ namespace ERPCore.Infra.Persistence.Migrations 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") @@ -420,11 +2119,54 @@ namespace ERPCore.Infra.Persistence.Migrations 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"); @@ -432,6 +2174,48 @@ namespace ERPCore.Infra.Persistence.Migrations 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"); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index ad5c3fc..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(); @@ -48,6 +52,29 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Cross-cutting + procurement services (docs/11 §3) +builder.Services.AddScoped(); +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(); @@ -57,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/INumberSequenceService.cs b/Backend/ERPCore/Services/Interfaces/INumberSequenceService.cs new file mode 100644 index 0000000..5be5af5 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/INumberSequenceService.cs @@ -0,0 +1,15 @@ +namespace ERPCore.Services.Interfaces; + +/// +/// Issues gap-controlled, per-year document numbers (FR-X-03). Call inside the +/// document's UoW transaction so the reserved number rolls back with the document +/// on failure. +/// +public interface INumberSequenceService +{ + /// + /// Reserve and return the next number for in the + /// current year, formatted e.g. PO-2026-00042. + /// + Task NextAsync(string docType, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs new file mode 100644 index 0000000..898588b --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPurchaseOrderService.cs @@ -0,0 +1,19 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; + +namespace ERPCore.Services.Interfaces; + +/// Purchase-order business logic (docs/11 §3.3; FR-PROC-03..07). +public interface IPurchaseOrderService +{ + Task> ListAsync( + PageQuery query, PurchaseOrderStatus? status, int? vendorId, CancellationToken ct = default); + + Task?> GetAsync(int poId, CancellationToken ct = default); + Task> CreateAsync(CreatePurchaseOrderRequest request, 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 new file mode 100644 index 0000000..309645d --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IRequisitionService.cs @@ -0,0 +1,13 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Procurement; + +namespace ERPCore.Services.Interfaces; + +/// Purchase-requisition business logic (docs/11 §3.1). +public interface IRequisitionService +{ + Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task GetAsync(int requisitionId, CancellationToken ct = default); + Task CreateAsync(CreateRequisitionRequest request, 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 new file mode 100644 index 0000000..9074dd1 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IRfqService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Procurement; + +namespace ERPCore.Services.Interfaces; + +/// RFQ & vendor-quotation business logic (docs/11 §3.2). +public interface IRfqService +{ + Task GetAsync(int rfqId, CancellationToken ct = default); + Task CreateAsync(CreateRfqRequest request, 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/NumberSequenceService.cs b/Backend/ERPCore/Services/NumberSequenceService.cs new file mode 100644 index 0000000..c48d349 --- /dev/null +++ b/Backend/ERPCore/Services/NumberSequenceService.cs @@ -0,0 +1,58 @@ +using System.Data; +using ERPCore.Infra.Persistence; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ERPCore.Services; + +/// +/// Atomic document-number issuer. Uses a single INSERT … ON CONFLICT … DO UPDATE +/// … RETURNING so concurrent issues for the same (docType, year) cannot get the +/// same number (the row is locked for the duration of the upsert). Runs as a direct +/// ADO.NET command enlisted in the DbContext's current transaction so it commits or +/// rolls back with the document (FR-X-03). EF's SqlQuery is avoided here +/// because it wraps the statement in a subquery, which PostgreSQL disallows for a +/// data-modifying statement. +/// +public sealed class NumberSequenceService : INumberSequenceService +{ + private readonly ErpDbContext _db; + + public NumberSequenceService(ErpDbContext db) => _db = db; + + public async Task NextAsync(string docType, CancellationToken ct = default) + { + var year = DateTime.UtcNow.Year; + + var conn = _db.Database.GetDbConnection(); + if (conn.State != ConnectionState.Open) + await conn.OpenAsync(ct); + + await using var cmd = conn.CreateCommand(); + cmd.Transaction = _db.Database.CurrentTransaction?.GetDbTransaction(); + cmd.CommandText = """ + INSERT INTO number_sequences (doc_type, year, last_number) + VALUES (@docType, @year, 1) + ON CONFLICT (doc_type, year) + DO UPDATE SET last_number = number_sequences.last_number + 1 + RETURNING last_number; + """; + AddParam(cmd, "docType", docType); + AddParam(cmd, "year", year); + + var result = await cmd.ExecuteScalarAsync(ct) + ?? throw new InvalidOperationException($"Failed to issue a document number for '{docType}'."); + var next = Convert.ToInt64(result); + + return $"{docType}-{year}-{next:D5}"; + } + + private static void AddParam(IDbCommand cmd, string name, object value) + { + var p = cmd.CreateParameter(); + p.ParameterName = name; + p.Value = value; + cmd.Parameters.Add(p); + } +} diff --git a/Backend/ERPCore/Services/PurchaseOrderService.cs b/Backend/ERPCore/Services/PurchaseOrderService.cs new file mode 100644 index 0000000..cae9d35 --- /dev/null +++ b/Backend/ERPCore/Services/PurchaseOrderService.cs @@ -0,0 +1,246 @@ +using ERPCore.Common.Http; +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +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-order service. Phase 1 auto-approves on creation (FR-PROC-04), +/// PO is freely editable while open (FR-PROC-05), totals are computed server-side +/// (02-SECURITY C.2), and cancel is blocked once any receipt exists. +/// +public sealed class PurchaseOrderService : IPurchaseOrderService +{ + private const string BaseCurrency = "LKR"; + + private readonly IRepository _pos; + private readonly IRepository _vendors; + private readonly IRepository _requisitions; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _warehouses; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public PurchaseOrderService( + IRepository pos, IRepository vendors, IRepository requisitions, + IRepository items, IRepository uoms, IRepository warehouses, + INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) + { + _pos = pos; + _vendors = vendors; + _requisitions = requisitions; + _items = items; + _uoms = uoms; + _warehouses = warehouses; + _numbers = numbers; + _currentUser = currentUser; + _uow = uow; + } + + public async Task> ListAsync( + PageQuery query, PurchaseOrderStatus? status, int? vendorId, CancellationToken ct = default) + { + var q = _pos.Query().AsNoTracking().Include(p => p.Lines).AsQueryable(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(p => EF.Functions.ILike(p.DocNo, $"%{term}%")); + } + if (status is not null) q = q.Where(p => p.Status == status); + if (vendorId is not null) q = q.Where(p => p.VendorId == vendorId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(p => p.PoId) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + var summaries = rows.Select(p => new PurchaseOrderSummaryDto( + p.PoId, p.DocNo, p.VendorId, p.Status, p.ApprovalRequired, p.CreatedAt, ComputeTotals(p.Lines))).ToList(); + + return PagedResponse.Create(summaries, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int poId, CancellationToken ct = default) + { + var po = await _pos.Query().AsNoTracking() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct); + return po is null ? null : new ETagged(Map(po), po.RowVersion); + } + + public async Task> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default) + { + await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct); + var actor = _currentUser.AuditUserId; + + var po = await _uow.ExecuteInTransactionAsync(async token => + { + var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseOrder, token); + var entity = new PurchaseOrder + { + DocNo = docNo, + VendorId = request.VendorId, + RequisitionId = request.RequisitionId, + // Phase 1: approvalRequired defaults off → auto-approved on creation (FR-PROC-04). + ApprovalRequired = false, + Status = PurchaseOrderStatus.Approved, + CreatedBy = actor, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(ToLine).ToList() + }; + await _pos.AddAsync(entity, token); + return entity; + }, ct); + + return new ETagged(Map(po), po.RowVersion); + } + + public async Task> UpdateAsync( + int poId, UpdatePurchaseOrderRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412); + + if (!IsEditable(po.Status)) + throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be edited.", 409); + + await ValidateReferencesAsync(request.VendorId, request.RequisitionId, request.Lines, ct); + + po.VendorId = request.VendorId; + po.RequisitionId = request.RequisitionId; + po.UpdatedAt = DateTime.UtcNow; + + // Full line replacement (Phase 1: no receipts yet, so qtyReceived is 0 on every line). + po.Lines.Clear(); + foreach (var input in request.Lines) + po.Lines.Add(ToLine(input)); + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412); + } + + return new ETagged(Map(po), po.RowVersion); + } + + public async Task ApproveAsync(int poId, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + // Phase 1 no-op: POs are already Approved on creation. Kept for the future + // approval workflow (PendingApproval → Approved) — FR-PROC-04. + if (po.Status == PurchaseOrderStatus.PendingApproval) + { + po.Status = PurchaseOrderStatus.Approved; + po.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + return Map(po); + } + + public async Task CancelAsync(int poId, string? reason, CancellationToken ct = default) + { + var po = await _pos.Query() + .Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == poId, ct) + ?? throw new NotFoundException($"Purchase order {poId} was not found."); + + if (po.Lines.Any(l => l.QtyReceived > 0)) + throw new ConflictException($"Purchase order {poId} cannot be cancelled because goods have been received against it."); + + if (po.Status != PurchaseOrderStatus.Cancelled) + { + po.Status = PurchaseOrderStatus.Cancelled; + po.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + return Map(po); + } + + private static bool IsEditable(PurchaseOrderStatus status) => status is not ( + PurchaseOrderStatus.FullyReceived or PurchaseOrderStatus.Closed or PurchaseOrderStatus.Cancelled); + + private static PoLine ToLine(CreatePoLineInput l) => new() + { + ItemId = l.ItemId, + UomId = l.UomId, + WarehouseId = l.WarehouseId, + Qty = l.Qty, + UnitPrice = l.UnitPrice, + Tax = l.Tax, + QtyReceived = 0 + }; + + private static PoTotalsDto ComputeTotals(IEnumerable lines) + { + decimal sub = 0, tax = 0; + foreach (var l in lines) + { + var net = l.Qty * l.UnitPrice; + sub += net; + tax += net * l.Tax; + } + sub = Math.Round(sub, 2, MidpointRounding.AwayFromZero); + tax = Math.Round(tax, 2, MidpointRounding.AwayFromZero); + return new PoTotalsDto(sub, tax, sub + tax, BaseCurrency); + } + + private async Task ValidateReferencesAsync( + int vendorId, int? requisitionId, IReadOnlyCollection lines, CancellationToken ct) + { + var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == vendorId, ct); + if (vendor is null) + throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} does not exist.", 422); + if (vendor.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.Validation, $"Vendor {vendorId} is inactive.", 422); + + if (requisitionId is not null + && !await _requisitions.Query().AnyAsync(r => r.RequisitionId == requisitionId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422); + + await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct); + await EnsureAllExistAsync(_uoms.Query().Select(u => u.UomId), lines.Select(l => l.UomId), "UOM", ct); + await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct); + } + + private static async Task EnsureAllExistAsync( + IQueryable keySource, IEnumerable requested, string label, CancellationToken ct) + { + var ids = requested.Distinct().ToList(); + var found = await keySource.Where(k => ids.Contains(k)).ToListAsync(ct); + var missing = ids.Except(found).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"{label}(s) not found: {string.Join(", ", missing)}.", 422); + } + + private static PurchaseOrderDto Map(PurchaseOrder p) => new( + p.PoId, p.DocNo, p.VendorId, p.RequisitionId, p.Status, p.ApprovalRequired, + p.CreatedBy, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines), + p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto( + l.PoLineId, l.ItemId, l.UomId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList()); +} 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 new file mode 100644 index 0000000..8390609 --- /dev/null +++ b/Backend/ERPCore/Services/RequisitionService.cs @@ -0,0 +1,119 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +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; + +public sealed class RequisitionService : IRequisitionService +{ + private readonly IRepository _requisitions; + private readonly IRepository _items; + private readonly INumberSequenceService _numbers; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + public RequisitionService( + IRepository requisitions, IRepository items, + INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) + { + _requisitions = requisitions; + _items = items; + _numbers = numbers; + _currentUser = currentUser; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + { + var q = _requisitions.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.RequisitionId) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new RequisitionSummaryDto(r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(int requisitionId, CancellationToken ct = default) + { + var req = await _requisitions.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.RequisitionId == requisitionId, ct); + return req is null ? null : Map(req); + } + + public async Task CreateAsync(CreateRequisitionRequest request, CancellationToken ct = default) + { + await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct); + var actor = _currentUser.AuditUserId; + + var req = await _uow.ExecuteInTransactionAsync(async token => + { + var docNo = await _numbers.NextAsync(DocumentTypes.Requisition, token); + var entity = new Requisition + { + DocNo = docNo, + RequestedBy = actor, + Status = RequisitionStatus.Draft, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(l => new RequisitionLine + { + ItemId = l.ItemId, + Qty = l.Qty, + RequiredBy = l.RequiredBy + }).ToList() + }; + await _requisitions.AddAsync(entity, token); + return entity; + }, ct); + + return Map(req); + } + + public async Task SubmitAsync(int requisitionId, CancellationToken ct = default) + { + var req = await _requisitions.Query() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.RequisitionId == requisitionId, ct) + ?? throw new NotFoundException($"Requisition {requisitionId} was not found."); + + if (req.Status != RequisitionStatus.Submitted) + { + req.Status = RequisitionStatus.Submitted; + await _uow.SaveChangesAsync(ct); + } + + return Map(req); + } + + private async Task EnsureItemsExistAsync(IEnumerable itemIds, CancellationToken ct) + { + var ids = itemIds.Distinct().ToList(); + var found = await _items.Query().AsNoTracking() + .Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct); + var missing = ids.Except(found).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422); + } + + private static RequisitionDto Map(Requisition r) => new( + r.RequisitionId, r.DocNo, r.Status, r.RequestedBy, r.CreatedAt, + r.Lines.OrderBy(l => l.ReqLineId) + .Select(l => new RequisitionLineDto(l.ReqLineId, l.ItemId, l.Qty, l.RequiredBy)) + .ToList()); +} diff --git a/Backend/ERPCore/Services/RfqService.cs b/Backend/ERPCore/Services/RfqService.cs new file mode 100644 index 0000000..b73db6e --- /dev/null +++ b/Backend/ERPCore/Services/RfqService.cs @@ -0,0 +1,157 @@ +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Procurement; +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 RfqService : IRfqService +{ + private readonly IRepository _rfqs; + private readonly IRepository _requisitions; + private readonly IRepository _items; + private readonly IRepository _vendors; + private readonly IRepository _quotations; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + + public RfqService( + IRepository rfqs, IRepository requisitions, IRepository items, + IRepository vendors, IRepository quotations, + INumberSequenceService numbers, IUnitOfWork uow) + { + _rfqs = rfqs; + _requisitions = requisitions; + _items = items; + _vendors = vendors; + _quotations = quotations; + _numbers = numbers; + _uow = uow; + } + + public async Task GetAsync(int rfqId, CancellationToken ct = default) + { + var rfq = await _rfqs.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.RfqId == rfqId, ct); + return rfq is null ? null : MapRfq(rfq); + } + + public async Task CreateAsync(CreateRfqRequest request, CancellationToken ct = default) + { + if (!await _requisitions.Query().AnyAsync(r => r.RequisitionId == request.RequisitionId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Requisition {request.RequisitionId} does not exist.", 422); + + await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct); + await EnsureVendorsExistAsync(request.VendorIds, ct); + + var rfq = await _uow.ExecuteInTransactionAsync(async token => + { + var docNo = await _numbers.NextAsync(DocumentTypes.Rfq, token); + var entity = new Rfq + { + DocNo = docNo, + RequisitionId = request.RequisitionId, + Status = RfqStatus.Open, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(l => new RfqLine { ItemId = l.ItemId, Qty = l.Qty }).ToList() + }; + await _rfqs.AddAsync(entity, token); + return entity; + }, ct); + + return MapRfq(rfq); + } + + public async Task AddQuotationAsync(int rfqId, CreateQuotationRequest request, CancellationToken ct = default) + { + var rfq = await _rfqs.Query().AsNoTracking() + .Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.RfqId == rfqId, ct) + ?? throw new NotFoundException($"RFQ {rfqId} was not found."); + + if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422); + + var rfqItemIds = rfq.Lines.Select(l => l.ItemId).ToHashSet(); + var offLine = request.Lines.Select(l => l.ItemId).FirstOrDefault(id => !rfqItemIds.Contains(id)); + if (offLine != 0) + throw new DomainException(ErrorCodes.Validation, $"Item {offLine} is not part of RFQ {rfqId}.", 422); + + if (await _quotations.Query().AnyAsync(q => q.RfqId == rfqId && q.VendorId == request.VendorId, ct)) + throw new ConflictException($"Vendor {request.VendorId} has already quoted RFQ {rfqId}."); + + var quotation = new VendorQuotation + { + RfqId = rfqId, + VendorId = request.VendorId, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(l => new VendorQuotationLine + { + ItemId = l.ItemId, + UnitPrice = l.UnitPrice, + LeadDays = l.LeadDays + }).ToList() + }; + await _quotations.AddAsync(quotation, ct); + await _uow.SaveChangesAsync(ct); + + return new VendorQuotationDto( + quotation.QuotationId, quotation.RfqId, quotation.VendorId, + quotation.Lines.Select(l => new QuotationLineDto(l.ItemId, l.UnitPrice, l.LeadDays)).ToList()); + } + + public async Task GetComparisonAsync(int rfqId, CancellationToken ct = default) + { + var rfq = await _rfqs.Query().AsNoTracking() + .Include(r => r.Lines) + .Include(r => r.Quotations).ThenInclude(q => q.Lines) + .FirstOrDefaultAsync(r => r.RfqId == rfqId, ct) + ?? throw new NotFoundException($"RFQ {rfqId} was not found."); + + var vendorIds = rfq.Quotations.Select(q => q.VendorId).Distinct().OrderBy(v => v).ToList(); + + var rows = rfq.Lines.OrderBy(l => l.RfqLineId).Select(line => + { + var cells = rfq.Quotations + .Select(q => new { q.VendorId, q.QuotationId, Line = q.Lines.FirstOrDefault(ql => ql.ItemId == line.ItemId) }) + .Where(x => x.Line is not null) + .OrderBy(x => x.VendorId) + .Select(x => new RfqComparisonCellDto(x.VendorId, x.QuotationId, x.Line!.UnitPrice, x.Line!.LeadDays)) + .ToList(); + return new RfqComparisonRowDto(line.ItemId, line.Qty, cells); + }).ToList(); + + return new RfqComparisonDto(rfqId, vendorIds, rows); + } + + private async Task EnsureItemsExistAsync(IEnumerable itemIds, CancellationToken ct) + { + var ids = itemIds.Distinct().ToList(); + var found = await _items.Query().AsNoTracking() + .Where(i => ids.Contains(i.ItemId)).Select(i => i.ItemId).ToListAsync(ct); + var missing = ids.Except(found).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item(s) not found: {string.Join(", ", missing)}.", 422); + } + + private async Task EnsureVendorsExistAsync(IEnumerable vendorIds, CancellationToken ct) + { + var ids = vendorIds.Distinct().ToList(); + if (ids.Count == 0) return; + var found = await _vendors.Query().AsNoTracking() + .Where(v => ids.Contains(v.VendorId)).Select(v => v.VendorId).ToListAsync(ct); + var missing = ids.Except(found).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Vendor(s) not found: {string.Join(", ", missing)}.", 422); + } + + private static RfqDto MapRfq(Rfq r) => new( + r.RfqId, r.DocNo, r.RequisitionId, r.Status, + r.Lines.OrderBy(l => l.RfqLineId).Select(l => new RfqLineDto(l.RfqLineId, l.ItemId, l.Qty)).ToList()); +} 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 627abc8..7e710c6 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -7,54 +7,62 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 0. Bootstrap - [x] Solution + Web API project (`net10.0`), packages restored (00-CORE §5.4) - [x] Folder structure per 00-CORE §5.3 -- [~] `ErpDbContext` + Npgsql wired; `InitialCreate` migration **created** (`Infra/Persistence/Migrations`, 8 master-data tables) — **not yet applied**: `dotnet ef database update` fails `28P01 password authentication failed for user "postgres"` (local Postgres is running on :5432 but the `postgres/postgres` dev creds in `appsettings.Development.json` don't match this box). Generated SQL script validates cleanly. **Action needed:** set the real local creds, then `ASPNETCORE_ENVIRONMENT=Development dotnet ef database update`. -- [x] Serilog, JWT, Swagger, HealthChecks, ProblemDetails in `Program.cs` (JWT bearer *validated*; endpoints not yet `[Authorize]`-gated — see §6 auth note) +- [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 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): entities, EF configs, DTOs, services, controllers — solution builds clean, app boots, and the generated OpenAPI exposes every path in `docs/11 §2`. Marked `[~]` (not `[x]`) because the **security gate** (00-CORE §8) is not yet fully met: the foundational auth control (02-SECURITY B.1) and the audit trail (B.3, the AR-01 compensating control) land in §6, and the schema is not yet applied to a DB. No live DB integration test has run (creds blocker above). Flip to `[x]` once §6 auth+audit are in and endpoints are exercised against Postgres. +> 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 (+ lines) + submit -- [ ] RFQ + quotations + comparison -- [ ] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open, approve (no-op), cancel -- [ ] Purchase Return (outbound movement, reason code) +> 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. +- [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) -- [ ] Document numbering sequences (per type, per year) -- [ ] Auth: simple in-app login → JWT (`POST /auth/login`) -- [ ] JournalEntryStub emitted per stock movement (data only) -- [ ] Negative-stock policy enforcement (default block) -- [ ] FEFO picking for perishables; block expired / on-hold issue +> **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. +- [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 @@ -70,4 +78,58 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - API: 5 controllers, lowercase routes matching `docs/11 §2` exactly (verified via generated `swagger.json`). ETag/If-Match (428 if missing, 412 on mismatch), narrow request DTOs (no over-posting), `PagedResponse` list envelope (§1.4), `PageQuery` with pageSize clamp ≤200 (B.6). - Migration `InitialCreate` generated (`xmin` correctly produces no DDL — uses the PG system column). - **Verified:** `dotnet build` clean (0 warn/0 err); app boots (`Now listening… Application started`); `/api/meta` 200; `swagger.json` 200 with all 13 master-data paths; DI resolves controller→service→repo→DbContext (a DB-backed call reaches Npgsql, failing only on creds). -- **Blocked / follow-ups:** (1) apply migration — needs real local Postgres creds (see §0 note); (2) auth enforcement + audit trail — §6 (security gate for `[x]`); (3) no `DELETE` master endpoints — `MASTER_IN_USE` code reserved until transaction tables exist (deactivate-only per FR-MD-08). +- **Blocked / follow-ups:** (1) auth enforcement + audit trail — §6 (the remaining security gate for `[x]`); (2) no `DELETE` master endpoints — `MASTER_IN_USE` code reserved until transaction tables exist (deactivate-only per FR-MD-08); (3) minor: bad-enum bind error leaks the CLR type name in `detail` (02-SECURITY B.5) — fine in Dev, tidy before prod. + +### 2026-07-10 — Migration applied + live smoke test PASSED +- `dotnet ef database update` applied `InitialCreate` to local Postgres; `/health` → `Healthy`. +- End-to-end curl smoke across all 5 controllers — all green: warehouse/bin create+list; uom create; category + child + `?tree=true` nesting; vendor create + PUT (If-Match 200 / stale 412 / missing 428); item create (201, referencing category/uom/vendor) + GET (ETag header) + list/filter `q` + `pageSize=9999`→clamped 200; reorder PUT; uom-conversions PUT; full item PUT with fresh ETag→200; PATCH status Inactive→204; duplicate SKU→400 `SKU_DUPLICATE`; bad reference→422; missing required→400 ValidationProblemDetails; bad enum→400. Concurrency token (`xmin`) confirmed incrementing per mutation. +- Note: local dev DB now holds smoke-test rows (warehouse/bin/uom×2/category×2/vendor/item, item left Inactive). Reset any time with `dotnet ef database drop -f && dotnet ef database update`. + +### 2026-07-10 — Procurement (§2, minus returns) + cross-cutting foundations +- Cross-cutting: `User` entity (+ seeded `system` user via `HasData`), `ICurrentUser.AuditUserId` (numeric actor, system fallback), `NumberSequence` + `NumberSequenceService` (atomic per-type/per-year doc numbers issued inside the UoW txn). +- Procurement: enums (RequisitionStatus, RfqStatus, PurchaseOrderStatus); 8 entities (Requisition/Line, Rfq/Line, VendorQuotation/Line, PurchaseOrder/Line) + configs; DTOs; 3 services; 3 controllers (`/requisitions`, `/rfqs`, `/purchase-orders`). PO carries the `xmin` ETag token; totals computed server-side; create/edit wrapped in `IUnitOfWork.ExecuteInTransactionAsync` so the reserved doc number rolls back with the doc. +- Migration `AddProcurement` generated + applied (10 tables incl. users/number_sequences; system-user seed; PO `xmin` emits no DDL). +- **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/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index cbf7fca..3bdc995 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -5,11 +5,17 @@ Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation. ## 0. Foundation -- [ ] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local`) -- [ ] Typed API client / fetch wrapper (one method per endpoint) + bearer token handling -- [ ] Shared TS types mirroring API DTOs (`types/`) -- [ ] Dependency-free client validation helpers (format/required/range) -- [ ] `ProblemDetails` normalizer + `code → message` map (`lib/`) +- [x] `NEXT_PUBLIC_API_BASE_URL` wired (`.env.local` / `.env.local.example`, alongside the existing `NEXT_PUBLIC_AUTH_API_BASE_URL`) — currently unused now that the fetch client is gone (see 2026-07-15 note below) +- [ ] Typed API client / fetch wrapper — **removed 2026-07-15** (`lib/api-client.ts` + `lib/auth-token.ts` deleted per explicit user request: "delete all api connections"). No live code path in the frontend makes an HTTP call to any backend anymore — every screen is 100% mock data (`lib/api/mock-data.ts`). +- [x] Shared TS types mirroring API DTOs (`types/common.ts`, `types/master-data.ts`, `types/procurement.ts`, `types/grn.ts`) — built only for the subset GRN needs (Item/Warehouse/Bin/Vendor/Uom/PurchaseOrder/Grn); `types/common.ts` now also carries `ApiResult` (moved here 2026-07-15 when `lib/api-client.ts` was deleted, since it's a plain data envelope, not fetch-specific) +- [~] Client validation helpers (`lib/validations/grn.ts`) — **deviation**: uses `zod` (already a project dependency, used by `lib/validations.ts`/login), not hand-rolled, to stay consistent with the codebase's existing pattern rather than introduce a second validation approach. UX-only; server remains authoritative (docs/20-FRONTEND.md §3) +- [x] `code → message` map (`lib/error-map.ts`) — `errorMessage`/`fieldErrors` duck-type any `{ code, detail, errors }`-shaped rejection (the `ApiError` class they used to check via `instanceof` no longer exists); this also fixed a latent bug where the mock layer's plain `Error`-plus-`.code` rejects never matched the old `instanceof ApiError` check, so `CODE_MESSAGES` silently never applied to any mock error + +> **Scope note:** this foundation was built alongside the GRN feature and only covers the endpoints GRN consumes (items, warehouses/bins, vendors, uoms, purchase-orders, grns). Other master-data/procurement API methods still need their own `lib/api/*.ts` files when those screens are built. + +> **2026-07-15 — fetch infrastructure deleted outright (not just reverted to mock).** Following an earlier same-week pass that wired every `lib/api/*.ts` module to real `fetch` calls (then reverted via `git revert --no-commit` at the user's request — see `Backend`-adjacent history if relevant later), the user asked to go further and delete the underlying connection mechanism entirely, not just leave it unused. Deleted `lib/api-client.ts` (`apiRequest`/`apiRequestWithETag`/`buildQuery`/`ApiError`) and `lib/auth-token.ts` (bearer-token storage) as files. Follow-on fixes this required: (1) `ApiResult` — used by `items.ts`/`purchase-orders.ts`/`vendors.ts` for their mock ETag pattern — moved into `types/common.ts`; (2) `lib/error-map.ts` rewritten to duck-type instead of `instanceof ApiError`; (3) three detail pages (`vendors/[id]`, `products/[id]`, `procurement/purchase-orders/[id]`) had their `err instanceof ApiError ? err.code : (err as {code?:string})?.code` conflict-detection simplified to the duck-typed form only. Also stripped the now-dangling commented-out "real implementation" blocks (`// import { apiRequest... } from "@/lib/api-client"` etc.) from all 15 `lib/api/*.ts` files, since they referenced a now-deleted module. `tsc --noEmit`/`eslint` clean (same pre-existing `login/page.tsx` error and established `set-state-in-effect` pattern only — confirmed unchanged by this pass). +> +> **If real backend integration is attempted again**, note two things found during the reverted pass: the RFQ backend contract had drifted from this file's assumed shapes (`Backend/ERPCore/Dtos/Procurement/RfqDtos.cs`/`RfqService.cs` — no persisted invited-vendor list, `requisitionId` required on create, different comparison DTO field names) — re-verify against the actual backend rather than trusting old assumptions; and a typed fetch client + ETag/error-normalization layer will need to be rebuilt from scratch since `lib/api-client.ts`/`lib/auth-token.ts` no longer exist. ## 1. Auth - [~] Login screen — UI built (`app/login`); not yet wired to `POST /auth/login` / token storage @@ -18,41 +24,111 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API ## 2. Master Data screens -- [ ] Items (list + create/edit, ETag handling) -- [ ] UOM + conversions -- [ ] Categories (tree) -- [ ] Vendors -- [ ] Warehouses + Bins -- [ ] Item reorder settings +- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. +- [~] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03 +- [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04 +- [~] Vendors (`app/dashboard/vendors` list + search/status-filter + create dialog, `[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-06. First screen this session to exercise the ETag/`If-Match`/412 pattern end-to-end (`lib/api-client.ts`'s `ApiResult` was built earlier but unused until now). +- [~] Warehouses + Bins (`app/dashboard/warehouse` list + create-warehouse dialog, `[id]` bin list + create-bin dialog) — FR-WH-01/FR-MD-07. Frontend-only (see note below); no ETag handling since there's no edit/delete yet, only create. +- [~] Item reorder settings — edited inline on the Item detail page (per-warehouse point/qty rows) via `PUT /items/{itemId}/reorder` — FR-MD-05 ## 3. Procurement screens -- [ ] Requisition (create + submit) -- [ ] RFQ + quotations + comparison view -- [ ] Purchase Order (create, edit-while-open, cancel) -- [ ] Purchase Return +- [~] Requisition (`app/dashboard/procurement/requisitions` list, `/new` create, `/[id]` detail + Submit) — FR-PROC-01 +- [~] RFQ + quotations + comparison view (`.../rfqs` list, `/new` create with vendor multi-invite, `/[id]` detail: lines, comparison matrix, record-quotation form, "Create PO from vendor") — FR-PROC-02 +- [~] Purchase Order (`.../purchase-orders` list, `/new` create — auto-approved, prefillable from a Requisition or an RFQ+vendor quotation via query params — `/[id]` detail: edit-while-open with ETag/If-Match, Cancel with reason) — FR-PROC-03..07 +- [~] Purchase Return (`.../purchase-returns` list, `/new` create against a Confirmed/Closed GRN's lines) — FR-PROC-08; also reachable from a `Rejected` GRN line via a "Create Return" button on the GRN detail page ## 4. Receiving screens -- [ ] GRN create (qty, bin, batch/serial capture) -- [ ] GRN confirm (render created layers / ledger refs) -- [ ] Inspection hold release / reject +- [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail +- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode` +- [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session +- [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed` +- Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn` + +> **`[~]` not `[x]`, by design:** these screens are built against the documented+planned contract in `docs/11-BACKEND-PHASE1.md` §4, but **no GRN backend exists yet** (this was frontend-only work; see the deviation below). +> +> **UI-only / mock-data mode (temporary):** `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` currently return **in-memory sample data** (`lib/api/mock-data.ts`) instead of calling the real API, so the three screens are fully browsable/demoable (list → create against a mocked PO or as a direct receipt → detail → confirm → release/reject) without a running backend. Each `lib/api/*.ts` file keeps the real `fetch`-based implementation **commented out directly above** the mock block — switch back by deleting the mock block, uncommenting the real block, and deleting `lib/api/mock-data.ts` once the GRN backend exists. `npm run dev` + `tsc --noEmit` + `eslint` are clean (aside from the pre-existing, unrelated `app/login/page.tsx` resolver-typing error and the two `react-hooks/set-state-in-effect` warnings shared with `hooks/use-mobile.ts`). +> +> **Deviation — `GET /grns` and `GET /grns/{id}`:** the API doc only specifies `POST /grns`, `POST /grns/{id}/confirm`, `POST /grns/{id}/lines/{id}/release` (no list/detail read). A list screen and a confirm/release screen both need to reload a GRN, so `lib/api/grns.ts` (`grnsApi.list`/`grnsApi.get`) and `types/grn.ts` assume these two GET endpoints will exist once the backend is built — flag this to whoever implements `Backend/PROGRESS.md` §3 so `docs/11-BACKEND-PHASE1.md` gets the corresponding doc update. ## 5. Stock screens -- [ ] Stock enquiry (onHand/available/onHold/inTransit) -- [ ] Ledger view · Valuation view -- [ ] Transfer (create → dispatch → receive) -- [ ] Adjustment (reason code required) -- [ ] Count (cycle/full → enter → post) -- [ ] Reorder alerts (+ create requisition) +- [~] Stock hub (`app/dashboard/stock/page.tsx`) — card grid linking to all 7 areas below +- [~] Stock enquiry (`.../stock/enquiry`) — onHand/available/onHold/inTransit/reserved, search by SKU/name + warehouse filter, links to Valuation per row +- [~] Ledger view (`.../stock/ledger`) — filterable by item/warehouse/date range, paginated +- [~] Valuation view (`.../stock/valuation`) — item+warehouse picker (also reachable via `?itemId=&warehouseId=` from Enquiry), FIFO layer breakdown + totals +- [~] Transfer (`.../stock/transfers` list, `/new` create, `/[id]` dispatch → receive) — cost-preserving per line (FR-STK-06) +- [~] Adjustment (`.../stock/adjustments` list, `/new` create) — reason code mandatory, auto-posts on submit (no separate confirm step, matching FR-STK-07) +- [~] Count (`.../stock/counts` list, `/new` create, `/[id]` enter counts → post) — posting creates a linked variance adjustment +- [~] Reorder alerts (`.../stock/reorder-alerts`) — items ≤ reorder point, one-click "Create requisition" +- [~] Wastage (`.../stock/wastage` report, `/new` record) — **not a documented endpoint or SRS document type**: damage/theft-loss/expiry write-offs are modeled as Stock Adjustments with a loss-type reason code (FR-STK-07); `lib/api/wastage.ts` is a frontend-only lens over `stockAdjustmentsApi` + the shared mock ledger (filters to reason codes `DMG`/`LOSS`/`EXPWO`, flattens adjustment lines to per-item wastage records, sums matching outbound ledger entries for cost). No new backend concept — confirmed with the user before building (asked whether "Wastage" meant this vs. a distinct document type). +- Sidebar: added "Stock" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/stock`; top-bar titles mapped per route (`components/Layouts/Header.tsx`) + +> **`[~]` not `[x]`, by design — same posture as §4 Receiving:** built frontend-only (user request), against the documented+assumed Stock Core contract (`docs/11-BACKEND-PHASE1.md` §5), with **no real backend**. Unlike Receiving, though, this pass wired a genuine in-memory Stock Core (`lib/api/mock-data.ts`: `mockStockLayers`, `mockStockLedger`, `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`) that GRN confirm now posts through too (previously GRN confirm fabricated a throwaway response; it now creates real layers/ledger entries and accrues PO `qtyReceived`/status) — so Enquiry/Ledger/Valuation reflect what actually happened in the session, and FIFO consumption (transfers, adjustment decreases, count variance decreases) genuinely throws a `STOCK_NEGATIVE_BLOCKED`-style error when stock is insufficient. `tsc --noEmit` and `eslint` are clean across all new/changed files (only the pre-existing `login/page.tsx` error and the established `set-state-in-effect` warnings remain, same as §4). +> +> **Deviations (same pattern as GRN, see §4):** `GET`/detail list endpoints for transfers/adjustments/counts (`lib/api/stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`) are assumed extensions beyond `docs/11-BACKEND-PHASE1.md` §5.4-5.6, which document only the transactional POSTs/PUT. `stockApi.onHandList()` (used by the Enquiry screen) is also not a documented endpoint — it's a frontend-only convenience that iterates known item/warehouse pairs and calls the (documented) on-hand computation per pair; a real backend would want a proper list endpoint instead. Flag all of these to whoever implements `Backend/PROGRESS.md` §4/§5 (Stock Core + stock transactions). +> +> **Simplifications (mock-data limitations, not spec decisions):** `StockLayer` has no per-bin field (matches the real ER model, docs/10 Part C.5 — only `StockLedger` carries `bin_id`), so Count lines don't attempt bin-level snapshotting. Transfers don't expose batch selection in the create UI (FIFO picks layers regardless of batch). Adjustment increases always cost at "last known cost" for that item/warehouse (FR-STK-07); there's no landed-cost/manual-cost override. In-transit quantity is shown for visibility at the destination warehouse only and is not subtracted a second time from the source's `available` (dispatch already reduced the source layer's `qtyRemaining`) — the docs' `available = onHand − onHold − reserved − inTransit(out)` formula is ambiguous on this point given dispatch semantics; this was a judgment call, noted here for whoever builds the real backend to confirm or correct. ## 6. Validation posture (20-FRONTEND §3) -- [ ] Client format/required/range checks on all forms -- [ ] Surface server `ProblemDetails` incl. domain codes; map to fields/messages -- [ ] `412` conflict → prompt refetch before retry -- [ ] No client-side gating on stock/availability/status (server-authoritative) +- [~] Client format/required/range checks on all forms — done for GRN create (`lib/validations/grn.ts`); not yet done for other forms +- [x] Surface server `ProblemDetails` incl. domain codes; map to fields/messages — `lib/error-map.ts` (`errorMessage`/`fieldErrors`), used by GRN create/detail +- [ ] `412` conflict → prompt refetch before retry — `apiRequestWithETag` surfaces the ETag but no screen edits a GRN yet (GRN has no PUT), so untested in practice +- [x] No client-side gating on stock/availability/status (server-authoritative) — GRN create always submits to the server and surfaces `OVER_RECEIPT_TOLERANCE`/etc. via `error-map.ts` rather than pre-blocking ## 7. UX states -- [ ] Loading / empty / error states on every list -- [ ] Transactional actions show server-returned side effects as confirmation +- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt +- [x] Transactional actions show server-returned side effects as confirmation — GRN confirm renders `createdLayers`/`ledgerRefs`/`poStatus` from the response ## Done + +### 2026-07-13 — GRN screens + frontend foundation (frontend-only; no backend changes) +- Foundation: `lib/api-client.ts`, `lib/error-map.ts`, `lib/auth-token.ts`, `types/{common,master-data,procurement,grn}.ts`, `lib/api/{grns,purchase-orders,warehouses,items,vendors,uoms}.ts` — scoped to what the GRN flow needs, not the full API surface. +- Added the shadcn `select` primitive (`npx shadcn add select`) — wasn't in `components/ui/` yet; needed for PO/vendor/warehouse/bin/item/hold-status pickers. +- Screens: GRN list, GRN create (PO-based + direct receipt, batch/serial capture by `trackingMode`), GRN detail (confirm + release/reject). Sidebar nav entry added. +- **This was explicitly frontend-only** (user interrupted an initial backend+frontend plan and asked for frontend only). No GRN backend exists — `Backend/PROGRESS.md` §3/§4 are unchanged. The screens are built against the contract in `docs/11-BACKEND-PHASE1.md` §4 plus two assumed-but-undocumented endpoints (`GET /grns`, `GET /grns/{id}`, see §4 note above); none of it is runnable end-to-end yet. +- Verified: `tsc --noEmit` clean for all new/edited files (one pre-existing, unrelated error remains in `app/login/page.tsx`); `eslint` clean aside from two `react-hooks/set-state-in-effect` warnings matching an already-existing pattern in `hooks/use-mobile.ts`; all three routes confirmed rendering (200, correct content, no error boundary) via SSR against the dev server. + +### 2026-07-13 — Stock Management screens (frontend-only; no backend changes) +- `types/stock.ts`: full DTO set for on-hand, ledger, valuation, transfers, adjustments, counts, reorder alerts (docs/11 §5). +- `lib/api/mock-data.ts` gained a real in-memory Stock Core: `mockStockLayers`/`mockStockLedger` + `receiveLayer`/`consumeFifo`/`postLedgerEntry`/`computeOnHand`/`lastKnownCost` helpers, plus `mockItemReorders` and `mockReasonCodes` seed data. `lib/api/grns.ts`'s `confirm()` was refactored to post through these helpers instead of fabricating a response, and now also accrues PO `qtyReceived`/recomputes PO status — so GRN and Stock screens are genuinely connected this session. +- New API modules: `lib/api/stock.ts` (on-hand/ledger/valuation/reorder-alerts), `stock-transfers.ts`, `stock-adjustments.ts`, `stock-counts.ts`, `reason-codes.ts` — same commented-real-block + active-mock-block pattern as the GRN modules. +- Screens: hub, Enquiry, Ledger, Valuation, Transfers (list/new/detail with dispatch+receive), Adjustments (list/new, auto-post), Counts (list/new/detail with enter-counts+post), Reorder Alerts. New shared badge set `components/stock/status-badges.tsx` (same fixed-size red/green/yellow convention as `components/receiving/status-badges.tsx`). Sidebar + header-title mappings added. +- Same posture as the GRN pass: `[~]` not `[x]`, frontend built ahead of a nonexistent Stock Core backend, deviations/simplifications recorded in the §5 note above. `tsc --noEmit` and `eslint` clean (only the same pre-existing/established issues as the GRN pass). + +### 2026-07-13 — Wastage screens (frontend-only; no backend changes) +- `lib/api/wastage.ts`: no new backend concept — confirmed with the user that "Wastage" should be a focused UI lens over the just-built Stock Adjustments (damage/theft-loss/expiry write-off reason codes), not a distinct document type. Filters `mockStockAdjustments` to loss-type reason codes, flattens to per-item `WastageRecord`s, and computes cost per record from matching outbound `mockStockLedger` entries. +- Screens: `.../stock/wastage` (report — totals cards, warehouse/reason filters, per-item table) and `.../stock/wastage/new` (single-line record form, reason dropdown restricted to wastage-type codes, posts via the existing `stockAdjustmentsApi.create`). Added a "Wastage" card to the Stock hub and header-title mappings. +- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from one more instance of the already-established `set-state-in-effect` pattern. + +### 2026-07-13 — Warehouse Management screens (frontend-only; no backend changes) +- Scope, per user selection out of the four FR-WH sub-areas offered (Warehouses & Bins / Stock Locator / Batch & Serial / Putaway): **Warehouses & Bins master data only** (FR-WH-01, FR-MD-07). The other three (bin-level Stock Locator, Batch/Serial tracking, Putaway) were **not** built — flagged here so a future pass knows they're still open, not forgotten. +- `lib/api/warehouses.ts` gained `create`/`get`/`createBin` (previously list/listBins only, read-only) — duplicate-code validation mirrors the real `SKU_DUPLICATE`-style 400 pattern used elsewhere. `mock-data.ts` gained `allocateWarehouseId`/`allocateBinId`. +- Screens: `app/dashboard/warehouse` (list + "New Warehouse" `Dialog` form) and `app/dashboard/warehouse/[id]` (bin list + "New Bin" `Dialog` form) — used `components/ui/dialog.tsx` instead of a full page for these two-field creates, since a whole page felt heavy for that. Sidebar "Warehouses" entry + header-title mapping added. +- Housekeeping: removed two stray duplicate route folders (`app/dashboard/receiving/grn/create new GRN/`, `.../view GRN/`) that were byte-for-byte copies of the real `new/` and `[id]/` GRN pages under garbled folder names — almost certainly an IDE artifact from an earlier malformed file-open path, not intentional work (confirmed untracked in git before removing). Also noted, but deliberately left alone: `app/warehouse/*`, `components/warehouse/`, `lib/warehouse/` are pre-existing **empty** scaffold folders (no files at all) from initial project setup — Warehouse Management was built under `app/dashboard/warehouse/*` instead so it gets the dashboard chrome (sidebar/header) for free, consistent with every other screen this session. +- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from one `exhaustive-deps` warning (not an error) on `[id]/page.tsx`'s `loadBins` helper. + +### 2026-07-13 — Vendor (Supplier) management screens (frontend-only; no backend changes) +- Confirmed with the user first: "supplier/shop" has no distinct "Shop" entity in the SRS/docs — scoped this to the documented Vendor master (FR-MD-06, docs/11 §2.4), "supplier" being the standard ERP synonym. +- `lib/api/vendors.ts` extended from list-only to `get`/`create`/`update`/`updateStatus`. This is the **first screen to exercise the ETag/If-Match/412 pattern**: `mock-data.ts` gained a per-vendor concurrency-token map (`getVendorVersion`/`bumpVendorVersion`/`initVendorVersion`, standing in for the real backend's `xmin` — the public `Vendor` type has no version field of its own since it travels as an HTTP `ETag` header, not a body field) so `update()` genuinely rejects a stale `If-Match` with `CONCURRENCY_CONFLICT`, matching `docs/11 §1.6` and `20-FRONTEND.md §3.2`. +- Screens: `app/dashboard/vendors` (list, search + status filter, "New Vendor" dialog) and `app/dashboard/vendors/[id]` (full edit form using the real `apiRequestWithETag`-shaped `ApiResult`, a dedicated conflict banner with "Reload before retrying" per the 412 UX rule rather than a generic toast, and an Activate/Deactivate toggle via `PATCH status`, FR-MD-08 — deactivate, not hard-delete). Sidebar "Vendors" entry + header-title mapping added. +- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session. +- **Follow-up (same day):** `vendorsApi.list()` didn't actually paginate (always returned page 1 / all matches, same latent gap the GRN list had before its own pagination pass) — fixed to slice by `page`/`pageSize` properly, added the same Previous/Next pagination controls used on the GRN and Stock list screens, and seeded 8 more sample vendors so there's something real to page through. + +### 2026-07-13 — Procurement screens: Requisition → RFQ → PO → Purchase Return (frontend-only; no backend changes) +- `types/procurement.ts` grew from a GRN-support subset (PO read types only) to the full §3 DTO set: Requisition/ReqLine, Rfq/RfqLine/Quotation/RfqComparison, PO create/update/cancel request types, PurchaseReturn/PurchaseReturnLine — mirrors `docs/11-BACKEND-PHASE1.md` §3 request/response JSON exactly (no `deliveryDate` field on PO lines, since the documented `POST /purchase-orders` example doesn't carry one despite FR-PROC-03's prose — contract-over-prose per `docs/20-FRONTEND.md` §1). +- `lib/api/mock-data.ts`: added `mockRequisitions`/`mockRfqs`/`mockQuotations`/`mockPurchaseReturns` + allocators, a PO concurrency-token map (`getPoVersion`/`bumpPoVersion`/`initPoVersion`, same out-of-band ETag pattern as vendors), and `consumeLayerByGrnLine` — a *new* consumption path deliberately separate from `consumeFifo`: a Purchase Return disposes of the exact layer its GRN line created (often `OnHold`/`Rejected`, which `consumeFifo`'s hold filter would otherwise skip), not "the oldest open layer for this item/warehouse". Seeded Requisition #210 to match the existing `mockPurchaseOrders[0].requisitionId` so the two screens cross-reference. +- New API modules: `lib/api/requisitions.ts`, `lib/api/rfqs.ts` (create/addQuotation/comparison — comparison is computed client-side from recorded quotations), `lib/api/purchase-returns.ts`. `lib/api/purchase-orders.ts` extended from list/get-only (its original GRN-support scope) to full create/update/cancel; added `getWithETag`/`isPoEditable` without touching the existing plain `get()` GRN's create-flow already depends on, so no existing call site broke. +- **Wiring, not just new screens:** `stockApi.createReorderRequisition` (Stock → Reorder Alerts, built in an earlier session) previously fabricated a response with no backing record; it now pushes a real row into `mockRequisitions`, so a reorder-triggered requisition genuinely shows up in the new Requisitions list — same "connect the mock modules together" posture as GRN confirm → Stock Core. +- Screens: Requisition (list/new/detail+Submit), RFQ (list/new with checkbox vendor multi-invite reusing the Stock Count's `Checkbox` pattern/new with quotation-recording form + vendor-by-line comparison table/"Create PO from vendor"), Purchase Order (list/new — accepts `?requisitionId=` or `?rfqId=&vendorId=` to prefill lines and pricing/detail with inline edit-while-open using the vendor `[id]` page's ETag+412-conflict-banner pattern, plus a Cancel-with-reason flow client-disabled when any line has receipts), Purchase Return (list/new — pick a Confirmed/Closed GRN, checkbox+qty its lines; also reachable via a new "Create Return" button next to `Rejected` lines on the GRN detail page, matching the SRS flow diagram's Reject→Purchase Return step). New `components/procurement/status-badges.tsx` (same fixed-width badge convention as Receiving/Stock) and `lib/validations/procurement.ts` (zod-free hand-rolled, matching the GRN validation file's style, not its `zod` deviation). +- Sidebar: added "Procurement" nav entry between Vendors and Receiving; header title mappings added for all new routes. +- Same `[~]` posture as every other module this session: built against the documented+assumed Procurement contract (`docs/11-BACKEND-PHASE1.md` §3), no Procurement backend exists (`Backend/PROGRESS.md` §2 unchanged). +- Verified: `tsc --noEmit` clean (same pre-existing `login/page.tsx` error only); `eslint` clean aside from the same established `set-state-in-effect` pattern used throughout this session (confirmed it also fires on the pre-existing `grn/page.tsx`/`vendors/page.tsx`/`hooks/use-mobile.ts` — not a regression); `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure blocks the full build, unrelated). All 12 new routes plus the GRN-detail "Create Return" link (including three query-param-prefilled variants) confirmed rendering 200 with no error boundary via SSR against the dev server. + +### 2026-07-13 — Master Data screens: Items, UOM, Categories, Reorder settings (frontend-only; no backend changes) +- `types/master-data.ts`: `ItemListItem` (the GRN/PO/Requisition/RFQ item-picker subset built in earlier sessions) is now a derived view of a new full `Item` type — SKU/name/description/category/baseUom/defaultVendor/type/trackingMode/taxClass/status plus `reorder: ItemReorderSetting[]` (matches the documented `GET /items/{itemId}` example inline) and `conversions: UomConversion[]` (**deviation**: the doc's example response only shows `reorder`, and conversions are otherwise reachable only via `PUT /items/{itemId}/uom-conversions` with no matching GET — embedding them on the full resource, like the assumed `GET /grns`/`GET /grns/{id}` reads elsewhere in this app, lets the Item detail screen show current conversions before editing). Also added `Category`/`CategoryTreeNode`, `CreateUomRequest`, `CreateCategoryRequest`, and Item create/update/reorder/conversion request types (docs/11 §2.1-2.3). +- `lib/api/mock-data.ts`: `mockItems` changed storage shape from `ItemListItem[]` to full `Item[]` (only `mock-data.ts` and `lib/api/items.ts` touched it directly, confirmed by grep, so no other call site broke) — `lib/api/items.ts`'s `list()` now maps down to `ItemListItem`, same "full record → mapped summary" pattern as `mockPurchaseOrders` → `PurchaseOrderSummary`. Added a per-item concurrency-token map (`getItemVersion`/`bumpItemVersion`/`initItemVersion`, same out-of-band ETag pattern as vendors/POs), `mockCategories` seeded with a 2-root/1-child tree matching the category IDs the existing sample items already reference (12 "Fasteners" under 3 "Hardware"; 20 "Power Tools"), and a UOM id allocator. +- New API modules: `lib/api/categories.ts` (`list`/`tree`/`create` — `tree()` builds the nested structure client-side from the flat list, since the mock has no separate tree-storage concept). `lib/api/items.ts` grew from list-only (its original GRN-picker scope) to full `get`/`create`/`update`/`updateStatus`/`updateReorder`/`updateUomConversions`; `lib/api/uoms.ts` gained `create`. +- Screens: Items (`app/dashboard/products` — **reused the pre-existing "Products" sidebar entry and stub route** rather than adding a new nav item, since it was already wired to an empty placeholder page; list has search + category/tracking-mode/status filters + pagination, `/new` create, `/[id]` detail combining three independently-saved sections in one page — basic info with ETag/If-Match + 412-conflict banner mirroring the Vendor `[id]` page, a Reorder Settings row-editor posting `PUT /items/{itemId}/reorder`, and a UOM Conversions row-editor posting `PUT /items/{itemId}/uom-conversions` — matching how the API groups these as sub-resources of Item rather than separate top-level screens). UOM (`app/dashboard/products/uoms` — flat list + create dialog, same shape as the Warehouses list). Categories (`app/dashboard/products/categories` — indented recursive tree view + create dialog with a parent picker). `lib/validations/master-data.ts` added (hand-rolled, matching the GRN validation file's style, not its `zod` deviation). Header title mappings added for all `/dashboard/products/*` routes. +- **Housekeeping:** removed `app/dashboard/vendors/view vendors/` — confirmed byte-for-byte identical to `vendors/[id]/page.tsx` and untracked in git, same IDE-artifact pattern (malformed file-open path) as the garbled GRN duplicate folders removed in the Warehouse Management pass; noted here rather than silently dropped. Left `app/dashboard/receiving/grn/[id]/edit/` alone — it's untracked too but is a distinct, non-duplicate GRN-edit screen, not an artifact. +- Same `[~]` posture as every other module this session: built against the documented+assumed Master Data contract (`docs/11-BACKEND-PHASE1.md` §2), no Master Data backend exists (`Backend/PROGRESS.md` §1 unchanged). +- Verified: `tsc --noEmit` clean after clearing a stale `.next` type cache that still referenced the just-deleted `view vendors` route (same pre-existing `login/page.tsx` error only remains); `eslint` clean aside from the same established `set-state-in-effect` pattern; `npm run build` compiles successfully via Turbopack (same pre-existing login type-check failure, unrelated). All 5 new/changed routes confirmed rendering 200 with no error boundary against the dev server (one false-alarm 500 during testing traced to an unrelated stale process already bound to port 3000, not this code — retested clean on the actual dev server port). diff --git a/Frontend/erp-system/app/dashboard/layout.tsx b/Frontend/erp-system/app/dashboard/layout.tsx index 3072700..49ec7f9 100644 --- a/Frontend/erp-system/app/dashboard/layout.tsx +++ b/Frontend/erp-system/app/dashboard/layout.tsx @@ -1,5 +1,6 @@ import { AppSidebar } from "@/components/Layouts/AppSidebar" import { Header } from "@/components/Layouts/Header" +import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs" import { Toaster } from "@/components/ui/toast" export default function DashboardLayout({ @@ -14,6 +15,7 @@ export default function DashboardLayout({
+
{children} diff --git a/Frontend/erp-system/app/dashboard/procurement/page.tsx b/Frontend/erp-system/app/dashboard/procurement/page.tsx new file mode 100644 index 0000000..dd9f1ad --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/page.tsx @@ -0,0 +1,64 @@ +import Link from "next/link" +import { ClipboardList, FileText, PackageX, ShoppingCart, type LucideIcon } from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [ + { + title: "Requisitions", + description: "Raise a purchase requisition and submit it into procurement.", + href: "/dashboard/procurement/requisitions", + icon: ClipboardList, + }, + { + title: "RFQs", + description: "Request quotations from vendors, record pricing, and compare side by side.", + href: "/dashboard/procurement/rfqs", + icon: FileText, + }, + { + title: "Purchase Orders", + description: "Auto-approved on creation, freely editable while open, cancellable before receipt.", + href: "/dashboard/procurement/purchase-orders", + icon: ShoppingCart, + }, + { + title: "Purchase Returns", + description: "Return received goods to a vendor, referencing the original GRN line.", + href: "/dashboard/procurement/purchase-returns", + icon: PackageX, + }, +] + +export default function ProcurementHubPage() { + return ( +
+
+

Procurement

+

+ Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09). +

+
+ +
+ {areas.map((area) => ( + + + +
+
+ +
+ {area.title} +
+
+ +

{area.description}

+
+
+ + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx new file mode 100644 index 0000000..a205486 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -0,0 +1,458 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Trash2 } from "lucide-react" + +import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders" +import { warehousesApi } from "@/lib/api/warehouses" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage } from "@/lib/error-map" +import { validatePoLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreatePoLineInput, PurchaseOrder } from "@/types/procurement" +import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" +import { PoStatusBadge } from "@/components/procurement/status-badges" + +interface DraftLine { + key: string + poLineId: number | null + itemId: number | null + uomId: number | null + warehouseId: number | null + qty: string + unitPrice: string + tax: string + qtyReceived: number +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `poeditline-${keySeq}` +} + +export default function PurchaseOrderDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const poId = Number(params.id) + + const [po, setPo] = useState(null) + const [etag, setEtag] = useState(null) + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [vendors, setVendors] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [lines, setLines] = useState([]) + const [lineErrors, setLineErrors] = useState>>({}) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + + const [showCancelForm, setShowCancelForm] = useState(false) + const [cancelReason, setCancelReason] = useState("") + const [cancelling, setCancelling] = useState(false) + + function toDraftLines(order: PurchaseOrder): DraftLine[] { + return order.lines.map((l) => ({ + key: newKey(), + poLineId: l.poLineId, + itemId: l.itemId, + uomId: l.uomId, + warehouseId: l.warehouseId, + qty: String(l.qty), + unitPrice: String(l.unitPrice), + tax: String(l.tax), + qtyReceived: l.qtyReceived, + })) + } + + function load() { + setLoadError(null) + purchaseOrdersApi + .getWithETag(poId) + .then(({ data, etag: tag }) => { + setPo(data) + setEtag(tag) + setLines(toDraftLines(data)) + setConflict(false) + }) + .catch((err) => setLoadError(errorMessage(err))) + } + + useEffect(() => { + if (!Number.isFinite(poId)) return + load() + Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list(), warehousesApi.list(), vendorsApi.list({ pageSize: 200 })]) + .then(([it, uo, wh, ve]) => { + setItems(it.items) + setUoms(uo.items) + setWarehouses(wh.items) + setVendors(ve.items) + }) + .catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [poId]) + + function itemFor(itemId: number | null) { + return items.find((i) => i.itemId === itemId) ?? null + } + function uomName(uomId: number) { + return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` + } + function warehouseCode(warehouseId: number) { + return warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? `#${warehouseId}` + } + function vendorCode(vendorId: number) { + return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}` + } + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSave() { + if (!po || !etag) return + setSaveError(null) + + if (lines.length === 0) { + setSaveError("A purchase order needs at least one line.") + return + } + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validatePoLine({ + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + unitPrice: line.unitPrice, + tax: line.tax, + }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSaveError("Fix the highlighted lines before saving.") + return + } + + const payloadLines: CreatePoLineInput[] = lines.map((l) => ({ + itemId: l.itemId as number, + uomId: l.uomId as number, + warehouseId: l.warehouseId as number, + qty: Number(l.qty), + unitPrice: Number(l.unitPrice), + tax: Number(l.tax), + })) + + setSaving(true) + try { + const result = await purchaseOrdersApi.update(po.poId, { vendorId: po.vendorId, requisitionId: po.requisitionId, lines: payloadLines }, etag) + setPo(result.data) + setEtag(result.etag) + setLines(toDraftLines(result.data)) + toast.success("Purchase order saved", `${result.data.docNo} updated (FR-PROC-05, edit-while-open).`) + } catch (err) { + const code = (err as { code?: string })?.code + if (code === "CONCURRENCY_CONFLICT") { + setConflict(true) + setSaveError(errorMessage(err)) + setSaving(false) + return + } + setSaveError(errorMessage(err)) + toast.error("Could not save purchase order", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleCancel() { + if (!po) return + if (!cancelReason.trim()) { + setSaveError("A cancellation reason is required.") + return + } + setCancelling(true) + try { + const updated = await purchaseOrdersApi.cancel(po.poId, { reason: cancelReason.trim() }) + setPo(updated) + setShowCancelForm(false) + toast.success("Purchase order cancelled", updated.docNo) + } catch (err) { + toast.error("Could not cancel purchase order", errorMessage(err)) + } finally { + setCancelling(false) + } + } + + if (loadError && !po) { + return ( +
+
{loadError}
+ + Back to purchase orders + +
+ ) + } + + if (!po) { + return ( +
+ + +
+ ) + } + + const editable = isPoEditable(po.status) && !conflict + const hasReceipts = po.lines.some((l) => l.qtyReceived > 0) + + return ( +
+
+
+ + + +
+
+

{po.docNo}

+ +
+

+ Vendor {vendorCode(po.vendorId)} {po.requisitionId ? `— from Requisition #${po.requisitionId}` : ""} — {po.totals.currency} {po.totals.grandTotal.toFixed(2)} +

+
+
+ + {isPoEditable(po.status) && !showCancelForm && ( + + )} +
+ + {showCancelForm && ( +
+

Cancel {po.docNo}

+ setCancelReason(e.target.value)} + placeholder="Reason (e.g. Duplicate order)" + className="h-11 max-w-md text-base" + /> +
+ + +
+
+ )} + + {conflict && ( +
+ +
+

{saveError ?? "This purchase order was changed by someone else."} Reload before retrying.

+ +
+
+ )} + + {saveError && !conflict && ( +
{saveError}
+ )} + +
+
+

Lines

+ {editable && ( + + )} +
+ +
+ + + + Item + UOM + Warehouse + Qty + Received + Unit price + Tax + {editable && } + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + if (!editable) { + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.uomId ? uomName(line.uomId) : "—"} + {line.warehouseId ? warehouseCode(line.warehouseId) : "—"} + {line.qty} + {line.qtyReceived} + {Number(line.unitPrice).toFixed(2)} + {(Number(line.tax) * 100).toFixed(0)}% + + ) + } + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {items.map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> + + + + + {warehouses.map((w) => ( + + {w.code} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + {line.qtyReceived} + + updateLine(line.key, { unitPrice: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { tax: e.target.value })} + className="h-11 text-base" + /> + + + + + + + ) + })} + +
+
+
+ + {editable && ( +
+ + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx new file mode 100644 index 0000000..06d58ae --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/new/page.tsx @@ -0,0 +1,411 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" + +import { purchaseOrdersApi } from "@/lib/api/purchase-orders" +import { requisitionsApi } from "@/lib/api/requisitions" +import { rfqsApi } from "@/lib/api/rfqs" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage } from "@/lib/error-map" +import { validatePoLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreatePoLineInput } from "@/types/procurement" +import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface DraftLine { + key: string + itemId: number | null + uomId: number | null + warehouseId: number | null + qty: string + unitPrice: string + tax: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `poline-${keySeq}` +} + +function emptyLine(): DraftLine { + return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "", tax: "0.18" } +} + +function NewPurchaseOrderContent() { + const router = useRouter() + const searchParams = useSearchParams() + const requisitionId = Number(searchParams.get("requisitionId")) || null + const rfqId = Number(searchParams.get("rfqId")) || null + const rfqVendorId = Number(searchParams.get("vendorId")) || null + + const [items, setItems] = useState(null) + const [uoms, setUoms] = useState(null) + const [warehouses, setWarehouses] = useState(null) + const [vendors, setVendors] = useState(null) + const [prefillLoading, setPrefillLoading] = useState(!!requisitionId || !!rfqId) + const [loadError, setLoadError] = useState(null) + + const [vendorId, setVendorId] = useState(rfqVendorId) + const [lines, setLines] = useState([emptyLine()]) + + const [headerError, setHeaderError] = useState(null) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + Promise.all([ + itemsApi.list({ pageSize: 200, status: "Active" }), + uomsApi.list(), + warehousesApi.list(), + vendorsApi.list({ pageSize: 200, status: "Active" }), + ]) + .then(([it, uo, wh, ve]) => { + setItems(it.items) + setUoms(uo.items) + setWarehouses(wh.items) + setVendors(ve.items) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, []) + + useEffect(() => { + if (requisitionId) { + requisitionsApi + .get(requisitionId) + .then((req) => { + setLines( + req.lines.map( + (l): DraftLine => ({ + key: newKey(), + itemId: l.itemId, + uomId: null, + warehouseId: null, + qty: String(l.qty), + unitPrice: "", + tax: "0.18", + }) + ) + ) + }) + .catch((err) => setHeaderError(errorMessage(err))) + .finally(() => setPrefillLoading(false)) + return + } + + if (rfqId && rfqVendorId) { + Promise.all([rfqsApi.get(rfqId), rfqsApi.comparison(rfqId)]) + .then(([rfq, comparison]) => { + setVendorId(rfqVendorId) + setLines( + rfq.lines.map((l): DraftLine => { + const cell = comparison.lines.find((cl) => cl.itemId === l.itemId)?.cells.find((c) => c.vendorId === rfqVendorId) + return { + key: newKey(), + itemId: l.itemId, + uomId: null, + warehouseId: null, + qty: String(l.qty), + unitPrice: cell ? String(cell.unitPrice) : "", + tax: "0.18", + } + }) + ) + }) + .catch((err) => setHeaderError(errorMessage(err))) + .finally(() => setPrefillLoading(false)) + return + } + + setPrefillLoading(false) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requisitionId, rfqId, rfqVendorId]) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + function itemFor(itemId: number | null) { + return items?.find((i) => i.itemId === itemId) ?? null + } + + async function handleSubmit() { + setHeaderError(null) + setSubmitError(null) + + if (!vendorId) { + setHeaderError("Select a vendor.") + return + } + if (lines.length === 0) { + setSubmitError("Add at least one line.") + return + } + + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validatePoLine({ + itemId: line.itemId, + uomId: line.uomId, + warehouseId: line.warehouseId, + qty: line.qty, + unitPrice: line.unitPrice, + tax: line.tax, + }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreatePoLineInput[] = lines.map((l) => ({ + itemId: l.itemId as number, + uomId: l.uomId as number, + warehouseId: l.warehouseId as number, + qty: Number(l.qty), + unitPrice: Number(l.unitPrice), + tax: Number(l.tax), + })) + + setSubmitting(true) + try { + const { data: po } = await purchaseOrdersApi.create({ + vendorId, + requisitionId: requisitionId ?? (rfqId ? undefined : null), + lines: payloadLines, + }) + toast.success("Purchase order created", `${po.docNo} — auto-approved (FR-PROC-04).`) + router.push(`/dashboard/procurement/purchase-orders/${po.poId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not create purchase order", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const loading = !items || !uoms || !warehouses || !vendors || prefillLoading + + return ( +
+
+ + + +
+

New Purchase Order

+

Auto-approved on creation; freely editable while open (FR-PROC-03..05).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + value={vendorId} onValueChange={setVendorId}> + + + + + {(vendors ?? []).map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+ {requisitionId && ( +
From Requisition #{requisitionId}
+ )} + {rfqId &&
From RFQ #{rfqId}
} +
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ +
+ + {lines.length > 0 && ( +
+ + + + Item + UOM + Warehouse + Qty + Unit price + Tax + + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + return ( + + + {requisitionId || rfqId ? ( +
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+ ) : ( + <> + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + )} +
+ + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + + + + value={line.warehouseId} onValueChange={(v) => updateLine(line.key, { warehouseId: v })}> + + + + + {(warehouses ?? []).map((w) => ( + + {w.code} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { unitPrice: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { tax: e.target.value })} + className="h-11 text-base" + /> + + + + + +
+ ) + })} +
+
+
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewPurchaseOrderPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx new file mode 100644 index 0000000..d399fa2 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/page.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, Plus, ShoppingCart } from "lucide-react" + +import { purchaseOrdersApi } from "@/lib/api/purchase-orders" +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage } from "@/lib/error-map" +import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement" +import { Vendor } from "@/types/master-data" +import { PaginationMeta } from "@/types/common" +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { PoStatusBadge } from "@/components/procurement/status-badges" + +type StatusFilter = PurchaseOrderStatus | "All" + +const PAGE_SIZE = 10 + +export default function PurchaseOrdersListPage() { + const [pos, setPos] = useState(null) + const [vendors, setVendors] = useState([]) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("All") + const [page, setPage] = useState(1) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status]) + + function load() { + setError(null) + purchaseOrdersApi + .list({ page, pageSize: PAGE_SIZE, q: query || undefined, status: status === "All" ? undefined : status }) + .then((res) => { + setPos(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page, query, status]) + useEffect(() => { + vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {}) + }, []) + + function vendorCode(vendorId: number) { + return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}` + } + + const hasFilters = query.length > 0 || status !== "All" + + return ( +
+
+
+

Purchase Orders

+

Auto-approved on creation and freely editable while open (FR-PROC-03..05).

+
+ + + New PO + +
+ +
+ setSearchInput(e.target.value)} + placeholder="Search doc no., vendor…" + className="h-12 w-full flex-1 basis-0 text-base" + aria-label="Search purchase orders" + /> + + {hasFilters && ( + + )} +
+ + {error && ( +
{error}
+ )} + + {!error && pos === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && pos !== null && pos.length === 0 && ( +
+ +

{hasFilters ? "No purchase orders match your search/filter." : "No purchase orders yet."}

+
+ )} + + {!error && pos !== null && pos.length > 0 && ( + <> + + + + Doc No + Vendor + Status + Grand total + Created + + + + {pos.map((po) => ( + + + + {po.docNo} + + + {vendorCode(po.vendorId)} + + + + {po.totals.currency} {po.totals.grandTotal.toFixed(2)} + {new Date(po.createdAt).toLocaleString()} + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx new file mode 100644 index 0000000..324cfe1 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -0,0 +1,286 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft } from "lucide-react" + +import { purchaseReturnsApi } from "@/lib/api/purchase-returns" +import { grnsApi } from "@/lib/api/grns" +import { reasonCodesApi } from "@/lib/api/reason-codes" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateReturnLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreatePurchaseReturnLineInput } from "@/types/procurement" +import { Grn, GrnLine } from "@/types/grn" +import { ItemListItem } from "@/types/master-data" +import { ReasonCode } from "@/types/stock" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" +import { HoldStatusBadge } from "@/components/receiving/status-badges" + +interface LineState { + selected: boolean + qty: string +} + +function NewPurchaseReturnContent() { + const router = useRouter() + const searchParams = useSearchParams() + const presetGrnId = Number(searchParams.get("grnId")) || null + const presetGrnLineId = Number(searchParams.get("grnLineId")) || null + + const [grns, setGrns] = useState(null) + const [items, setItems] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [grnId, setGrnId] = useState(presetGrnId) + const [reasonCodeId, setReasonCodeId] = useState(null) + const [lineState, setLineState] = useState>({}) + const [lineErrors, setLineErrors] = useState>>({}) + + const [headerError, setHeaderError] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")]) + .then(([grnList, it, rc]) => { + // Only Confirmed/Closed GRNs have posted stock layers to return against. + Promise.all(grnList.items.filter((g) => g.status === "Confirmed" || g.status === "Closed").map((g) => grnsApi.get(g.grnId))).then(setGrns) + setItems(it.items) + setReasonCodes(rc.items) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, []) + + const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null + + useEffect(() => { + if (!selectedGrn) { + setLineState({}) + return + } + const next: Record = {} + for (const line of selectedGrn.lines) { + next[line.grnLineId] = { + selected: presetGrnLineId ? line.grnLineId === presetGrnLineId : false, + qty: presetGrnLineId && line.grnLineId === presetGrnLineId ? String(line.qty) : "", + } + } + setLineState(next) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedGrn?.grnId]) + + function itemFor(itemId: number) { + return items.find((i) => i.itemId === itemId) + } + + function toggleLine(line: GrnLine) { + setLineState((prev) => ({ + ...prev, + [line.grnLineId]: { selected: !prev[line.grnLineId]?.selected, qty: prev[line.grnLineId]?.qty || String(line.qty) }, + })) + } + + function setQty(grnLineId: number, qty: string) { + setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty } })) + } + + async function handleSubmit() { + setHeaderError(null) + setSubmitError(null) + + if (!selectedGrn) { + setHeaderError("Select a GRN to return against.") + return + } + if (!reasonCodeId) { + setHeaderError("Select a reason code.") + return + } + + const selectedLines = selectedGrn.lines.filter((l) => lineState[l.grnLineId]?.selected) + if (selectedLines.length === 0) { + setSubmitError("Select at least one line to return.") + return + } + + const nextErrors: Record> = {} + for (const line of selectedLines) { + const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty }) + if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors + } + setLineErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreatePurchaseReturnLineInput[] = selectedLines.map((l) => ({ + grnLineId: l.grnLineId, + itemId: l.itemId, + qty: Number(lineState[l.grnLineId].qty), + })) + + setSubmitting(true) + try { + const purchaseReturn = await purchaseReturnsApi.create({ + vendorId: selectedGrn.vendorId, + warehouseId: selectedGrn.warehouseId, + reasonCodeId, + lines: payloadLines, + }) + toast.success("Purchase return posted", `${purchaseReturn.docNo} — ${purchaseReturn.ledgerRefs.length} ledger entr${purchaseReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`) + router.push("/dashboard/procurement/purchase-returns") + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not post purchase return", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const loading = !grns + + return ( +
+
+ + + +
+

New Purchase Return

+

Return received goods to the vendor; posts an outbound ledger entry immediately (FR-PROC-08).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}> + + + + + {(grns ?? []).map((g) => ( + + {g.docNo} — Vendor #{g.vendorId}, Warehouse #{g.warehouseId} + + ))} + + +
+
+ + value={reasonCodeId} onValueChange={setReasonCodeId}> + + + + + {reasonCodes.map((rc) => ( + + {rc.description} + + ))} + + +
+
+ + {headerError && ( +
{headerError}
+ )} + + {selectedGrn && ( +
+

Lines received on {selectedGrn.docNo}

+ + + + + Item + Received qty + Hold status + Return qty + + + + {selectedGrn.lines.map((line) => { + const item = itemFor(line.itemId) + const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } + const errors = lineErrors[line.grnLineId] ?? {} + return ( + + + toggleLine(line)} /> + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + + + + + setQty(line.grnLineId, e.target.value)} + className="h-11 text-base" + /> + + + + ) + })} + +
+
+ )} + + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewPurchaseReturnPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx new file mode 100644 index 0000000..62a0694 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/page.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { PackageX, Plus } from "lucide-react" + +import { purchaseReturnsApi } from "@/lib/api/purchase-returns" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" +import { reasonCodesApi } from "@/lib/api/reason-codes" +import { errorMessage } from "@/lib/error-map" +import { PurchaseReturnSummary } from "@/types/procurement" +import { Vendor, Warehouse } from "@/types/master-data" +import { ReasonCode } from "@/types/stock" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +export default function PurchaseReturnsListPage() { + const [returns, setReturns] = useState(null) + const [vendors, setVendors] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([purchaseReturnsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list(), reasonCodesApi.list("Return")]) + .then(([r, v, w, rc]) => { + setReturns(r.items) + setVendors(v.items) + setWarehouses(w.items) + setReasonCodes(rc.items) + }) + .catch((err) => setError(errorMessage(err))) + }, []) + + function vendorCode(id: number) { + return vendors.find((v) => v.vendorId === id)?.code ?? `#${id}` + } + function warehouseCode(id: number) { + return warehouses.find((w) => w.warehouseId === id)?.code ?? `#${id}` + } + function reasonLabel(id: number) { + return reasonCodes.find((r) => r.reasonCodeId === id)?.description ?? `#${id}` + } + + return ( +
+
+
+

Purchase Returns

+

Return received goods to a vendor, referencing the original GRN line (FR-PROC-08).

+
+ + + New Return + +
+ + {error && ( +
{error}
+ )} + + {!error && returns === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && returns !== null && returns.length === 0 && ( +
+ +

No purchase returns yet.

+ + + New Return + +
+ )} + + {!error && returns !== null && returns.length > 0 && ( + + + + Doc No + Vendor + Warehouse + Reason + Status + Created + + + + {returns.map((r) => ( + + {r.docNo} + {vendorCode(r.vendorId)} + {warehouseCode(r.warehouseId)} + {reasonLabel(r.reasonCodeId)} + + + {r.status} + + + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx new file mode 100644 index 0000000..1dbf471 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/[id]/page.tsx @@ -0,0 +1,144 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react" + +import { requisitionsApi } from "@/lib/api/requisitions" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { Requisition } from "@/types/procurement" +import { ItemListItem } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" +import { RequisitionStatusBadge } from "@/components/procurement/status-badges" + +export default function RequisitionDetailPage() { + const params = useParams<{ id: string }>() + const requisitionId = Number(params.id) + + const [requisition, setRequisition] = useState(null) + const [items, setItems] = useState([]) + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function load() { + setError(null) + requisitionsApi.get(requisitionId).then(setRequisition).catch((err) => setError(errorMessage(err))) + } + + useEffect(() => { + if (!Number.isFinite(requisitionId)) return + load() + itemsApi.list({ pageSize: 200 }).then((res) => setItems(res.items)).catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requisitionId]) + + function itemFor(itemId: number) { + return items.find((i) => i.itemId === itemId) + } + + async function handleSubmit() { + if (!requisition) return + setSubmitting(true) + try { + const updated = await requisitionsApi.submit(requisition.requisitionId) + setRequisition(updated) + toast.success("Requisition submitted", `${updated.docNo} is ready for RFQ or a direct PO.`) + } catch (err) { + toast.error("Could not submit requisition", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + if (error && !requisition) { + return
{error}
+ } + + if (!requisition) { + return ( +
+ + +
+ ) + } + + return ( +
+
+
+ + + +
+
+

{requisition.docNo}

+ +
+

Requested by #{requisition.requestedBy} — {new Date(requisition.createdAt).toLocaleString()}

+
+
+ +
+ {requisition.status === "Draft" && ( + + )} + {requisition.status === "Submitted" && ( + <> + + + Create RFQ + + + + Create PO + + + )} +
+
+ + {error && ( +
{error}
+ )} + + + + + Item + Qty + Required by + + + + {requisition.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + {line.requiredBy} + + ) + })} + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx new file mode 100644 index 0000000..a6143d3 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/new/page.tsx @@ -0,0 +1,213 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" + +import { requisitionsApi } from "@/lib/api/requisitions" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateRequisitionLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreateReqLineInput } from "@/types/procurement" +import { ItemListItem } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface DraftLine { + key: string + itemId: number | null + qty: string + requiredBy: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `rline-${keySeq}` +} + +function emptyLine(): DraftLine { + return { key: newKey(), itemId: null, qty: "", requiredBy: "" } +} + +export default function NewRequisitionPage() { + const router = useRouter() + + const [items, setItems] = useState(null) + const [loadError, setLoadError] = useState(null) + const [lines, setLines] = useState([emptyLine()]) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + itemsApi.list({ pageSize: 200, status: "Active" }).then((res) => setItems(res.items)).catch((err) => setLoadError(errorMessage(err))) + }, []) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSubmit() { + setSubmitError(null) + + if (lines.length === 0) { + setSubmitError("Add at least one line.") + return + } + + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validateRequisitionLine({ itemId: line.itemId, qty: line.qty, requiredBy: line.requiredBy }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreateReqLineInput[] = lines.map((l) => ({ + itemId: l.itemId as number, + qty: Number(l.qty), + requiredBy: l.requiredBy, + })) + + setSubmitting(true) + try { + const requisition = await requisitionsApi.create({ lines: payloadLines }) + toast.success("Requisition created", `${requisition.docNo} is a draft — submit it when ready.`) + router.push(`/dashboard/procurement/requisitions/${requisition.requisitionId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not create requisition", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const loading = !items + + return ( +
+
+ + + +
+

New Requisition

+

Request items for procurement; submit once the lines are ready (FR-PROC-01).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+

Lines

+ +
+ + {lines.length > 0 && ( + + + + Item + Qty + Required by + + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { requiredBy: e.target.value })} + className="h-11 text-base" + /> + + + + + + + ) + })} + +
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx new file mode 100644 index 0000000..27d444c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/requisitions/page.tsx @@ -0,0 +1,151 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ClipboardList, Plus } from "lucide-react" + +import { requisitionsApi } from "@/lib/api/requisitions" +import { errorMessage } from "@/lib/error-map" +import { RequisitionStatus, RequisitionSummary } from "@/types/procurement" +import { PaginationMeta } from "@/types/common" +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { RequisitionStatusBadge } from "@/components/procurement/status-badges" + +type StatusFilter = RequisitionStatus | "All" + +const PAGE_SIZE = 10 + +export default function RequisitionsListPage() { + const [requisitions, setRequisitions] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [status, setStatus] = useState("All") + const [page, setPage] = useState(1) + + useEffect(() => setPage(1), [status]) + + function load() { + setError(null) + requisitionsApi + .list({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status }) + .then((res) => { + setRequisitions(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page, status]) + + return ( +
+
+
+

Requisitions

+

Raise a purchase requisition and submit it into procurement (FR-PROC-01).

+
+ + + New Requisition + +
+ +
+ +
+ + {error && ( +
{error}
+ )} + + {!error && requisitions === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && requisitions !== null && requisitions.length === 0 && ( +
+ +

No requisitions yet.

+ + + New Requisition + +
+ )} + + {!error && requisitions !== null && requisitions.length > 0 && ( + <> + + + + Doc No + Status + Lines + Requested by + Created + + + + {requisitions.map((r) => ( + + + + {r.docNo} + + + + + + {r.lineCount} + #{r.requestedBy} + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx new file mode 100644 index 0000000..a60d775 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/[id]/page.tsx @@ -0,0 +1,332 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, ShoppingCart } from "lucide-react" + +import { rfqsApi } from "@/lib/api/rfqs" +import { vendorsApi } from "@/lib/api/vendors" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateQuotationLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { QuotationLine, Rfq, RfqComparison } from "@/types/procurement" +import { ItemListItem, Vendor } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" +import { RfqStatusBadge } from "@/components/procurement/status-badges" + +interface QuoteDraft { + unitPrice: string + leadDays: string +} + +export default function RfqDetailPage() { + const params = useParams<{ id: string }>() + const rfqId = Number(params.id) + + const [rfq, setRfq] = useState(null) + const [comparison, setComparison] = useState(null) + const [items, setItems] = useState([]) + const [vendors, setVendors] = useState([]) + const [error, setError] = useState(null) + + const [quoteVendorId, setQuoteVendorId] = useState(null) + const [quoteLines, setQuoteLines] = useState>({}) + const [quoteErrors, setQuoteErrors] = useState>>({}) + const [quoteFormError, setQuoteFormError] = useState(null) + const [submittingQuote, setSubmittingQuote] = useState(false) + + function load() { + setError(null) + Promise.all([rfqsApi.get(rfqId), rfqsApi.comparison(rfqId)]) + .then(([r, c]) => { + setRfq(r) + setComparison(c) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(() => { + if (!Number.isFinite(rfqId)) return + load() + Promise.all([itemsApi.list({ pageSize: 200 }), vendorsApi.list({ pageSize: 200 })]) + .then(([it, ve]) => { + setItems(it.items) + setVendors(ve.items) + }) + .catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rfqId]) + + const quotedVendorIds = useMemo(() => { + const set = new Set() + for (const line of comparison?.lines ?? []) for (const cell of line.cells) set.add(cell.vendorId) + return set + }, [comparison]) + + const pendingVendors = useMemo(() => (rfq ? rfq.vendorIds.filter((id) => !quotedVendorIds.has(id)) : []), [rfq, quotedVendorIds]) + + function itemFor(itemId: number) { + return items.find((i) => i.itemId === itemId) + } + function vendorFor(vendorId: number) { + return vendors.find((v) => v.vendorId === vendorId) + } + + function selectQuoteVendor(vendorId: number | null) { + setQuoteVendorId(vendorId) + setQuoteFormError(null) + setQuoteErrors({}) + if (!rfq) return + const draft: Record = {} + for (const line of rfq.lines) draft[line.itemId] = { unitPrice: "", leadDays: "" } + setQuoteLines(draft) + } + + async function handleSubmitQuote() { + if (!rfq || !quoteVendorId) { + setQuoteFormError("Select a vendor first.") + return + } + const nextErrors: Record> = {} + for (const line of rfq.lines) { + const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" } + const errors = validateQuotationLine(draft) + if (Object.keys(errors).length > 0) nextErrors[line.itemId] = errors + } + setQuoteErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + setQuoteFormError("Fix the highlighted fields before submitting.") + return + } + + const lines: QuotationLine[] = rfq.lines.map((l) => ({ + itemId: l.itemId, + unitPrice: Number(quoteLines[l.itemId].unitPrice), + leadDays: Number(quoteLines[l.itemId].leadDays), + })) + + setSubmittingQuote(true) + try { + await rfqsApi.addQuotation(rfqId, { vendorId: quoteVendorId, lines }) + toast.success("Quotation recorded", `${vendorFor(quoteVendorId)?.code ?? `Vendor #${quoteVendorId}`} priced ${lines.length} line(s).`) + setQuoteVendorId(null) + setQuoteLines({}) + const c = await rfqsApi.comparison(rfqId) + setComparison(c) + } catch (err) { + setQuoteFormError(errorMessage(err)) + toast.error("Could not record quotation", errorMessage(err)) + } finally { + setSubmittingQuote(false) + } + } + + if (error && !rfq) { + return
{error}
+ } + + if (!rfq || !comparison) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ + + +
+
+

{rfq.docNo}

+ +
+

+ {rfq.requisitionId ? `From Requisition #${rfq.requisitionId} — ` : ""} + Invited: {rfq.vendorIds.map((id) => vendorFor(id)?.code ?? `#${id}`).join(", ")} +

+
+
+ + {error && ( +
{error}
+ )} + +
+

Lines

+ + + + Item + Qty + + + + {rfq.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {line.qty} + + ) + })} + +
+
+ +
+

Vendor comparison

+ {comparison.lines.every((l) => l.cells.length === 0) ? ( +

No quotations recorded yet.

+ ) : ( +
+ + + + Item + {rfq.vendorIds.map((vid) => ( + {vendorFor(vid)?.code ?? `#${vid}`} + ))} + + + + {comparison.lines.map((line) => { + const item = itemFor(line.itemId) + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {rfq.vendorIds.map((vid) => { + const cell = line.cells.find((c) => c.vendorId === vid) + return ( + + {cell ? ( + + {cell.unitPrice.toFixed(2)} ({cell.leadDays}d) + + ) : ( + + )} + + ) + })} + + ) + })} + +
+
+ )} + + {[...quotedVendorIds].length > 0 && ( +
+ {[...quotedVendorIds].map((vid) => ( + + + Create PO from {vendorFor(vid)?.code ?? `#${vid}`} + + ))} +
+ )} +
+ + {pendingVendors.length > 0 && ( +
+

Record a quotation

+ +
+ + value={quoteVendorId} onValueChange={selectQuoteVendor}> + + + + + {pendingVendors.map((vid) => ( + + {vendorFor(vid)?.code ?? `#${vid}`} — {vendorFor(vid)?.name} + + ))} + + +
+ + {quoteVendorId && ( + + + + Item + Unit price + Lead days + + + + {rfq.lines.map((line) => { + const item = itemFor(line.itemId) + const draft = quoteLines[line.itemId] ?? { unitPrice: "", leadDays: "" } + const errors = quoteErrors[line.itemId] ?? {} + return ( + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + + setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], unitPrice: e.target.value } }))} + className="h-11 text-base" + /> + + + + setQuoteLines((prev) => ({ ...prev, [line.itemId]: { ...prev[line.itemId], leadDays: e.target.value } }))} + className="h-11 text-base" + /> + + + + ) + })} + +
+ )} + + {quoteFormError && ( +
{quoteFormError}
+ )} + +
+ +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx new file mode 100644 index 0000000..ee05136 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/new/page.tsx @@ -0,0 +1,280 @@ +"use client" + +import { Suspense, useEffect, useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" + +import { rfqsApi } from "@/lib/api/rfqs" +import { requisitionsApi } from "@/lib/api/requisitions" +import { vendorsApi } from "@/lib/api/vendors" +import { itemsApi } from "@/lib/api/items" +import { errorMessage } from "@/lib/error-map" +import { validateRfqLine } from "@/lib/validations/procurement" +import { cn } from "@/lib/utils" +import { CreateRfqLineInput } from "@/types/procurement" +import { ItemListItem, Vendor } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface DraftLine { + key: string + itemId: number | null + qty: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `rfqline-${keySeq}` +} + +function emptyLine(): DraftLine { + return { key: newKey(), itemId: null, qty: "" } +} + +function NewRfqContent() { + const router = useRouter() + const searchParams = useSearchParams() + const requisitionId = Number(searchParams.get("requisitionId")) || null + + const [items, setItems] = useState(null) + const [vendors, setVendors] = useState(null) + const [loadError, setLoadError] = useState(null) + const [requisitionLoading, setRequisitionLoading] = useState(!!requisitionId) + + const [vendorIds, setVendorIds] = useState>(new Set()) + const [lines, setLines] = useState([emptyLine()]) + const [lineErrors, setLineErrors] = useState>>({}) + const [headerError, setHeaderError] = useState(null) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), vendorsApi.list({ pageSize: 200, status: "Active" })]) + .then(([it, ve]) => { + setItems(it.items) + setVendors(ve.items) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, []) + + useEffect(() => { + if (!requisitionId) return + requisitionsApi + .get(requisitionId) + .then((req) => { + setLines(req.lines.map((l): DraftLine => ({ key: newKey(), itemId: l.itemId, qty: String(l.qty) }))) + }) + .catch((err) => setHeaderError(errorMessage(err))) + .finally(() => setRequisitionLoading(false)) + }, [requisitionId]) + + function toggleVendor(vendorId: number) { + setVendorIds((prev) => { + const next = new Set(prev) + if (next.has(vendorId)) next.delete(vendorId) + else next.add(vendorId) + return next + }) + } + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSubmit() { + setHeaderError(null) + setSubmitError(null) + + if (vendorIds.size === 0) { + setHeaderError("Select at least one vendor to invite.") + return + } + if (lines.length === 0) { + setSubmitError("Add at least one line.") + return + } + + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validateRfqLine({ itemId: line.itemId, qty: line.qty }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreateRfqLineInput[] = lines.map((l) => ({ itemId: l.itemId as number, qty: Number(l.qty) })) + + setSubmitting(true) + try { + const rfq = await rfqsApi.create({ requisitionId, vendorIds: [...vendorIds], lines: payloadLines }) + toast.success("RFQ created", `${rfq.docNo} sent to ${vendorIds.size} vendor(s).`) + router.push(`/dashboard/procurement/rfqs/${rfq.rfqId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not create RFQ", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + function itemFor(itemId: number | null) { + return items?.find((i) => i.itemId === itemId) ?? null + } + + const loading = !items || !vendors || requisitionLoading + + return ( +
+
+ + + +
+

New RFQ

+

+ {requisitionId ? `Request quotations for Requisition #${requisitionId}` : "Request quotations from one or more vendors (FR-PROC-02)."} +

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+ +
+ {(vendors ?? []).map((v) => ( + + ))} +
+
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ {!requisitionId && ( + + )} +
+ + {lines.length > 0 && ( + + + + Item + Qty + {!requisitionId && } + + + + {lines.map((line) => { + const errors = lineErrors[line.key] ?? {} + const item = itemFor(line.itemId) + return ( + + + {requisitionId ? ( +
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
+ ) : ( + <> + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + )} +
+ + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + {!requisitionId && ( + + + + )} +
+ ) + })} +
+
+ )} +
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} + +export default function NewRfqPage() { + return ( + }> + + + ) +} diff --git a/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx new file mode 100644 index 0000000..6cc1f9c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/procurement/rfqs/page.tsx @@ -0,0 +1,104 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { FileText, Plus } from "lucide-react" + +import { rfqsApi } from "@/lib/api/rfqs" +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage } from "@/lib/error-map" +import { RfqSummary } from "@/types/procurement" +import { Vendor } from "@/types/master-data" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { RfqStatusBadge } from "@/components/procurement/status-badges" + +export default function RfqsListPage() { + const [rfqs, setRfqs] = useState(null) + const [vendors, setVendors] = useState([]) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([rfqsApi.list(), vendorsApi.list({ pageSize: 200 })]) + .then(([r, v]) => { + setRfqs(r.items) + setVendors(v.items) + }) + .catch((err) => setError(errorMessage(err))) + }, []) + + function vendorNames(vendorIds: number[]) { + return vendorIds.map((id) => vendors.find((v) => v.vendorId === id)?.code ?? `#${id}`).join(", ") + } + + return ( +
+
+
+

RFQs

+

Request quotations from vendors and compare pricing (FR-PROC-02).

+
+ + + New RFQ + +
+ + {error && ( +
{error}
+ )} + + {!error && rfqs === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && rfqs !== null && rfqs.length === 0 && ( +
+ +

No RFQs yet.

+ + + New RFQ + +
+ )} + + {!error && rfqs !== null && rfqs.length > 0 && ( + + + + Doc No + Requisition + Vendors invited + Status + Created + + + + {rfqs.map((r) => ( + + + + {r.docNo} + + + {r.requisitionId ? `#${r.requisitionId}` : } + {vendorNames(r.vendorIds)} + + + + {new Date(r.createdAt).toLocaleString()} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx new file mode 100644 index 0000000..94cd29d --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -0,0 +1,591 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { AlertTriangle, ArrowLeft, Plus, Save, Trash2 } from "lucide-react" + +import { itemsApi } from "@/lib/api/items" +import { categoriesApi } from "@/lib/api/categories" +import { uomsApi } from "@/lib/api/uoms" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { validateConversionLine, validateItemForm, validateReorderLine } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Item, ItemReorderSetting, ItemType, TrackingMode, UomConversion } from "@/types/master-data" + +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface ReorderDraft { + key: string + warehouseId: number | null + reorderPoint: string + reorderQty: string +} + +interface ConversionDraft { + key: string + fromUom: number | null + toUom: number | null + factor: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `row-${keySeq}` +} + +export default function ItemDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const itemId = Number(params.id) + + const [item, setItem] = useState(null) + const [etag, setEtag] = useState(null) + const [categories, setCategories] = useState<{ categoryId: number; name: string }[]>([]) + const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) + const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[]>([]) + const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([]) + const [loadError, setLoadError] = useState(null) + + // Basic info form + const [sku, setSku] = useState("") + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [categoryId, setCategoryId] = useState(null) + const [baseUomId, setBaseUomId] = useState(null) + const [defaultVendorId, setDefaultVendorId] = useState(null) + const [itemType, setItemType] = useState("Stocked") + const [trackingMode, setTrackingMode] = useState("None") + const [taxClass, setTaxClass] = useState("") + + const [errors, setErrors] = useState>({}) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + const [togglingStatus, setTogglingStatus] = useState(false) + + // Reorder settings + const [reorderLines, setReorderLines] = useState([]) + const [reorderErrors, setReorderErrors] = useState>>({}) + const [reorderSaveError, setReorderSaveError] = useState(null) + const [savingReorder, setSavingReorder] = useState(false) + + // UOM conversions + const [conversionLines, setConversionLines] = useState([]) + const [conversionErrors, setConversionErrors] = useState>>({}) + const [conversionSaveError, setConversionSaveError] = useState(null) + const [savingConversions, setSavingConversions] = useState(false) + + function applyItem(data: Item) { + setItem(data) + setSku(data.sku) + setName(data.name) + setDescription(data.description ?? "") + setCategoryId(data.categoryId) + setBaseUomId(data.baseUomId) + setDefaultVendorId(data.defaultVendorId) + setItemType(data.itemType) + setTrackingMode(data.trackingMode) + setTaxClass(data.taxClass ?? "") + setReorderLines(data.reorder.map((r): ReorderDraft => ({ key: newKey(), warehouseId: r.warehouseId, reorderPoint: String(r.reorderPoint), reorderQty: String(r.reorderQty) }))) + setConversionLines(data.conversions.map((c): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) }))) + } + + function load() { + setLoadError(null) + itemsApi + .get(itemId) + .then(({ data, etag: tag }) => { + applyItem(data) + setEtag(tag) + setConflict(false) + }) + .catch((err) => setLoadError(errorMessage(err))) + } + + useEffect(() => { + if (!Number.isFinite(itemId)) return + load() + Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200 }), warehousesApi.list()]) + .then(([cat, uo, ve, wh]) => { + setCategories(cat.items) + setUoms(uo.items) + setVendors(ve.items) + setWarehouses(wh.items) + }) + .catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [itemId]) + + async function handleSave() { + if (!item || !etag) return + setSaveError(null) + const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSaving(true) + try { + const result = await itemsApi.update( + item.itemId, + { sku, name, description: description || null, categoryId: categoryId as number, baseUomId: baseUomId as number, defaultVendorId, itemType, trackingMode, taxClass: taxClass || null }, + etag + ) + applyItem(result.data) + setEtag(result.etag) + toast.success("Item saved", `${result.data.sku} — ${result.data.name}`) + } catch (err) { + const code = (err as { code?: string })?.code + if (code === "CONCURRENCY_CONFLICT") { + setConflict(true) + setSaveError(errorMessage(err)) + setSaving(false) + return + } + const fe = fieldErrors(err) + if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku })) + setSaveError(errorMessage(err)) + toast.error("Could not save item", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleToggleStatus() { + if (!item) return + const next = item.status === "Active" ? "Inactive" : "Active" + setTogglingStatus(true) + try { + await itemsApi.updateStatus(item.itemId, next) + toast.success(next === "Active" ? "Item activated" : "Item deactivated") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setTogglingStatus(false) + } + } + + function updateReorderLine(key: string, patch: Partial) { + setReorderLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + function removeReorderLine(key: string) { + setReorderLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSaveReorder() { + if (!item) return + setReorderSaveError(null) + const nextErrors: Record> = {} + for (const line of reorderLines) { + const errs = validateReorderLine({ warehouseId: line.warehouseId, reorderPoint: line.reorderPoint, reorderQty: line.reorderQty }) + if (Object.keys(errs).length > 0) nextErrors[line.key] = errs + } + setReorderErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + setReorderSaveError("Fix the highlighted rows before saving.") + return + } + + const settings: ItemReorderSetting[] = reorderLines.map((l) => ({ + warehouseId: l.warehouseId as number, + reorderPoint: Number(l.reorderPoint), + reorderQty: Number(l.reorderQty), + })) + + setSavingReorder(true) + try { + const result = await itemsApi.updateReorder(item.itemId, { settings }) + setItem((prev) => (prev ? { ...prev, reorder: result.settings } : prev)) + toast.success("Reorder settings saved") + } catch (err) { + setReorderSaveError(errorMessage(err)) + toast.error("Could not save reorder settings", errorMessage(err)) + } finally { + setSavingReorder(false) + } + } + + function updateConversionLine(key: string, patch: Partial) { + setConversionLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + function removeConversionLine(key: string) { + setConversionLines((prev) => prev.filter((l) => l.key !== key)) + } + + async function handleSaveConversions() { + if (!item) return + setConversionSaveError(null) + const nextErrors: Record> = {} + for (const line of conversionLines) { + const errs = validateConversionLine({ fromUom: line.fromUom, toUom: line.toUom, factor: line.factor }) + if (Object.keys(errs).length > 0) nextErrors[line.key] = errs + } + setConversionErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + setConversionSaveError("Fix the highlighted rows before saving.") + return + } + + const conversions = conversionLines.map((l) => ({ fromUom: l.fromUom as number, toUom: l.toUom as number, factor: Number(l.factor) })) + + setSavingConversions(true) + try { + const result = await itemsApi.updateUomConversions(item.itemId, { conversions }) + setItem((prev) => (prev ? { ...prev, conversions: result.conversions } : prev)) + setConversionLines(result.conversions.map((c: UomConversion): ConversionDraft => ({ key: newKey(), fromUom: c.fromUom, toUom: c.toUom, factor: String(c.factor) }))) + toast.success("UOM conversions saved") + } catch (err) { + setConversionSaveError(errorMessage(err)) + toast.error("Could not save UOM conversions", errorMessage(err)) + } finally { + setSavingConversions(false) + } + } + + function uomName(uomId: number) { + return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}` + } + + if (loadError && !item) { + return ( +
+
{loadError}
+ + Back to items + +
+ ) + } + + if (!item) { + return ( +
+ + +
+ ) + } + + return ( +
+
+
+ + + +
+
+

{item.sku}

+ + {item.status} + +
+

{item.name}

+
+
+ + +
+ + {conflict && ( +
+ +
+

{saveError ?? "This item was changed by someone else."} Reload before retrying.

+ +
+
+ )} + + {saveError && !conflict && ( +
{saveError}
+ )} + +
+

Basic info

+
+
+ + setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} /> + +
+
+ + setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> + +
+
+ + setDescription(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + value={categoryId} onValueChange={setCategoryId} disabled={conflict}> + + + + + {categories.map((c) => ( + + {c.name} + + ))} + + + +
+
+ + value={baseUomId} onValueChange={setBaseUomId} disabled={conflict}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + +
+
+ + value={defaultVendorId} onValueChange={setDefaultVendorId} disabled={conflict}> + + + + + {vendors.map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+
+ + setTaxClass(e.target.value)} className="h-12 text-base" disabled={conflict} /> +
+
+ + value={itemType} onValueChange={(v) => v && setItemType(v)} disabled={conflict}> + + + + + Stocked + Non-stocked + Service + + +
+
+ + value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)} disabled={conflict}> + + + + + None + Batch + Serial + + +
+
+
+ + +
+
+ +
+
+
+

Reorder settings

+

Per-warehouse reorder point and quantity (FR-MD-05).

+
+ +
+ + {reorderLines.length > 0 && ( + + + + Warehouse + Reorder point + Reorder qty + + + + + {reorderLines.map((line) => { + const errs = reorderErrors[line.key] ?? {} + return ( + + + value={line.warehouseId} onValueChange={(v) => updateReorderLine(line.key, { warehouseId: v })}> + + + + + {warehouses.map((w) => ( + + {w.code} + + ))} + + + + + + updateReorderLine(line.key, { reorderPoint: e.target.value })} className="h-11 text-base" /> + + + + updateReorderLine(line.key, { reorderQty: e.target.value })} className="h-11 text-base" /> + + + + + + + ) + })} + +
+ )} + + {reorderSaveError && ( +
{reorderSaveError}
+ )} + +
+ +
+
+ +
+
+
+

UOM conversions

+

Purchase/stock UOM → base UOM conversion factors (FR-MD-02/03).

+
+ +
+ + {conversionLines.length > 0 && ( + + + + From UOM + To UOM + Factor + + + + + {conversionLines.map((line) => { + const errs = conversionErrors[line.key] ?? {} + return ( + + + value={line.fromUom} onValueChange={(v) => updateConversionLine(line.key, { fromUom: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + value={line.toUom} onValueChange={(v) => updateConversionLine(line.key, { toUom: v })}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + + + + + updateConversionLine(line.key, { factor: e.target.value })} className="h-11 text-base" /> + + + + + + + ) + })} + +
+ )} + + {conversionSaveError && ( +
{conversionSaveError}
+ )} + +
+ +
+
+ +

+ {uomName(item.baseUomId)} is the base UOM — every transaction converts to and stores quantities in base UOM (FR-MD-03). {warehouses.length === 0 && "No warehouses configured yet."} +

+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx new file mode 100644 index 0000000..19037b4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -0,0 +1,166 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, ListTree, Plus } from "lucide-react" + +import { categoriesApi } from "@/lib/api/categories" +import { errorMessage } from "@/lib/error-map" +import { validateCategoryName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Category, CategoryTreeNode } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +function TreeNode({ node, depth }: { node: CategoryTreeNode; depth: number }) { + return ( +
+
+ + {node.name} + #{node.categoryId} +
+ {node.children.map((child) => ( + + ))} +
+ ) +} + +export default function CategoriesPage() { + const [tree, setTree] = useState(null) + const [flat, setFlat] = useState([]) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [parentId, setParentId] = useState(null) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + setError(null) + Promise.all([categoriesApi.tree(), categoriesApi.list()]) + .then(([t, f]) => { + setTree(t) + setFlat(f.items) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + async function handleCreate() { + const nextErrors = validateCategoryName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const category = await categoriesApi.create({ name, parentId }) + toast.success("Category created", category.name) + setOpen(false) + setName("") + setParentId(null) + setErrors({}) + load() + } catch (err) { + setErrors({ name: errorMessage(err) }) + toast.error("Could not create category", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+ + + +
+

Categories

+

Hierarchical item category structure (FR-MD-04).

+
+
+ + + New Category} /> + + + New category + Optionally nest it under an existing category. + + + + Name + setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> + + + + Parent (optional) + value={parentId} onValueChange={setParentId}> + + + + + {flat.map((c) => ( + + {c.name} + + ))} + + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && tree === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && tree !== null && tree.length === 0 && ( +
+ +

No categories yet.

+
+ )} + + {!error && tree !== null && tree.length > 0 && ( +
+ {tree.map((node) => ( + + ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx new file mode 100644 index 0000000..eda1169 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft } from "lucide-react" + +import { itemsApi } from "@/lib/api/items" +import { categoriesApi } from "@/lib/api/categories" +import { uomsApi } from "@/lib/api/uoms" +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { validateItemForm } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { ItemType, TrackingMode } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +export default function NewItemPage() { + const router = useRouter() + + const [categories, setCategories] = useState<{ categoryId: number; name: string }[] | null>(null) + const [uoms, setUoms] = useState<{ uomId: number; name: string }[] | null>(null) + const [vendors, setVendors] = useState<{ vendorId: number; code: string; name: string }[] | null>(null) + const [loadError, setLoadError] = useState(null) + + const [sku, setSku] = useState("") + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [categoryId, setCategoryId] = useState(null) + const [baseUomId, setBaseUomId] = useState(null) + const [defaultVendorId, setDefaultVendorId] = useState(null) + const [itemType, setItemType] = useState("Stocked") + const [trackingMode, setTrackingMode] = useState("None") + const [taxClass, setTaxClass] = useState("STD") + + const [errors, setErrors] = useState>({}) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + Promise.all([categoriesApi.list(), uomsApi.list(), vendorsApi.list({ pageSize: 200, status: "Active" })]) + .then(([cat, uo, ve]) => { + setCategories(cat.items) + setUoms(uo.items) + setVendors(ve.items) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, []) + + async function handleSubmit() { + setSubmitError(null) + const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId }) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const { data: item } = await itemsApi.create({ + sku, + name, + description: description || null, + categoryId: categoryId as number, + baseUomId: baseUomId as number, + defaultVendorId, + itemType, + trackingMode, + taxClass: taxClass || null, + }) + toast.success("Item created", `${item.sku} — ${item.name}`) + router.push(`/dashboard/products/${item.itemId}`) + } catch (err) { + const fe = fieldErrors(err) + if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku })) + setSubmitError(errorMessage(err)) + toast.error("Could not create item", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const loading = !categories || !uoms || !vendors + + return ( +
+
+ + + +
+

New Item

+

SKU, category, base UOM, item type, and tracking mode (FR-MD-01).

+
+
+ + {loadError && ( +
{loadError}
+ )} + + {loading && !loadError && } + + {!loading && ( + <> +
+
+ + setSku(e.target.value)} placeholder="ITM-1004" aria-invalid={!!errors.sku} className="h-12 text-base" /> + +
+
+ + setName(e.target.value)} placeholder="Steel Washer M8" aria-invalid={!!errors.name} className="h-12 text-base" /> + +
+
+ + setDescription(e.target.value)} className="h-12 text-base" /> +
+
+ + value={categoryId} onValueChange={setCategoryId}> + + + + + {(categories ?? []).map((c) => ( + + {c.name} + + ))} + + + +
+
+ + value={baseUomId} onValueChange={setBaseUomId}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + +
+
+ + value={defaultVendorId} onValueChange={setDefaultVendorId}> + + + + + {(vendors ?? []).map((v) => ( + + {v.code} — {v.name} + + ))} + + +
+
+ + setTaxClass(e.target.value)} placeholder="STD" className="h-12 text-base" /> +
+
+ + value={itemType} onValueChange={(v) => v && setItemType(v)}> + + + + + Stocked + Non-stocked + Service + + +
+
+ + value={trackingMode} onValueChange={(v) => v && setTrackingMode(v)}> + + + + + None + Batch + Serial + + +
+
+ + {submitError && ( +
{submitError}
+ )} + +
+ + Cancel + + +
+ + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index 84969ca..ce888b2 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -1,7 +1,250 @@ -export default function ProductsPage() { +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ChevronLeft, ChevronRight, ListTree, Package, Pencil, Plus, Ruler, Search } from "lucide-react" + +import { itemsApi } from "@/lib/api/items" +import { categoriesApi } from "@/lib/api/categories" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { EntityStatus, PaginationMeta } from "@/types/common" +import { Category, ItemListItem, TrackingMode } from "@/types/master-data" + +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" + +type StatusFilter = EntityStatus | "All" +type TrackingFilter = TrackingMode | "All" + +const PAGE_SIZE = 10 + +export default function ItemsPage() { + const [items, setItems] = useState(null) + const [categories, setCategories] = useState([]) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("All") + const [categoryId, setCategoryId] = useState("All") + const [trackingMode, setTrackingMode] = useState("All") + const [page, setPage] = useState(1) + + useEffect(() => { + const timeout = setTimeout(() => setQuery(searchInput.trim()), 300) + return () => clearTimeout(timeout) + }, [searchInput]) + + useEffect(() => setPage(1), [query, status, categoryId, trackingMode]) + + function load() { + setError(null) + itemsApi + .list({ + page, + pageSize: PAGE_SIZE, + q: query || undefined, + status: status === "All" ? undefined : status, + categoryId: categoryId === "All" ? undefined : categoryId, + trackingMode: trackingMode === "All" ? undefined : trackingMode, + }) + .then((res) => { + setItems(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page, query, status, categoryId, trackingMode]) + useEffect(() => { + categoriesApi.list().then((res) => setCategories(res.items)).catch(() => {}) + }, []) + + function categoryName(id: number) { + return categories.find((c) => c.categoryId === id)?.name ?? `#${id}` + } + + const hasFilters = query.length > 0 || status !== "All" || categoryId !== "All" || trackingMode !== "All" + return ( -
-

Products

+
+
+
+

Items

+

Item master — SKU, tracking mode, category, default vendor (FR-MD-01).

+
+
+ + + UOMs + + + + Categories + + + + New Item + +
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search SKU or name…" + className="h-14 w-full pl-11 text-base" + aria-label="Search items" + /> +
+ value={categoryId} onValueChange={(v) => setCategoryId(v ?? "All")}> + + + + + All categories + {categories.map((c) => ( + + {c.name} + + ))} + + + value={trackingMode} onValueChange={(v) => setTrackingMode(v ?? "All")}> + + + + + All tracking modes + None + Batch + Serial + + + value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Inactive + + +
+ + {error && ( +
{error}
+ )} + + {!error && items === null && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {!error && items !== null && items.length === 0 && ( +
+ +

{hasFilters ? "No items match your search/filter." : "No items yet."}

+ {!hasFilters && ( + + + New Item + + )} +
+ )} + + {!error && items !== null && items.length > 0 && ( + <> + + + + SKU + Name + Category + Type + Tracking + Status + Actions + + + + {items.map((item) => ( + + + + {item.sku} + + + {item.name} + {categoryName(item.categoryId)} + {item.itemType} + {item.trackingMode} + + + {item.status} + + + + + + + + + ))} + +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} + + )}
) } diff --git a/Frontend/erp-system/app/dashboard/products/uoms/page.tsx b/Frontend/erp-system/app/dashboard/products/uoms/page.tsx new file mode 100644 index 0000000..70a3ebd --- /dev/null +++ b/Frontend/erp-system/app/dashboard/products/uoms/page.tsx @@ -0,0 +1,134 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Plus, Ruler } from "lucide-react" + +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { validateUomName } from "@/lib/validations/master-data" +import { cn } from "@/lib/utils" +import { Uom } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +export default function UomsPage() { + const [uoms, setUoms] = useState(null) + const [error, setError] = useState(null) + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + uomsApi.list().then((res) => setUoms(res.items)).catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + async function handleCreate() { + const nextErrors = validateUomName(name) + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const uom = await uomsApi.create({ name }) + toast.success("UOM created", uom.name) + setOpen(false) + setName("") + setErrors({}) + load() + } catch (err) { + const fe = fieldErrors(err) + if (fe?.name) setErrors({ name: fe.name }) + toast.error("Could not create UOM", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+ + + +
+

Units of Measure

+

Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).

+
+
+ + + New UOM} /> + + + New UOM + e.g. EA, KG, Box-12. + + + + Name + setName(e.target.value)} placeholder="Box-12" aria-invalid={!!errors.name} /> + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && uoms === null && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {!error && uoms !== null && uoms.length === 0 && ( +
+ +

No UOMs yet.

+
+ )} + + {!error && uoms !== null && uoms.length > 0 && ( + + + + Name + + + + {uoms.map((u) => ( + + {u.name} + + ))} + +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx new file mode 100644 index 0000000..941f7fe --- /dev/null +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/edit/page.tsx @@ -0,0 +1,444 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Plus, Trash2 } from "lucide-react" + +import { grnsApi } from "@/lib/api/grns" +import { warehousesApi } from "@/lib/api/warehouses" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage } from "@/lib/error-map" +import { validateLine, splitSerials } from "@/lib/validations/grn" +import { cn } from "@/lib/utils" +import { CreateGrnLineInput, Grn, HoldStatus } from "@/types/grn" +import { Bin, ItemListItem, Uom, Warehouse } from "@/types/master-data" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +interface DraftLine { + key: string + poLineId: number | null + itemId: number | null + uomId: number | null + binId: number | null + qty: string + unitCost: string + holdStatus: HoldStatus + batchNo: string + expiryDate: string + serialNumbersText: string +} + +let keySeq = 0 +function newKey() { + keySeq += 1 + return `egline-${keySeq}` +} + +function emptyLine(): DraftLine { + return { + key: newKey(), + poLineId: null, + itemId: null, + uomId: null, + binId: null, + qty: "", + unitCost: "", + holdStatus: "Available", + batchNo: "", + expiryDate: "", + serialNumbersText: "", + } +} + +export default function EditGrnPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const grnId = Number(params.id) + + const [grn, setGrn] = useState(null) + const [warehouses, setWarehouses] = useState(null) + const [items, setItems] = useState(null) + const [uoms, setUoms] = useState(null) + const [bins, setBins] = useState([]) + const [loadError, setLoadError] = useState(null) + + const [warehouseId, setWarehouseId] = useState(null) + const [lines, setLines] = useState([]) + + const [headerError, setHeaderError] = useState(null) + const [lineErrors, setLineErrors] = useState>>({}) + const [submitError, setSubmitError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!Number.isFinite(grnId)) return + Promise.all([ + grnsApi.get(grnId), + warehousesApi.list(), + itemsApi.list({ pageSize: 200, status: "Active" }), + uomsApi.list(), + ]) + .then(([g, wh, it, uo]) => { + if (g.status !== "Draft") { + setLoadError(`${g.docNo} is ${g.status.toLowerCase()} and can no longer be edited.`) + setGrn(g) + return + } + setGrn(g) + setWarehouses(wh.items) + setItems(it.items) + setUoms(uo.items) + setWarehouseId(g.warehouseId) + setLines( + g.lines.map( + (l): DraftLine => ({ + key: newKey(), + poLineId: l.poLineId, + itemId: l.itemId, + uomId: l.uomId, + binId: l.binId, + qty: String(l.qty), + unitCost: String(l.unitCost), + holdStatus: l.holdStatus, + batchNo: "", + expiryDate: "", + serialNumbersText: "", + }) + ) + ) + }) + .catch((err) => setLoadError(errorMessage(err))) + }, [grnId]) + + useEffect(() => { + if (!warehouseId) { + setBins([]) + return + } + warehousesApi.listBins(warehouseId).then((r) => setBins(r.items)).catch(() => setBins([])) + }, [warehouseId]) + + function updateLine(key: string, patch: Partial) { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) + } + + function removeLine(key: string) { + setLines((prev) => prev.filter((l) => l.key !== key)) + } + + function itemFor(itemId: number | null) { + return items?.find((i) => i.itemId === itemId) ?? null + } + + async function handleSubmit() { + if (!grn) return + setSubmitError(null) + setHeaderError(null) + + if (!warehouseId) { + setHeaderError("Select a warehouse.") + return + } + if (lines.length === 0) { + setSubmitError("Add at least one line.") + return + } + + const nextLineErrors: Record> = {} + for (const line of lines) { + const errors = validateLine({ + itemId: line.itemId, + uomId: line.uomId, + qty: line.qty, + unitCost: line.unitCost, + trackingMode: itemFor(line.itemId)?.trackingMode ?? null, + batchNo: line.batchNo, + serialNumbersText: line.serialNumbersText, + }) + if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors + } + setLineErrors(nextLineErrors) + if (Object.keys(nextLineErrors).length > 0) { + setSubmitError("Fix the highlighted lines before submitting.") + return + } + + const payloadLines: CreateGrnLineInput[] = lines.map((l) => { + const trackingMode = itemFor(l.itemId)?.trackingMode ?? "None" + return { + poLineId: l.poLineId, + itemId: l.itemId as number, + uomId: l.uomId as number, + binId: l.binId, + qty: Number(l.qty), + unitCost: Number(l.unitCost), + holdStatus: l.holdStatus, + batch: trackingMode === "Batch" ? { batchNo: l.batchNo.trim(), expiryDate: l.expiryDate || null } : null, + serialNumbers: trackingMode === "Serial" ? splitSerials(l.serialNumbersText) : null, + } + }) + + setSubmitting(true) + try { + const updated = await grnsApi.update(grn.grnId, { + poId: grn.poId, + vendorId: grn.vendorId, + warehouseId: warehouseId as number, + lines: payloadLines, + }) + toast.success("GRN updated", `${updated.docNo} saved.`) + router.push(`/dashboard/receiving/grn/${updated.grnId}`) + } catch (err) { + setSubmitError(errorMessage(err)) + toast.error("Could not update GRN", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + if (loadError) { + return ( +
+
+ + + +

Edit GRN

+
+
{loadError}
+
+ ) + } + + const loading = !grn || !warehouses || !items || !uoms + + return ( +
+
+ + + +
+

Edit {grn?.docNo ?? "GRN"}

+

Only Draft GRNs can be edited — confirming posts stock layers permanently.

+
+
+ + {loading && } + + {!loading && ( + <> +
+
+ + value={warehouseId} onValueChange={(v) => setWarehouseId(v)}> + + + + + {(warehouses ?? []).map((w) => ( + + {w.code} — {w.name} + + ))} + + +
+
+ + {headerError && ( +
{headerError}
+ )} + +
+
+

Lines

+ +
+ + {lines.length > 0 && ( + + + + Item + UOM + Bin + Qty + Unit cost + Hold status + Batch / Serial + + + + + {lines.map((line) => { + const item = itemFor(line.itemId) + const errors = lineErrors[line.key] ?? {} + return ( + + + value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> + + + + + {(items ?? []).map((i) => ( + + {i.sku} — {i.name} + + ))} + + + + + + value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}> + + + + + {(uoms ?? []).map((u) => ( + + {u.name} + + ))} + + + + + + value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}> + + + + + {bins.map((b) => ( + + {b.code} + + ))} + + + + + updateLine(line.key, { qty: e.target.value })} + className="h-11 text-base" + /> + + + + updateLine(line.key, { unitCost: e.target.value })} + className="h-11 text-base" + /> + + + + value={line.holdStatus} onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}> + + + + + Available + On hold (inspection) + + + + + {item?.trackingMode === "Batch" && ( +
+ updateLine(line.key, { batchNo: e.target.value })} + className="h-9 text-sm" + /> + updateLine(line.key, { expiryDate: e.target.value })} + className="h-9 text-sm" + /> + +
+ )} + {item?.trackingMode === "Serial" && ( +
+