diff --git a/Backend/ERPCore/Controllers/AuditLogsController.cs b/Backend/ERPCore/Controllers/AuditLogsController.cs new file mode 100644 index 0000000..a3f9750 --- /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] long? entityId, [FromQuery] long? userId, + [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _audit.ListLogsAsync(entityType, entityId, userId, from, to, query, ct)); +} diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs new file mode 100644 index 0000000..0903a5a --- /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:long}")] + [ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(long 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:long}/confirm")] + [ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Confirm( + long 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:long}/lines/{grnLineId:long}/release")] + [ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Release( + long grnId, long grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct) + => Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct)); +} diff --git a/Backend/ERPCore/Controllers/JournalEntriesController.cs b/Backend/ERPCore/Controllers/JournalEntriesController.cs new file mode 100644 index 0000000..a2d500d --- /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] long? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _audit.ListJournalAsync(sourceDocType, sourceDocId, query, 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/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..3124bd3 --- /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] long itemId, [FromQuery] long warehouseId, CancellationToken ct) + => Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct)); + + [HttpGet("ledger")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> Ledger( + [FromQuery] long? itemId, [FromQuery] long? warehouseId, + [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct)); + + [HttpGet("valuation")] + [ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)] + public async Task> Valuation([FromQuery] long itemId, [FromQuery] long 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] long? 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:long}/requisition")] + [ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> SuggestRequisition( + long itemId, [FromQuery] long 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..e1c7bed --- /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:long}")] + [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(long 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:long}/counts")] + [ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> EnterCounts(long countId, [FromBody] EnterCountsRequest request, CancellationToken ct) + => Ok(await _counts.EnterCountsAsync(countId, request, ct)); + + /// Post: emit a variance adjustment and close the count. + [HttpPost("{countId:long}/post")] + [ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Post(long 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..9d70733 --- /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:long}")] + [ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetById(long transferId, CancellationToken ct) + { + var dto = await _transfers.GetAsync(transferId, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + [ProducesResponseType(typeof(TransferDto), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> 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:long}/dispatch")] + [ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Dispatch(long transferId, CancellationToken ct) + => Ok(await _transfers.DispatchAsync(transferId, ct)); + + /// Receive: create the destination layer at the inherited cost (cost-preserving). + [HttpPost("{transferId:long}/receive")] + [ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public async Task> Receive(long transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct) + => Ok(await _transfers.ReceiveAsync(transferId, request, ct)); +} diff --git a/Backend/ERPCore/Domain/Entities/AuditLog.cs b/Backend/ERPCore/Domain/Entities/AuditLog.cs new file mode 100644 index 0000000..833a68c --- /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 long AuditId { get; set; } + public long UserId { get; set; } + public string EntityType { get; set; } = string.Empty; + public long EntityId { get; set; } + public AuditAction Action { get; set; } + /// 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..cc51ae4 --- /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 long BatchId { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public string BatchNo { get; set; } = string.Empty; + public DateOnly? ExpiryDate { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/Grn.cs b/Backend/ERPCore/Domain/Entities/Grn.cs new file mode 100644 index 0000000..9fc2516 --- /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 long GrnId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public long? PoId { get; set; } + public PurchaseOrder? PurchaseOrder { get; set; } + + public long VendorId { get; set; } + public Vendor? Vendor { get; set; } + + public long WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public GrnStatus Status { get; set; } = GrnStatus.Draft; + + public long CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public DateTime? PostedAt { get; set; } + + /// 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..28dce48 --- /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 long GrnLineId { get; set; } + + public long GrnId { get; set; } + public Grn? Grn { get; set; } + + public long? PoLineId { get; set; } + public PoLine? PoLine { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public long UomId { get; set; } + public Uom? Uom { get; set; } + + public long? BinId { get; set; } + public Bin? Bin { get; set; } + + public long? BatchId { get; set; } + public Batch? Batch { get; set; } + + public decimal Qty { get; set; } + public decimal UnitCost { get; set; } + public decimal ReceivedValue { get; set; } + public HoldStatus HoldStatus { get; set; } = HoldStatus.Available; +} diff --git a/Backend/ERPCore/Domain/Entities/JournalEntryStub.cs b/Backend/ERPCore/Domain/Entities/JournalEntryStub.cs new file mode 100644 index 0000000..7f458c7 --- /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 long JournalId { get; set; } + public string SourceDocType { get; set; } = string.Empty; + public long SourceDocId { get; set; } + public string DebitAccount { get; set; } = string.Empty; + public string CreditAccount { get; set; } = string.Empty; + public decimal Amount { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs b/Backend/ERPCore/Domain/Entities/PurchaseReturn.cs new file mode 100644 index 0000000..1acd501 --- /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 long ReturnId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public long VendorId { get; set; } + public Vendor? Vendor { get; set; } + + public long WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public long ReasonCodeId { get; set; } + public ReasonCode? ReasonCode { get; set; } + + public ReturnStatus Status { get; set; } = ReturnStatus.Posted; + + public long CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + + public ICollection 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..f42c0e0 --- /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 long ReturnLineId { get; set; } + + public long ReturnId { get; set; } + public PurchaseReturn? Return { get; set; } + + public long? GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public decimal Qty { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/ReasonCode.cs b/Backend/ERPCore/Domain/Entities/ReasonCode.cs new file mode 100644 index 0000000..662bb49 --- /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 long 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/Serial.cs b/Backend/ERPCore/Domain/Entities/Serial.cs new file mode 100644 index 0000000..2fdd5a4 --- /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 long SerialId { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public string SerialNo { get; set; } = string.Empty; + public string Status { get; set; } = "InStock"; +} diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustment.cs b/Backend/ERPCore/Domain/Entities/StockAdjustment.cs new file mode 100644 index 0000000..3b47f1a --- /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 long AdjustmentId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public long WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public long ReasonCodeId { get; set; } + public ReasonCode? ReasonCode { get; set; } + + public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted; + + public long CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } + + public ICollection 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..dbdae9b --- /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 long AdjLineId { get; set; } + + public long AdjustmentId { get; set; } + public StockAdjustment? Adjustment { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public long? BinId { get; set; } + public long? BatchId { get; set; } + public long? SerialId { get; set; } + + public decimal QtyDelta { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/StockCount.cs b/Backend/ERPCore/Domain/Entities/StockCount.cs new file mode 100644 index 0000000..5d652d8 --- /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 long CountId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public long WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public CountType CountType { get; set; } + public CountStatus Status { get; set; } = CountStatus.Draft; + + public long CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } + + public ICollection 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..2a8ef40 --- /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 long CountLineId { get; set; } + + public long CountId { get; set; } + public StockCount? Count { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public long? BinId { get; set; } + + public decimal SystemQty { get; set; } + public decimal? CountedQty { get; set; } + public decimal? Variance { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/StockLayer.cs b/Backend/ERPCore/Domain/Entities/StockLayer.cs new file mode 100644 index 0000000..de686c1 --- /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 long LayerId { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public long WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public long? BatchId { get; set; } + public Batch? Batch { get; set; } + + public long? SerialId { get; set; } + public Serial? Serial { get; set; } + + /// Originating GRN line — carries the inspection hold status for this stock. + public long? GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public decimal QtyReceived { get; set; } + public decimal QtyRemaining { get; set; } + public decimal UnitCost { get; set; } + public DateTime ReceiptDate { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/StockLedger.cs b/Backend/ERPCore/Domain/Entities/StockLedger.cs new file mode 100644 index 0000000..cbffbf7 --- /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 long LedgerId { get; set; } + + public long ItemId { get; set; } + public long WarehouseId { get; set; } + public long? BinId { get; set; } + public long? BatchId { get; set; } + public long? SerialId { get; set; } + public long UserId { get; set; } + + public Direction Direction { get; set; } + public decimal QtyBase { get; set; } + public decimal UnitCost { get; set; } + public decimal Value { get; set; } + public decimal RunningBalance { get; set; } + + public string SourceDocType { get; set; } = string.Empty; + public long SourceDocId { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/StockTransfer.cs b/Backend/ERPCore/Domain/Entities/StockTransfer.cs new file mode 100644 index 0000000..7bfc244 --- /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 long TransferId { get; set; } + public string DocNo { get; set; } = string.Empty; + + public long SrcWarehouseId { get; set; } + public Warehouse? SrcWarehouse { get; set; } + + public long DestWarehouseId { get; set; } + public Warehouse? DestWarehouse { get; set; } + + public TransferStatus Status { get; set; } = TransferStatus.Draft; + + public long CreatedBy { get; set; } + public User? Creator { get; set; } + + public DateTime CreatedAt { get; set; } + public uint RowVersion { get; set; } + + public ICollection 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..3959676 --- /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 long TransferLineId { get; set; } + + public long TransferId { get; set; } + public StockTransfer? Transfer { get; set; } + + public long ItemId { get; set; } + public Item? Item { get; set; } + + public long? SrcBinId { get; set; } + public long? DestBinId { get; set; } + public long? BatchId { get; set; } + public long? SerialId { get; set; } + + public decimal Qty { get; set; } + + /// 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/Enums/AdjustmentStatus.cs b/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs new file mode 100644 index 0000000..7d56708 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/AdjustmentStatus.cs @@ -0,0 +1,13 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so +/// is reserved for the future threshold-approval +/// workflow (FR-STK-07). Stored as a string. +/// +public enum AdjustmentStatus +{ + Draft, + PendingApproval, + Posted +} diff --git a/Backend/ERPCore/Domain/Enums/AuditAction.cs b/Backend/ERPCore/Domain/Enums/AuditAction.cs new file mode 100644 index 0000000..f7644e8 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/AuditAction.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Kind of mutation recorded in the audit trail (FR-X-02). Stored as a string. +public enum AuditAction +{ + Create, + Update, + Delete +} diff --git a/Backend/ERPCore/Domain/Enums/CountStatus.cs b/Backend/ERPCore/Domain/Enums/CountStatus.cs new file mode 100644 index 0000000..8e3e958 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/CountStatus.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Stock-count lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string. +public enum CountStatus +{ + Draft, + Counted, + Posted +} diff --git a/Backend/ERPCore/Domain/Enums/CountType.cs b/Backend/ERPCore/Domain/Enums/CountType.cs new file mode 100644 index 0000000..29015d8 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/CountType.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Physical-count scope (docs/11 §8; FR-STK-08). Stored as a string. +public enum CountType +{ + Cycle, + Full +} diff --git a/Backend/ERPCore/Domain/Enums/Direction.cs b/Backend/ERPCore/Domain/Enums/Direction.cs new file mode 100644 index 0000000..afee991 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/Direction.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Stock-ledger movement direction (docs/11 §8). Stored as a string. +public enum Direction +{ + In, + Out +} diff --git a/Backend/ERPCore/Domain/Enums/GrnStatus.cs b/Backend/ERPCore/Domain/Enums/GrnStatus.cs new file mode 100644 index 0000000..550cca2 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/GrnStatus.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Goods-receipt-note lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string. +public enum GrnStatus +{ + Draft, + Confirmed, + Closed +} diff --git a/Backend/ERPCore/Domain/Enums/HoldStatus.cs b/Backend/ERPCore/Domain/Enums/HoldStatus.cs new file mode 100644 index 0000000..e38dd1d --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/HoldStatus.cs @@ -0,0 +1,12 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Inspection-hold state of received stock (docs/11 §8; FR-GRN-05). +/// stock is on-hand but not issuable until released (FR-WH-07). Stored as a string. +/// +public enum HoldStatus +{ + Available, + OnHold, + Rejected +} diff --git a/Backend/ERPCore/Domain/Enums/ReasonContext.cs b/Backend/ERPCore/Domain/Enums/ReasonContext.cs new file mode 100644 index 0000000..b569040 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/ReasonContext.cs @@ -0,0 +1,9 @@ +namespace ERPCore.Domain.Enums; + +/// Where a reason code applies (FR-X-04; docs/10 §B.8.3). Stored as a string. +public enum ReasonContext +{ + Adjustment, + Return, + Count +} diff --git a/Backend/ERPCore/Domain/Enums/ReturnStatus.cs b/Backend/ERPCore/Domain/Enums/ReturnStatus.cs new file mode 100644 index 0000000..6214b0b --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/ReturnStatus.cs @@ -0,0 +1,8 @@ +namespace ERPCore.Domain.Enums; + +/// Purchase-return lifecycle (docs/11 §3.4). Auto-posts in Phase 1. Stored as a string. +public enum ReturnStatus +{ + Draft, + Posted +} diff --git a/Backend/ERPCore/Domain/Enums/TransferStatus.cs b/Backend/ERPCore/Domain/Enums/TransferStatus.cs new file mode 100644 index 0000000..0f96724 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/TransferStatus.cs @@ -0,0 +1,10 @@ +namespace ERPCore.Domain.Enums; + +/// Stock-transfer lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string. +public enum TransferStatus +{ + Draft, + InTransit, + Received, + Closed +} diff --git a/Backend/ERPCore/Dtos/Audit/AuditDtos.cs b/Backend/ERPCore/Dtos/Audit/AuditDtos.cs new file mode 100644 index 0000000..4870725 --- /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( + long AuditId, long UserId, string EntityType, long EntityId, AuditAction Action, JsonElement ChangeSet, DateTime CreatedAt); + +/// A GL-ready journal stub emitted per stock movement (FR-STK-13). +public sealed record JournalEntryStubDto( + long JournalId, string SourceDocType, long SourceDocId, string DebitAccount, string CreditAccount, decimal Amount); diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs new file mode 100644 index 0000000..72582ed --- /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( + long GrnLineId, long? PoLineId, long ItemId, long UomId, long? BinId, + decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, long? BatchId); + +public sealed record GrnDto( + long GrnId, string DocNo, long? PoId, long VendorId, long WarehouseId, GrnStatus Status, + long CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList Lines); + +public sealed record CreatedLayerDto( + long LayerId, long ItemId, long WarehouseId, long? BatchId, + decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate); + +public sealed record GrnConfirmResultDto( + long GrnId, GrnStatus Status, DateTime PostedAt, + IReadOnlyList CreatedLayers, IReadOnlyList LedgerRefs, PurchaseOrderStatus? PoStatus); + +public sealed record ReleaseLineResultDto(long GrnLineId, HoldStatus HoldStatus); + +// Requests ---------------------------------------------------------------------- + +public sealed class BatchInput +{ + [Required, StringLength(50)] public string BatchNo { get; set; } = string.Empty; + public DateOnly? ExpiryDate { get; set; } +} + +public sealed class CreateGrnLineInput +{ + /// Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3). + public long? PoLineId { get; set; } + [Required] public long ItemId { get; set; } + [Required] public long UomId { get; set; } + public long? BinId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// 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 long? PoId { get; set; } + /// Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO). + public long? VendorId { get; set; } + [Required] public long 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/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs new file mode 100644 index 0000000..85328ed --- /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(long ReturnLineId, long? GrnLineId, long ItemId, decimal Qty); + +public sealed record PurchaseReturnDto( + long ReturnId, string DocNo, long VendorId, long WarehouseId, long ReasonCodeId, ReturnStatus Status, + long CreatedBy, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreatePurchaseReturnLineInput +{ + /// Original GRN line, for traceability against the receipt. + public long? GrnLineId { get; set; } + [Required] public long ItemId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } +} + +public sealed class CreatePurchaseReturnRequest +{ + [Required] public long VendorId { get; set; } + [Required] public long WarehouseId { get; set; } + /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error. + public long? ReasonCodeId { 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..60080f2 --- /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(long ReasonCodeId, string Code, string Description, ReasonContext Context); + +public sealed class CreateReasonCodeRequest +{ + [Required, StringLength(20)] public string Code { get; set; } = string.Empty; + [Required, StringLength(200)] public string Description { get; set; } = string.Empty; + [Required, EnumDataType(typeof(ReasonContext))] public ReasonContext Context { get; set; } +} diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs new file mode 100644 index 0000000..57d41a3 --- /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(long AdjLineId, long ItemId, long? BinId, long? BatchId, decimal QtyDelta); + +public sealed record AdjustmentDto( + long AdjustmentId, string DocNo, long WarehouseId, long ReasonCodeId, AdjustmentStatus Status, + long CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreateAdjustmentLineInput +{ + [Required] public long ItemId { get; set; } + public long? BinId { get; set; } + public long? 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 long WarehouseId { get; set; } + /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error, not 0. + public long? 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..79a4b73 --- /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(long CountLineId, long ItemId, long? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance); + +public sealed record CountDto( + long CountId, string DocNo, long WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList Lines); + +public sealed record CountPostResultDto(long CountId, CountStatus Status, long? AdjustmentId, IReadOnlyList LedgerRefs); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreateCountRequest +{ + [Required] public long WarehouseId { get; set; } + [Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; } + [Required, MinLength(1)] public List ItemIds { get; set; } = new(); +} + +public sealed class EnterCountLineInput +{ + [Required] public long CountLineId { get; set; } + [Range(0, double.MaxValue)] public decimal CountedQty { get; set; } +} + +public sealed class EnterCountsRequest +{ + [Required, MinLength(1)] public List 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..48d1b7c --- /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( + long ItemId, long 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..a1663e8 --- /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( + long ItemId, long 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( + long LedgerId, long ItemId, long WarehouseId, long? BinId, long? BatchId, long? SerialId, + Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance, + string SourceDocType, long SourceDocId, long UserId, DateTime CreatedAt); + +/// An open FIFO layer in a valuation (docs/11 §5.3). +public sealed record StockValuationLayerDto(long 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( + long ItemId, long 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..3974852 --- /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( + long TransferLineId, long ItemId, long? SrcBinId, long? DestBinId, long? BatchId, decimal Qty, decimal QtyReceived); + +public sealed record TransferDto( + long TransferId, string DocNo, long SrcWarehouseId, long DestWarehouseId, + TransferStatus Status, IReadOnlyList Lines); + +public sealed record ConsumedLayerDto(long LayerId, decimal QtyConsumed, decimal UnitCost); + +public sealed record DispatchResultDto( + long TransferId, TransferStatus Status, IReadOnlyList ConsumedLayers, IReadOnlyList LedgerRefs); + +public sealed record TransferCreatedLayerDto(long LayerId, long WarehouseId, decimal QtyReceived, decimal UnitCost); + +public sealed record ReceiveResultDto( + long TransferId, TransferStatus Status, IReadOnlyList CreatedLayers, IReadOnlyList LedgerRefs); + +// Requests ---------------------------------------------------------------------- + +public sealed class CreateTransferLineInput +{ + [Required] public long ItemId { get; set; } + public long? SrcBinId { get; set; } + public long? DestBinId { get; set; } + public long? BatchId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } +} + +public sealed class CreateTransferRequest +{ + [Required] public long SrcWarehouseId { get; set; } + [Required] public long DestWarehouseId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} + +public sealed class ReceiveTransferLineInput +{ + [Required] public long TransferLineId { get; set; } + [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } +} + +public sealed class ReceiveTransferRequest +{ + [Required, MinLength(1)] public List Lines { get; set; } = new(); +} diff --git a/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs b/Backend/ERPCore/Infra/Persistence/Auditing/AuditScribe.cs new file mode 100644 index 0000000..a65e413 --- /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, long 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, long userId, DateTime now) => new() + { + UserId = userId, + EntityType = p.EntityType, + EntityId = p.IsAdded ? ReadKey(p.Entry) : p.CapturedId, + Action = p.Action, + ChangeSet = p.ChangeSet, + CreatedAt = now, + }; + + private static long ReadKey(EntityEntry entry) + { + var pk = entry.Metadata.FindPrimaryKey(); + if (pk is null || pk.Properties.Count != 1) return 0; + var value = entry.Property(pk.Properties[0].Name).CurrentValue; + return value is null ? 0 : Convert.ToInt64(value); + } + + private static string BuildChangeSet(EntityEntry entry, AuditAction action) + { + var set = new Dictionary(); + foreach (var p in entry.Properties) + { + if (p.Metadata.IsPrimaryKey()) continue; + if (p.Metadata.Name == nameof(Item.RowVersion)) continue; + + switch (action) + { + case AuditAction.Create when p.CurrentValue is not null: + set[p.Metadata.Name] = p.CurrentValue; + break; + case AuditAction.Delete: + set[p.Metadata.Name] = p.OriginalValue; + break; + case AuditAction.Update when p.IsModified && !Equals(p.OriginalValue, p.CurrentValue): + set[p.Metadata.Name] = new Dictionary { ["old"] = p.OriginalValue, ["new"] = p.CurrentValue }; + break; + } + } + return JsonSerializer.Serialize(set, Json); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs new file mode 100644 index 0000000..a93681c --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/AuditConfiguration.cs @@ -0,0 +1,41 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class AuditLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("audit_logs"); + builder.HasKey(a => a.AuditId); + + builder.Property(a => a.EntityType).IsRequired().HasMaxLength(80); + builder.Property(a => a.Action).HasConversion().HasMaxLength(10).IsRequired(); + builder.Property(a => a.ChangeSet).IsRequired().HasColumnType("jsonb"); + builder.Property(a => a.CreatedAt).IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(a => new { a.EntityType, a.EntityId }); + builder.HasIndex(a => a.CreatedAt); + builder.HasIndex(a => a.UserId); + } +} + +public sealed class JournalEntryStubConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("journal_entry_stubs"); + builder.HasKey(j => j.JournalId); + + builder.Property(j => j.SourceDocType).IsRequired().HasMaxLength(10); + builder.Property(j => j.DebitAccount).IsRequired().HasMaxLength(20); + builder.Property(j => j.CreditAccount).IsRequired().HasMaxLength(20); + builder.Property(j => j.Amount).HasPrecision(18, 4); + + builder.HasIndex(j => new { j.SourceDocType, j.SourceDocId }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs new file mode 100644 index 0000000..8de6adf --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs @@ -0,0 +1,43 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class BatchConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("batches"); + builder.HasKey(b => b.BatchId); + + builder.Property(b => b.BatchNo).IsRequired().HasMaxLength(50); + + builder.HasOne(b => b.Item) + .WithMany() + .HasForeignKey(b => b.ItemId) + .OnDelete(DeleteBehavior.Restrict); + + // Batch number unique within an item. + builder.HasIndex(b => new { b.ItemId, b.BatchNo }).IsUnique(); + } +} + +public sealed class SerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("serials"); + builder.HasKey(s => s.SerialId); + + builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100); + builder.Property(s => s.Status).IsRequired().HasMaxLength(20); + + builder.HasOne(s => s.Item) + .WithMany() + .HasForeignKey(s => s.ItemId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs new file mode 100644 index 0000000..087ea19 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -0,0 +1,50 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class GrnConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("grns"); + builder.HasKey(g => g.GrnId); + + builder.Property(g => g.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(g => g.DocNo).IsUnique(); + + builder.Property(g => g.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(g => g.CreatedAt).IsRequired(); + builder.Property(g => g.RowVersion).IsRowVersion(); + + builder.HasOne(g => g.PurchaseOrder).WithMany().HasForeignKey(g => g.PoId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(g => g.Vendor).WithMany().HasForeignKey(g => g.VendorId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(g => g.Warehouse).WithMany().HasForeignKey(g => g.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(g => g.Creator).WithMany().HasForeignKey(g => g.CreatedBy).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(g => g.Status); + builder.HasIndex(g => g.PoId); + } +} + +public sealed class GrnLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("grn_lines"); + builder.HasKey(l => l.GrnLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.ReceivedValue).HasPrecision(18, 4); + builder.Property(l => l.HoldStatus).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.PoLine).WithMany().HasForeignKey(l => l.PoLineId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Uom).WithMany().HasForeignKey(l => l.UomId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Bin).WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs new file mode 100644 index 0000000..00add0a --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PurchaseReturnConfiguration.cs @@ -0,0 +1,40 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PurchaseReturnConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("purchase_returns"); + builder.HasKey(r => r.ReturnId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + + builder.Property(r => r.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(r => r.CreatedAt).IsRequired(); + + builder.HasOne(r => r.Vendor).WithMany().HasForeignKey(r => r.VendorId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.ReasonCode).WithMany().HasForeignKey(r => r.ReasonCodeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class PurchaseReturnLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("purchase_return_lines"); + builder.HasKey(l => l.ReturnLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + + builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs new file mode 100644 index 0000000..4746889 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ReasonCodeConfiguration.cs @@ -0,0 +1,20 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class ReasonCodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("reason_codes"); + builder.HasKey(r => r.ReasonCodeId); + + builder.Property(r => r.Code).IsRequired().HasMaxLength(20); + builder.Property(r => r.Description).IsRequired().HasMaxLength(200); + builder.Property(r => r.Context).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasIndex(r => new { r.Context, r.Code }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs new file mode 100644 index 0000000..d50a0e6 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs @@ -0,0 +1,42 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_adjustments"); + builder.HasKey(a => a.AdjustmentId); + + builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(a => a.DocNo).IsUnique(); + + builder.Property(a => a.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(a => a.CreatedAt).IsRequired(); + builder.Property(a => a.RowVersion).IsRowVersion(); + + builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(a => a.Creator).WithMany().HasForeignKey(a => a.CreatedBy).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_adjustment_lines"); + builder.HasKey(l => l.AdjLineId); + + builder.Property(l => l.QtyDelta).HasPrecision(18, 4); + + builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs new file mode 100644 index 0000000..57d51a6 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockConfiguration.cs @@ -0,0 +1,60 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class StockLayerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_layers"); + builder.HasKey(l => l.LayerId); + + builder.Property(l => l.QtyReceived).HasPrecision(18, 4); + builder.Property(l => l.QtyRemaining).HasPrecision(18, 4); + builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.ReceiptDate).IsRequired(); + + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Warehouse).WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.GrnLine).WithMany().HasForeignKey(l => l.GrnLineId).OnDelete(DeleteBehavior.Restrict); + + // FIFO consumption orders by receipt date then layer id, scoped per item+warehouse. + builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.ReceiptDate, l.LayerId }); + builder.HasIndex(l => l.GrnLineId); + } +} + +public sealed class StockLedgerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Append-only (FR-STK-01/FR-X-05): the app never updates/deletes ledger rows. + // DB-level revocation of UPDATE/DELETE is a deferred hardening step (02-SECURITY B.3). + builder.ToTable("stock_ledger"); + builder.HasKey(l => l.LedgerId); + + builder.Property(l => l.Direction).HasConversion().HasMaxLength(5).IsRequired(); + builder.Property(l => l.QtyBase).HasPrecision(18, 4); + builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.Value).HasPrecision(18, 4); + builder.Property(l => l.RunningBalance).HasPrecision(18, 4); + builder.Property(l => l.SourceDocType).IsRequired().HasMaxLength(10); + builder.Property(l => l.CreatedAt).IsRequired(); + + builder.HasOne().WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + + // Time-series query paths (NFR-06) and polymorphic source tracing. + builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.LedgerId }); + builder.HasIndex(l => new { l.ItemId, l.WarehouseId, l.CreatedAt }); + builder.HasIndex(l => new { l.SourceDocType, l.SourceDocId }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs new file mode 100644 index 0000000..fa44f42 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockCountConfiguration.cs @@ -0,0 +1,44 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class StockCountConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_counts"); + builder.HasKey(c => c.CountId); + + builder.Property(c => c.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(c => c.DocNo).IsUnique(); + + builder.Property(c => c.CountType).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(c => c.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(c => c.CreatedAt).IsRequired(); + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasOne(c => c.Warehouse).WithMany().HasForeignKey(c => c.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(c => c.Creator).WithMany().HasForeignKey(c => c.CreatedBy).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(c => c.Status); + } +} + +public sealed class StockCountLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_count_lines"); + builder.HasKey(l => l.CountLineId); + + builder.Property(l => l.SystemQty).HasPrecision(18, 4); + builder.Property(l => l.CountedQty).HasPrecision(18, 4); + builder.Property(l => l.Variance).HasPrecision(18, 4); + + builder.HasOne(l => l.Count).WithMany(c => c.Lines).HasForeignKey(l => l.CountId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs new file mode 100644 index 0000000..2aae8bb --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockTransferConfiguration.cs @@ -0,0 +1,47 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class StockTransferConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_transfers"); + builder.HasKey(t => t.TransferId); + + builder.Property(t => t.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(t => t.DocNo).IsUnique(); + + builder.Property(t => t.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(t => t.CreatedAt).IsRequired(); + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasOne(t => t.SrcWarehouse).WithMany().HasForeignKey(t => t.SrcWarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(t => t.DestWarehouse).WithMany().HasForeignKey(t => t.DestWarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(t => t.Status); + } +} + +public sealed class StockTransferLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_transfer_lines"); + builder.HasKey(l => l.TransferLineId); + + builder.Property(l => l.Qty).HasPrecision(18, 4); + builder.Property(l => l.UnitCost).HasPrecision(18, 6); + builder.Property(l => l.QtyReceived).HasPrecision(18, 4); + + builder.HasOne(l => l.Transfer).WithMany(t => t.Lines).HasForeignKey(l => l.TransferId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.SrcBinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.DestBinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs new file mode 100644 index 0000000..59a8803 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -0,0 +1,45 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Infra.Persistence; + +/// +/// Idempotent startup seeding of configurable reference data (docs/10 §B.8.3). +/// Reason codes are seeded at runtime (not via HasData) so the identity +/// sequence advances normally and later admin POST /reason-codes calls +/// cannot collide with seeded ids. +/// +public static class DataSeeder +{ + private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes = + [ + ("DMG", "Damage", ReasonContext.Adjustment), + ("THEFT", "Theft/Loss", ReasonContext.Adjustment), + ("VAR", "Count Variance", ReasonContext.Adjustment), + ("EXP", "Expiry Write-off", ReasonContext.Adjustment), + ("SYS", "System Correction", ReasonContext.Adjustment), + ("DEF", "Defective", ReasonContext.Return), + ("WRONG", "Wrong Item", ReasonContext.Return), + ("OVER", "Over-supply", ReasonContext.Return), + ("QREJ", "Quality Reject", ReasonContext.Return), + ]; + + public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default) + { + var existing = await db.ReasonCodes + .Select(r => new { r.Context, r.Code }) + .ToListAsync(ct); + var have = existing.Select(x => (x.Context, x.Code)).ToHashSet(); + + var toAdd = StandardReasonCodes + .Where(r => !have.Contains((r.Context, r.Code))) + .Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context }) + .ToList(); + + if (toAdd.Count == 0) return; + + db.ReasonCodes.AddRange(toAdd); + await db.SaveChangesAsync(ct); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index d8bf5d5..6a7d7e6 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -1,4 +1,6 @@ using ERPCore.Domain.Entities; +using ERPCore.Infra.Auth; +using ERPCore.Infra.Persistence.Auditing; using Microsoft.EntityFrameworkCore; namespace ERPCore.Infra.Persistence; @@ -8,11 +10,15 @@ namespace ERPCore.Infra.Persistence; /// configurations are added under /// Domain/Entities and Infra/Persistence/Configurations as they are implemented. /// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here. +/// Every save writes an immutable audit trail (FR-X-02) via . /// public class ErpDbContext : DbContext { - public ErpDbContext(DbContextOptions options) : base(options) + private readonly ICurrentUser _currentUser; + + public ErpDbContext(DbContextOptions options, ICurrentUser currentUser) : base(options) { + _currentUser = currentUser; } // --- Master Data (docs/10 Part C.1) --- @@ -39,6 +45,37 @@ public class ErpDbContext : DbContext public DbSet PurchaseOrders => Set(); public DbSet PoLines => Set(); + // --- Goods Receipt (docs/10 Part C.3) --- + public DbSet Grns => Set(); + public DbSet GrnLines => Set(); + + // --- Batch / Serial (docs/10 Part C.4) --- + public DbSet Batches => Set(); + public DbSet Serials => Set(); + + // --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) --- + public DbSet StockLayers => Set(); + public DbSet StockLedger => Set(); + + // --- Stock transactions (docs/10 Part C.6) --- + public DbSet StockTransfers => Set(); + public DbSet StockTransferLines => Set(); + public DbSet StockAdjustments => Set(); + public DbSet StockAdjustmentLines => Set(); + public DbSet StockCounts => Set(); + public DbSet StockCountLines => Set(); + + // --- Purchase returns (docs/10 Part C.2) --- + public DbSet PurchaseReturns => Set(); + public DbSet PurchaseReturnLines => Set(); + + // --- Reference data (docs/10 Part C.7) --- + public DbSet ReasonCodes => Set(); + + // --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) --- + public DbSet AuditLogs => Set(); + public DbSet JournalEntryStubs => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -47,4 +84,39 @@ public class ErpDbContext : DbContext // (Infra/Persistence/Configurations/*). modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly); } + + // Audit trail (FR-X-02): capture mutations before save (accurate old→new), then + // write the log rows once inserts have their generated keys. A second base save + // persists the logs without re-auditing them. + public override async Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken ct = default) + { + var pending = AuditScribe.Capture(ChangeTracker); + var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); + if (pending.Count > 0) + { + WriteAuditLogs(pending); + await base.SaveChangesAsync(acceptAllChangesOnSuccess, ct); + } + return result; + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + var pending = AuditScribe.Capture(ChangeTracker); + var result = base.SaveChanges(acceptAllChangesOnSuccess); + if (pending.Count > 0) + { + WriteAuditLogs(pending); + base.SaveChanges(acceptAllChangesOnSuccess); + } + return result; + } + + private void WriteAuditLogs(IReadOnlyList pending) + { + var now = DateTime.UtcNow; + var userId = _currentUser.AuditUserId; + foreach (var p in pending) + AuditLogs.Add(AuditScribe.ToLog(p, userId, now)); + } } diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260713054124_AddStockAndGrn.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260713054124_AddStockAndGrn.Designer.cs new file mode 100644 index 0000000..d10be22 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260713054124_AddStockAndGrn.Designer.cs @@ -0,0 +1,1480 @@ +// +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("20260713054124_AddStockAndGrn")] + partial class AddStockAndGrn + { + /// + 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.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("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.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("GrnId") + .HasColumnType("bigint"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoLineId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("bigint"); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("bigint"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("bigint") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("GrnLineId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SourceDocId") + .HasColumnType("bigint"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ToUomId") + .HasColumnType("bigint"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1L, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("bigint"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.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.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.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.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.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260713054124_AddStockAndGrn.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260713054124_AddStockAndGrn.cs new file mode 100644 index 0000000..a43fc8c --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260713054124_AddStockAndGrn.cs @@ -0,0 +1,434 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class AddStockAndGrn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "batches", + columns: table => new + { + BatchId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "bigint", 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: "grns", + columns: table => new + { + GrnId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + PoId = table.Column(type: "bigint", nullable: true), + VendorId = table.Column(type: "bigint", nullable: false), + WarehouseId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "bigint", 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: "serials", + columns: table => new + { + SerialId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ItemId = table.Column(type: "bigint", 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: "grn_lines", + columns: table => new + { + GrnLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + GrnId = table.Column(type: "bigint", nullable: false), + PoLineId = table.Column(type: "bigint", nullable: true), + ItemId = table.Column(type: "bigint", nullable: false), + UomId = table.Column(type: "bigint", nullable: false), + BinId = table.Column(type: "bigint", nullable: true), + BatchId = table.Column(type: "bigint", 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: "stock_ledger", + columns: table => new + { + LedgerId = 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), + BinId = table.Column(type: "bigint", nullable: true), + BatchId = table.Column(type: "bigint", nullable: true), + SerialId = table.Column(type: "bigint", nullable: true), + UserId = table.Column(type: "bigint", 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: "bigint", 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_layers", + columns: table => new + { + LayerId = 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), + BatchId = table.Column(type: "bigint", nullable: true), + SerialId = table.Column(type: "bigint", nullable: true), + GrnLineId = table.Column(type: "bigint", 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.CreateIndex( + name: "IX_batches_ItemId_BatchNo", + table: "batches", + columns: new[] { "ItemId", "BatchNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_grn_lines_BatchId", + table: "grn_lines", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_grn_lines_BinId", + table: "grn_lines", + column: "BinId"); + + migrationBuilder.CreateIndex( + name: "IX_grn_lines_GrnId", + table: "grn_lines", + column: "GrnId"); + + migrationBuilder.CreateIndex( + name: "IX_grn_lines_ItemId", + table: "grn_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_grn_lines_PoLineId", + table: "grn_lines", + column: "PoLineId"); + + migrationBuilder.CreateIndex( + name: "IX_grn_lines_UomId", + table: "grn_lines", + column: "UomId"); + + migrationBuilder.CreateIndex( + name: "IX_grns_CreatedBy", + table: "grns", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_grns_DocNo", + table: "grns", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_grns_PoId", + table: "grns", + column: "PoId"); + + migrationBuilder.CreateIndex( + name: "IX_grns_Status", + table: "grns", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_grns_VendorId", + table: "grns", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_grns_WarehouseId", + table: "grns", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_serials_ItemId_SerialNo", + table: "serials", + columns: new[] { "ItemId", "SerialNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stock_layers_BatchId", + table: "stock_layers", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_layers_GrnLineId", + table: "stock_layers", + column: "GrnLineId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_layers_ItemId_WarehouseId_ReceiptDate_LayerId", + table: "stock_layers", + columns: new[] { "ItemId", "WarehouseId", "ReceiptDate", "LayerId" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_layers_SerialId", + table: "stock_layers", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_layers_WarehouseId", + table: "stock_layers", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_ledger_BatchId", + table: "stock_ledger", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_ledger_BinId", + table: "stock_ledger", + column: "BinId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_ledger_ItemId_WarehouseId_CreatedAt", + table: "stock_ledger", + columns: new[] { "ItemId", "WarehouseId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_ledger_ItemId_WarehouseId_LedgerId", + table: "stock_ledger", + columns: new[] { "ItemId", "WarehouseId", "LedgerId" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_ledger_SerialId", + table: "stock_ledger", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_ledger_SourceDocType_SourceDocId", + table: "stock_ledger", + columns: new[] { "SourceDocType", "SourceDocId" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_ledger_UserId", + table: "stock_ledger", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_ledger_WarehouseId", + table: "stock_ledger", + column: "WarehouseId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "stock_layers"); + + migrationBuilder.DropTable( + name: "stock_ledger"); + + migrationBuilder.DropTable( + name: "grn_lines"); + + migrationBuilder.DropTable( + name: "serials"); + + migrationBuilder.DropTable( + name: "batches"); + + migrationBuilder.DropTable( + name: "grns"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260713061141_AddStockTransactions.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260713061141_AddStockTransactions.Designer.cs new file mode 100644 index 0000000..ff45314 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260713061141_AddStockTransactions.Designer.cs @@ -0,0 +1,1847 @@ +// +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("20260713061141_AddStockTransactions")] + partial class AddStockTransactions + { + /// + 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.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("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.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("GrnId") + .HasColumnType("bigint"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoLineId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("bigint"); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("bigint"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("bigint") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("bigint"); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + 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.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("GrnLineId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SourceDocId") + .HasColumnType("bigint"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DestWarehouseId") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("DestBinId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SrcBinId") + .HasColumnType("bigint"); + + b.Property("TransferId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ToUomId") + .HasColumnType("bigint"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1L, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("bigint"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.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.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.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.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.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/20260713061141_AddStockTransactions.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260713061141_AddStockTransactions.cs new file mode 100644 index 0000000..08a79bd --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260713061141_AddStockTransactions.cs @@ -0,0 +1,337 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class AddStockTransactions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "reason_codes", + columns: table => new + { + ReasonCodeId = table.Column(type: "bigint", 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: "stock_transfers", + columns: table => new + { + TransferId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + SrcWarehouseId = table.Column(type: "bigint", nullable: false), + DestWarehouseId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "bigint", 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: "stock_adjustments", + columns: table => new + { + AdjustmentId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + WarehouseId = table.Column(type: "bigint", nullable: false), + ReasonCodeId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "bigint", 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_transfer_lines", + columns: table => new + { + TransferLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TransferId = table.Column(type: "bigint", nullable: false), + ItemId = table.Column(type: "bigint", nullable: false), + SrcBinId = table.Column(type: "bigint", nullable: true), + DestBinId = table.Column(type: "bigint", nullable: true), + BatchId = table.Column(type: "bigint", nullable: true), + SerialId = table.Column(type: "bigint", 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: "stock_adjustment_lines", + columns: table => new + { + AdjLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AdjustmentId = table.Column(type: "bigint", nullable: false), + ItemId = table.Column(type: "bigint", nullable: false), + BinId = table.Column(type: "bigint", nullable: true), + BatchId = table.Column(type: "bigint", nullable: true), + SerialId = table.Column(type: "bigint", 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.CreateIndex( + name: "IX_reason_codes_Context_Code", + table: "reason_codes", + columns: new[] { "Context", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustment_lines_AdjustmentId", + table: "stock_adjustment_lines", + column: "AdjustmentId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustment_lines_BatchId", + table: "stock_adjustment_lines", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustment_lines_BinId", + table: "stock_adjustment_lines", + column: "BinId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustment_lines_ItemId", + table: "stock_adjustment_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustment_lines_SerialId", + table: "stock_adjustment_lines", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustments_CreatedBy", + table: "stock_adjustments", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustments_DocNo", + table: "stock_adjustments", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustments_ReasonCodeId", + table: "stock_adjustments", + column: "ReasonCodeId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_adjustments_WarehouseId", + table: "stock_adjustments", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_lines_BatchId", + table: "stock_transfer_lines", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_lines_DestBinId", + table: "stock_transfer_lines", + column: "DestBinId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_lines_ItemId", + table: "stock_transfer_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_lines_SerialId", + table: "stock_transfer_lines", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_lines_SrcBinId", + table: "stock_transfer_lines", + column: "SrcBinId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_lines_TransferId", + table: "stock_transfer_lines", + column: "TransferId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfers_CreatedBy", + table: "stock_transfers", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfers_DestWarehouseId", + table: "stock_transfers", + column: "DestWarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfers_DocNo", + table: "stock_transfers", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfers_SrcWarehouseId", + table: "stock_transfers", + column: "SrcWarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfers_Status", + table: "stock_transfers", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "stock_adjustment_lines"); + + migrationBuilder.DropTable( + name: "stock_transfer_lines"); + + migrationBuilder.DropTable( + name: "stock_adjustments"); + + migrationBuilder.DropTable( + name: "stock_transfers"); + + migrationBuilder.DropTable( + name: "reason_codes"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260713105512_AddCountsAndReturns.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260713105512_AddCountsAndReturns.Designer.cs new file mode 100644 index 0000000..15cd145 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260713105512_AddCountsAndReturns.Designer.cs @@ -0,0 +1,2134 @@ +// +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("20260713105512_AddCountsAndReturns")] + partial class AddCountsAndReturns + { + /// + 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.Batch", b => + { + b.Property("BatchId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("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.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("GrnId") + .HasColumnType("bigint"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoLineId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId")); + + b.Property("BaseUomId") + .HasColumnType("bigint"); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultVendorId") + .HasColumnType("bigint"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("ItemType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("ItemId"); + + b.HasIndex("BaseUomId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("bigint") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("bigint"); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("CountId") + .HasColumnType("bigint"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("GrnLineId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SourceDocId") + .HasColumnType("bigint"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DestWarehouseId") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("DestBinId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SrcBinId") + .HasColumnType("bigint"); + + b.Property("TransferId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ToUomId") + .HasColumnType("bigint"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1L, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("bigint"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.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/20260713105512_AddCountsAndReturns.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260713105512_AddCountsAndReturns.cs new file mode 100644 index 0000000..bc674d9 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260713105512_AddCountsAndReturns.cs @@ -0,0 +1,253 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class AddCountsAndReturns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "purchase_returns", + columns: table => new + { + ReturnId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + VendorId = table.Column(type: "bigint", nullable: false), + WarehouseId = table.Column(type: "bigint", nullable: false), + ReasonCodeId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CreatedBy = table.Column(type: "bigint", 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_counts", + columns: table => new + { + CountId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DocNo = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + WarehouseId = table.Column(type: "bigint", 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: "bigint", 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: "purchase_return_lines", + columns: table => new + { + ReturnLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ReturnId = table.Column(type: "bigint", nullable: false), + GrnLineId = table.Column(type: "bigint", nullable: true), + ItemId = table.Column(type: "bigint", nullable: false), + Qty = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_purchase_return_lines", x => x.ReturnLineId); + table.ForeignKey( + name: "FK_purchase_return_lines_grn_lines_GrnLineId", + column: x => x.GrnLineId, + principalTable: "grn_lines", + principalColumn: "GrnLineId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_purchase_return_lines_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_purchase_return_lines_purchase_returns_ReturnId", + column: x => x.ReturnId, + principalTable: "purchase_returns", + principalColumn: "ReturnId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "stock_count_lines", + columns: table => new + { + CountLineId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CountId = table.Column(type: "bigint", nullable: false), + ItemId = table.Column(type: "bigint", nullable: false), + BinId = table.Column(type: "bigint", 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.CreateIndex( + name: "IX_purchase_return_lines_GrnLineId", + table: "purchase_return_lines", + column: "GrnLineId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_return_lines_ItemId", + table: "purchase_return_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_return_lines_ReturnId", + table: "purchase_return_lines", + column: "ReturnId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_returns_CreatedBy", + table: "purchase_returns", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_returns_DocNo", + table: "purchase_returns", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_purchase_returns_ReasonCodeId", + table: "purchase_returns", + column: "ReasonCodeId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_returns_VendorId", + table: "purchase_returns", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_purchase_returns_WarehouseId", + table: "purchase_returns", + column: "WarehouseId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_count_lines_BinId", + table: "stock_count_lines", + column: "BinId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_count_lines_CountId", + table: "stock_count_lines", + column: "CountId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_count_lines_ItemId", + table: "stock_count_lines", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_counts_CreatedBy", + table: "stock_counts", + column: "CreatedBy"); + + migrationBuilder.CreateIndex( + name: "IX_stock_counts_DocNo", + table: "stock_counts", + column: "DocNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stock_counts_Status", + table: "stock_counts", + column: "Status"); + + migrationBuilder.CreateIndex( + name: "IX_stock_counts_WarehouseId", + table: "stock_counts", + column: "WarehouseId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "purchase_return_lines"); + + migrationBuilder.DropTable( + name: "stock_count_lines"); + + migrationBuilder.DropTable( + name: "purchase_returns"); + + migrationBuilder.DropTable( + name: "stock_counts"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260713121825_AddAuditAndJournal.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260713121825_AddAuditAndJournal.Designer.cs new file mode 100644 index 0000000..04650de --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260713121825_AddAuditAndJournal.Designer.cs @@ -0,0 +1,2222 @@ +// +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("20260713121825_AddAuditAndJournal")] + partial class AddAuditAndJournal + { + /// + 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("bigint"); + + 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("bigint"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("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.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("GrnId") + .HasColumnType("bigint"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoLineId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("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.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId")); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("bigint") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("integer") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("DocType", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("numeric(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("UomId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("PoId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequestedBy") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RequisitionId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("bigint"); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("CountId") + .HasColumnType("bigint"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("GrnLineId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SourceDocId") + .HasColumnType("bigint"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DestWarehouseId") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("DestBinId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SrcBinId") + .HasColumnType("bigint"); + + b.Property("TransferId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("ToUomId") + .HasColumnType("bigint"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1L, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("bigint"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("bigint"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.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/20260713121825_AddAuditAndJournal.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260713121825_AddAuditAndJournal.cs new file mode 100644 index 0000000..0db846e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260713121825_AddAuditAndJournal.cs @@ -0,0 +1,87 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class AddAuditAndJournal : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "audit_logs", + columns: table => new + { + AuditId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "bigint", nullable: false), + EntityType = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + EntityId = table.Column(type: "bigint", 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: "journal_entry_stubs", + columns: table => new + { + JournalId = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SourceDocType = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + SourceDocId = table.Column(type: "bigint", 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.CreateIndex( + name: "IX_audit_logs_CreatedAt", + table: "audit_logs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_audit_logs_EntityType_EntityId", + table: "audit_logs", + columns: new[] { "EntityType", "EntityId" }); + + migrationBuilder.CreateIndex( + name: "IX_audit_logs_UserId", + table: "audit_logs", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_journal_entry_stubs_SourceDocType_SourceDocId", + table: "journal_entry_stubs", + columns: new[] { "SourceDocType", "SourceDocId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "audit_logs"); + + migrationBuilder.DropTable( + name: "journal_entry_stubs"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index bf2c3f1..0d3e800 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -22,6 +22,75 @@ namespace ERPCore.Infra.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId")); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.HasKey("BatchId"); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => { b.Property("BinId") @@ -73,6 +142,126 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("categories", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PoId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("GrnId") + .HasColumnType("bigint"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("PoLineId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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") @@ -185,6 +374,43 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("item_reorders", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + 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("bigint"); + + 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") @@ -322,6 +548,118 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("purchase_orders", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("VendorId") + .HasColumnType("bigint"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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") @@ -449,6 +787,458 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("rfq_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("ReasonCodeId") + .HasColumnType("bigint"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("bigint"); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + 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("bigint"); + + 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("bigint"); + + 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("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("CountId") + .HasColumnType("bigint"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("GrnLineId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + 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("bigint"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("BinId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SourceDocId") + .HasColumnType("bigint"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("bigint"); + + b.Property("DestWarehouseId") + .HasColumnType("bigint"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("bigint"); + + 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("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("bigint"); + + b.Property("DestBinId") + .HasColumnType("bigint"); + + b.Property("ItemId") + .HasColumnType("bigint"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("bigint"); + + b.Property("SrcBinId") + .HasColumnType("bigint"); + + b.Property("TransferId") + .HasColumnType("bigint"); + + 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") @@ -688,6 +1478,26 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("warehouses", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => { b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") @@ -709,6 +1519,88 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Parent"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") @@ -815,6 +1707,67 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Vendor"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => { b.HasOne("ERPCore.Domain.Entities.User", "Requester") @@ -875,6 +1828,263 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Rfq"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") @@ -945,6 +2155,11 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Children"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => { b.Navigation("ReorderSettings"); @@ -957,6 +2172,11 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => { b.Navigation("Lines"); @@ -969,6 +2189,21 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Quotations"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => { b.Navigation("Lines"); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 73b5f1a..d03cea2 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -6,6 +6,7 @@ using ERPCore.Repositories; using ERPCore.Repositories.Interfaces; using ERPCore.Services; using ERPCore.Services.Interfaces; +using ERPCore.Services.Stock; using ERPCore.System.Errors; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi; @@ -54,6 +55,23 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// Stock core + goods receipt (docs/11 §4–5) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Stock transactions + reference data (docs/11 §5–6) +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13) +builder.Services.AddScoped(); + // Health checks (EF Core DB) builder.Services.AddHealthChecks().AddDbContextCheck(); @@ -63,6 +81,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/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..49290a1 --- /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, long? entityId, long? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default) + { + var q = _logs.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType); + if (entityId is not null) q = q.Where(l => l.EntityId == entityId); + if (userId is not null) q = q.Where(l => l.UserId == userId); + if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue)); + if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue)); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(l => l.AuditId) + .Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + + var dtos = rows.Select(l => new AuditLogDto( + l.AuditId, l.UserId, l.EntityType, l.EntityId, l.Action, + JsonSerializer.Deserialize(l.ChangeSet), l.CreatedAt)).ToList(); + + return PagedResponse.Create(dtos, query.Page, query.PageSize, total); + } + + public async Task> ListJournalAsync( + string? sourceDocType, long? sourceDocId, PageQuery query, CancellationToken ct = default) + { + var q = _journal.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(j => j.SourceDocType == sourceDocType); + if (sourceDocId is not null) q = q.Where(j => j.SourceDocId == sourceDocId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(j => j.JournalId) + .Skip(query.Skip).Take(query.PageSize) + .Select(j => new JournalEntryStubDto(j.JournalId, j.SourceDocType, j.SourceDocId, j.DebitAccount, j.CreditAccount, j.Amount)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } +} diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs new file mode 100644 index 0000000..3e34b26 --- /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(long countId, CancellationToken ct = default) + { + var count = await _counts.Query().AsNoTracking().Include(c => c.Lines) + .FirstOrDefaultAsync(c => c.CountId == countId, ct); + return count is null ? null : Map(count); + } + + public async Task 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(long countId, EnterCountsRequest request, CancellationToken ct = default) + { + var count = await _counts.Query().Include(c => c.Lines) + .FirstOrDefaultAsync(c => c.CountId == countId, ct) + ?? throw new NotFoundException($"Count {countId} was not found."); + if (count.Status == CountStatus.Posted) + throw new ConflictException($"Count {countId} is already posted."); + + foreach (var input in request.Lines) + { + var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId) + ?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422); + line.CountedQty = input.CountedQty; + line.Variance = input.CountedQty - line.SystemQty; + } + + count.Status = CountStatus.Counted; + await _uow.SaveChangesAsync(ct); + return Map(count); + } + + public async Task PostAsync(long countId, CancellationToken ct = default) + { + var count = await _counts.Query().Include(c => c.Lines) + .FirstOrDefaultAsync(c => c.CountId == countId, ct) + ?? throw new NotFoundException($"Count {countId} was not found."); + if (count.Status == CountStatus.Posted) + throw new ConflictException($"Count {countId} is already posted."); + if (count.Status != CountStatus.Counted) + throw new ConflictException($"Count {countId} has no entered counts to post."); + + var deltas = count.Lines + .Where(l => l.Variance.HasValue && l.Variance.Value != 0) + .Select(l => new StockDelta(l.ItemId, l.BinId, null, l.Variance!.Value)) + .ToList(); + + // No variances → just close the count, no adjustment. + if (deltas.Count == 0) + { + count.Status = CountStatus.Posted; + await _uow.SaveChangesAsync(ct); + return new CountPostResultDto(count.CountId, count.Status, null, Array.Empty()); + } + + 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..fdfa83e --- /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(long grnId, CancellationToken ct = default) + { + var grn = await _grns.Query().AsNoTracking() + .Include(g => g.Lines) + .FirstOrDefaultAsync(g => g.GrnId == grnId, ct); + return grn is null ? null : Map(grn); + } + + public async Task CreateAsync(CreateGrnRequest request, CancellationToken ct = default) + { + if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422); + + // Resolve vendor + PO context. + PurchaseOrder? po = null; + long vendorId; + if (request.PoId is not null) + { + po = await _pos.Query().AsNoTracking().Include(p => p.Lines) + .FirstOrDefaultAsync(p => p.PoId == request.PoId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422); + if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived)) + throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against."); + vendorId = po.VendorId; + } + else + { + if (request.VendorId is null) + throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422); + if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422); + vendorId = request.VendorId.Value; + } + + var lines = new List(); + var batchCache = new Dictionary<(long ItemId, string BatchNo), Batch>(); + + foreach (var input in request.Lines) + { + var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422); + if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct)) + throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422); + if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422); + + // Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct. + decimal unitCost; + if (input.PoLineId is not null) + { + var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId) + ?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422); + if (poLine.ItemId != input.ItemId) + throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422); + + var openQty = poLine.Qty - poLine.QtyReceived; + if (input.Qty > openQty * (1 + OverReceiptTolerance)) + throw new DomainException(ErrorCodes.OverReceiptTolerance, + $"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422); + + unitCost = poLine.UnitPrice; + } + else + { + unitCost = input.UnitCost; + } + + var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct); + + lines.Add(new GrnLine + { + PoLineId = input.PoLineId, + ItemId = input.ItemId, + UomId = input.UomId, + BinId = input.BinId, + Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted + Qty = input.Qty, + UnitCost = unitCost, + ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero), + HoldStatus = input.HoldStatus + }); + } + + var grn = await _uow.ExecuteInTransactionAsync(async token => + { + var docNo = await _numbers.NextAsync(DocumentTypes.Grn, token); + var entity = new Grn + { + DocNo = docNo, + PoId = request.PoId, + VendorId = vendorId, + WarehouseId = request.WarehouseId, + Status = GrnStatus.Draft, + CreatedBy = _currentUser.AuditUserId, + CreatedAt = DateTime.UtcNow, + Lines = lines + }; + await _grns.AddAsync(entity, token); + return entity; + }, ct); + + return Map(grn); + } + + public async Task ConfirmAsync(long grnId, string? idempotencyKey, CancellationToken ct = default) + { + var grn = await _grns.Query() + .Include(g => g.Lines) + .FirstOrDefaultAsync(g => g.GrnId == grnId, ct) + ?? throw new NotFoundException($"GRN {grnId} was not found."); + + // Idempotent replay: an already-confirmed GRN returns its existing result. + if (grn.Status == GrnStatus.Confirmed) + return await BuildConfirmResultAsync(grn, ct); + if (grn.Status == GrnStatus.Closed) + throw new ConflictException($"GRN {grnId} is closed."); + + var actor = _currentUser.AuditUserId; + var now = DateTime.UtcNow; + var runningBalance = new Dictionary<(long, long), decimal>(); + var createdLayers = new List(); + 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(long grnId, long grnLineId, string action, CancellationToken ct = default) + { + var grn = await _grns.Query() + .Include(g => g.Lines) + .FirstOrDefaultAsync(g => g.GrnId == grnId, ct) + ?? throw new NotFoundException($"GRN {grnId} was not found."); + + var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId) + ?? throw new NotFoundException($"GRN line {grnLineId} was not found on GRN {grnId}."); + + if (grn.Status != GrnStatus.Confirmed) + throw new ConflictException($"GRN {grnId} must be confirmed before releasing holds."); + if (line.HoldStatus != HoldStatus.OnHold) + throw new ConflictException($"GRN line {grnLineId} is {line.HoldStatus}, not OnHold."); + + if (string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase)) + { + line.HoldStatus = HoldStatus.Available; + await _uow.SaveChangesAsync(ct); + return new ReleaseLineResultDto(grnLineId, HoldStatus.Available); + } + + // Reject: remove the held stock from on-hand and post a reversing ledger entry. + // Linking rejected stock to a formal purchase return is deferred (§3.4). + var actor = _currentUser.AuditUserId; + var now = DateTime.UtcNow; + await _uow.ExecuteInTransactionAsync(async token => + { + line.HoldStatus = HoldStatus.Rejected; + var layers = await _layers.Query() + .Where(l => l.GrnLineId == grnLineId && l.QtyRemaining > 0).ToListAsync(token); + + foreach (var layer in layers) + { + var bal = await _fifo.GetOnHandAsync(layer.ItemId, layer.WarehouseId, token) - layer.QtyRemaining; + await _fifo.PostLedgerAsync( + layer.ItemId, layer.WarehouseId, line.BinId, layer.BatchId, null, actor, + Direction.Out, layer.QtyRemaining, layer.UnitCost, bal, DocumentTypes.Grn, grn.GrnId, now, token); + layer.QtyRemaining = 0; + } + return 0; + }, ct); + + return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected); + } + + private async Task ResolveBatchAsync( + Item item, BatchInput? batch, Dictionary<(long, string), Batch> cache, CancellationToken ct) + { + if (item.TrackingMode == TrackingMode.Batch && batch is null) + throw new DomainException(ErrorCodes.Validation, $"Item {item.ItemId} is batch-tracked; a batch is required.", 422); + if (batch is null) return null; + + var key = (item.ItemId, batch.BatchNo.Trim()); + if (cache.TryGetValue(key, out var cached)) return cached; + + var existing = await _batches.Query().FirstOrDefaultAsync(b => b.ItemId == item.ItemId && b.BatchNo == key.Item2, ct); + if (existing is not null) + { + cache[key] = existing; + return existing; + } + + var created = new Batch { ItemId = item.ItemId, BatchNo = key.Item2, ExpiryDate = batch.ExpiryDate }; + await _batches.AddAsync(created, ct); + cache[key] = created; + return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges + } + + private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync( + Item item, long uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct) + { + if (uomId == item.BaseUomId) + return (qty, unitCostPerUom); + + var conv = await _conversions.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct) + ?? throw new DomainException(ErrorCodes.Validation, + $"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422); + + return (qty * conv.Factor, unitCostPerUom / conv.Factor); + } + + private async Task UpdatePoStatusAsync(long? poId, CancellationToken ct) + { + if (poId is null) return; + var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct); + if (po is null) return; + + po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty) + ? PurchaseOrderStatus.FullyReceived + : PurchaseOrderStatus.PartiallyReceived; + po.UpdatedAt = DateTime.UtcNow; + } + + private async Task GetPoStatusAsync(long? poId, CancellationToken ct) + { + if (poId is null) return null; + return await _pos.Query().AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct); + } + + private async Task 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..c9b503d --- /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, long? entityId, long? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default); + + Task> ListJournalAsync( + string? sourceDocType, long? 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..51d89cc --- /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(long countId, CancellationToken ct = default); + Task CreateAsync(CreateCountRequest request, CancellationToken ct = default); + Task EnterCountsAsync(long countId, EnterCountsRequest request, CancellationToken ct = default); + Task PostAsync(long 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..ef78632 --- /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( + long itemId, long warehouseId, long? batchId, long? serialId, long? 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( + long itemId, long warehouseId, long? batchId, decimal qtyBase, CancellationToken ct = default); + + /// Append an immutable ledger entry (value = qtyBase × unitCost). + Task PostLedgerAsync( + long itemId, long warehouseId, long? binId, long? batchId, long? serialId, long userId, + Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, + string sourceDocType, long sourceDocId, DateTime createdAt, CancellationToken ct = default); + + /// Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse. + Task GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default); + + /// Valuation over open layers: Σ(qtyRemaining × unitCost) (FR-STK-04). + Task GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default); +} + +/// A quantity consumed from one FIFO layer at that layer's unit cost. +public sealed record ConsumedSegment(long 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..a02d883 --- /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(long grnId, CancellationToken ct = default); + Task CreateAsync(CreateGrnRequest request, CancellationToken ct = default); + + /// Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically. + Task ConfirmAsync(long grnId, string? idempotencyKey, CancellationToken ct = default); + + Task ReleaseLineAsync(long grnId, long grnLineId, string action, 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..dbb4fd0 --- /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(long? warehouseId, PageQuery query, CancellationToken ct = default); + Task CreateSuggestedRequisitionAsync(long itemId, long warehouseId, 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..f603cc5 --- /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(long ItemId, long? BinId, long? 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( + long warehouseId, string sourceDocType, long 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..d8cc6d1 --- /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(long itemId, long warehouseId, CancellationToken ct = default); + + Task> GetLedgerAsync( + long? itemId, long? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default); + + Task GetValuationAsync(long itemId, long 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..6eceb63 --- /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(long transferId, CancellationToken ct = default); + Task CreateAsync(CreateTransferRequest request, CancellationToken ct = default); + + /// Dispatch: consume source FIFO layers into in-transit (row-locked). + Task DispatchAsync(long transferId, CancellationToken ct = default); + + /// Receive: create the destination layer at the inherited (cost-preserving) cost. + Task ReceiveAsync(long transferId, ReceiveTransferRequest request, CancellationToken ct = default); +} 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..28a5322 --- /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(long? warehouseId, PageQuery query, CancellationToken ct = default) + { + var q = _reorders.Query().AsNoTracking(); + if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); + var policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct); + + var alerts = new List(); + 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(long itemId, long warehouseId, CancellationToken ct = default) + { + var policy = await _reorders.Query().AsNoTracking() + .FirstOrDefaultAsync(r => r.ItemId == itemId && r.WarehouseId == warehouseId, ct) + ?? throw new DomainException(ErrorCodes.Validation, + $"No reorder policy for item {itemId} at warehouse {warehouseId}.", 422); + + return await _requisitions.CreateAsync(new CreateRequisitionRequest + { + Lines = [new CreateRequisitionLineInput { ItemId = itemId, Qty = policy.ReorderQty }] + }, ct); + } +} diff --git a/Backend/ERPCore/Services/Stock/FifoCostingService.cs b/Backend/ERPCore/Services/Stock/FifoCostingService.cs new file mode 100644 index 0000000..6f4f0a8 --- /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( + long itemId, long warehouseId, long? batchId, long? serialId, long? grnLineId, + decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default) + { + var layer = new StockLayer + { + ItemId = itemId, + WarehouseId = warehouseId, + BatchId = batchId, + SerialId = serialId, + GrnLineId = grnLineId, + QtyReceived = qtyBase, + QtyRemaining = qtyBase, + UnitCost = unitCost, + ReceiptDate = receiptDate + }; + await _layers.AddAsync(layer, ct); + return layer; + } + + public async Task> ConsumeAsync( + long itemId, long warehouseId, long? batchId, decimal qtyBase, CancellationToken ct = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + if (batchId is not null) + { + var expiry = await _db.Batches.AsNoTracking() + .Where(b => b.BatchId == batchId).Select(b => b.ExpiryDate).FirstOrDefaultAsync(ct); + if (expiry is not null && expiry < today) + throw new DomainException(ErrorCodes.ExpiredBatchBlocked, + $"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409); + } + + // Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02). + // Excludes on-hold (grn line) and expired-batch stock. No LINQ is composed on + // top of the raw SQL so the FOR UPDATE reaches the database intact. + // {batchId}::bigint casts give Npgsql an explicit type for the (possibly null) + // parameter — without it a null batch filter fails with 42P18. + var layers = await _db.StockLayers + .FromSqlInterpolated($""" + SELECT * FROM stock_layers sl + WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0 + AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint) + AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" = 'OnHold') + AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today}) + ORDER BY sl."ReceiptDate", sl."LayerId" + FOR UPDATE + """) + .ToListAsync(ct); + + var available = layers.Sum(l => l.QtyRemaining); + if (available < qtyBase) + { + var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l => + l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 + && (batchId == null || l.BatchId == batchId) + && l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold, ct); + if (heldExists) + throw new DomainException(ErrorCodes.OnHoldNotIssuable, + $"Stock for item {itemId} at warehouse {warehouseId} is on inspection hold and cannot be issued.", 409); + + throw new DomainException(ErrorCodes.StockNegativeBlocked, + $"Available {available} < requested {qtyBase} for item {itemId} at warehouse {warehouseId}.", 409); + } + + var segments = new List(); + 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( + long itemId, long warehouseId, long? binId, long? batchId, long? serialId, long userId, + Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, + string sourceDocType, long sourceDocId, DateTime createdAt, CancellationToken ct = default) + { + var entry = new StockLedger + { + ItemId = itemId, + WarehouseId = warehouseId, + BinId = binId, + BatchId = batchId, + SerialId = serialId, + UserId = userId, + Direction = direction, + QtyBase = qtyBase, + UnitCost = unitCost, + Value = Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero), + RunningBalance = runningBalance, + SourceDocType = sourceDocType, + SourceDocId = sourceDocId, + CreatedAt = createdAt + }; + await _ledger.AddAsync(entry, ct); + + // GL-ready journal entry per movement (FR-STK-13; data only, no posting). + // Inbound debits Inventory / credits Clearing; outbound reverses. + var (debit, credit) = direction == Direction.In + ? (InventoryAccount, ClearingAccount) + : (ClearingAccount, InventoryAccount); + await _journal.AddAsync(new JournalEntryStub + { + SourceDocType = sourceDocType, + SourceDocId = sourceDocId, + DebitAccount = debit, + CreditAccount = credit, + Amount = entry.Value + }, ct); + + return entry; + } + + public async Task GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default) + => await _layers.Query().AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + + public async Task GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default) + { + var open = await _layers.Query().AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0) + .OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId) + .Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate }) + .ToListAsync(ct); + + var layers = open.Select(l => new StockValuationLayerDto( + l.LayerId, l.QtyRemaining, l.UnitCost, + Math.Round(l.QtyRemaining * l.UnitCost, 4, MidpointRounding.AwayFromZero), l.ReceiptDate)).ToList(); + + return new StockValuationDto( + itemId, warehouseId, layers, + layers.Sum(l => l.QtyRemaining), layers.Sum(l => l.Value), BaseCurrency, Method); + } +} diff --git a/Backend/ERPCore/Services/Stock/StockMutator.cs b/Backend/ERPCore/Services/Stock/StockMutator.cs new file mode 100644 index 0000000..729f72e --- /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( + long warehouseId, string sourceDocType, long 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(long itemId, long warehouseId, CancellationToken ct) + => await _layers.Query().AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId) + .OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId) + .Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct) ?? 0m; +} diff --git a/Backend/ERPCore/Services/Stock/StockService.cs b/Backend/ERPCore/Services/Stock/StockService.cs new file mode 100644 index 0000000..722a069 --- /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(long itemId, long warehouseId, CancellationToken ct = default) + { + var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct); + + // On-hold stock is on-hand but not issuable — sourced from GRN lines still OnHold. + var onHold = await _layers.Query().AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId + && l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + + // Outbound in-transit: dispatched from this warehouse, not yet received at the + // destination. Dispatch already consumed the source layers, so this stock has + // left onHand — it is reported for visibility (AR-05) but is NOT re-subtracted + // from available (that would double-count). reserved (sales) stays a stub. + var inTransit = await _transferLines.Query().AsNoTracking() + .Where(l => l.ItemId == itemId + && l.Transfer!.SrcWarehouseId == warehouseId + && l.Transfer.Status == TransferStatus.InTransit) + .SumAsync(l => (decimal?)(l.Qty - l.QtyReceived), ct) ?? 0m; + const decimal reserved = 0m; + var available = onHand - onHold - reserved; + + return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow); + } + + public async Task> GetLedgerAsync( + long? itemId, long? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default) + { + var q = _ledger.Query().AsNoTracking(); + if (itemId is not null) q = q.Where(l => l.ItemId == itemId); + if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId); + if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue)); + if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue)); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(l => l.LedgerId) + .Skip(query.Skip).Take(query.PageSize) + .Select(l => new StockLedgerRowDto( + l.LedgerId, l.ItemId, l.WarehouseId, l.BinId, l.BatchId, l.SerialId, + l.Direction, l.QtyBase, l.UnitCost, l.Value, l.RunningBalance, + l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public Task GetValuationAsync(long itemId, long 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..4c765f5 --- /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(long transferId, CancellationToken ct = default) + { + var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines) + .FirstOrDefaultAsync(x => x.TransferId == transferId, ct); + return t is null ? null : Map(t); + } + + public async Task 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(long transferId, CancellationToken ct = default) + { + var transfer = await _transfers.Query().Include(t => t.Lines) + .FirstOrDefaultAsync(t => t.TransferId == transferId, ct) + ?? throw new NotFoundException($"Transfer {transferId} was not found."); + if (transfer.Status != TransferStatus.Draft) + throw new ConflictException($"Transfer {transferId} is {transfer.Status}; only a Draft can be dispatched."); + + var actor = _currentUser.AuditUserId; + var now = DateTime.UtcNow; + var consumed = new List(); + 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(long transferId, ReceiveTransferRequest request, CancellationToken ct = default) + { + var transfer = await _transfers.Query().Include(t => t.Lines) + .FirstOrDefaultAsync(t => t.TransferId == transferId, ct) + ?? throw new NotFoundException($"Transfer {transferId} was not found."); + if (transfer.Status != TransferStatus.InTransit) + throw new ConflictException($"Transfer {transferId} is {transfer.Status}; only an InTransit transfer can be received."); + + var actor = _currentUser.AuditUserId; + var now = DateTime.UtcNow; + var createdLayers = new List(); + 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(long warehouseId, CancellationToken ct) + { + if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422); + } + + private static TransferDto Map(StockTransfer t) => new( + t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status, + t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto( + l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList()); +} diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 66d7ab5..a1761e9 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -29,36 +29,40 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Requisition (+ lines) + submit (`POST /requisitions`, `/{id}/submit`, list, get) - [~] RFQ + quotations + comparison (`POST /rfqs`, `/{id}/quotations` [one per vendor], `GET /{id}/comparison` matrix) - [~] Purchase Order: create (auto-approve, `approvalRequired` flag), edit-while-open (If-Match), approve (no-op), cancel -- [ ] Purchase Return (outbound movement, reason code) — **deferred**: needs GRN lines + stock ledger/FIFO (§3/§4). Build with those. +- [~] 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. +- [~] 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. +- [~] 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. +- [~] 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. +- [~] `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). +- [~] 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). +- [~] 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`). +- [~] 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. +- [~] 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. +- [~] Reorder alerts (query) + suggest requisition — `GET /stock/reorder-alerts` (available ≤ ROP, computed on read) + `POST …/{itemId}/requisition` (draft PR at suggested qty). Verified. +syte ## 6. Cross-cutting -> **Auth-enforcement gap (open):** JWT bearer *validation* is wired, but no token issuer exists yet and controllers are **not** `[Authorize]`-gated, so §1 endpoints are currently open. This is the AR-01/NFR-03 control surface — gate all v1 endpoints (fallback authorization policy) in the same change as `POST /auth/login`, then re-run the 02-SECURITY B.1 checklist and flip §1 items to `[x]`. -- [ ] Audit log on every mutation (who/when/old→new) +> **Status:** audit trail, doc numbering, reason codes, JournalEntryStub, negative-stock block all **done**. The **one** remaining item is authentication. **Auth-enforcement gap (open):** JWT bearer *validation* is wired, but no token issuer exists yet and controllers are **not** `[Authorize]`-gated, so 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–§5 items to `[x]`. (The AR-01 **audit** compensating control is now in place.) +- [x] Audit log on every mutation (who/when/old→new) — `AuditLog` (jsonb `changeSet`), written by an `ErpDbContext.SaveChanges` override (`AuditScribe`): Create captures the field set, Update captures **only changed fields as {old,new}**, Delete captures the prior row; PK/RowVersion excluded; ledger/layer/seq/self/journal excluded. Actor from `ICurrentUser` (system=1 until auth). Read via `GET /audit-logs`. **Verified** (Item create+update old→new; StockAdjustment create). This is the **AR-01 compensating control** (02-SECURITY B.3) — app-level append-only; DB-role UPDATE/DELETE revoke still deferred. - [x] Document numbering sequences (per type, per year) — `NumberSequence` + `NumberSequenceService` (atomic `INSERT … ON CONFLICT … RETURNING` inside the doc's UoW txn; gap-controlled). Verified issuing + incrementing PR/RFQ/PO. - [~] Auth: simple in-app login → JWT (`POST /auth/login`) — foundation only: `User` table + seeded `system` user (id 1) exist and `ICurrentUser.AuditUserId` stamps docs; login endpoint + `[Authorize]` still pending. -- [ ] JournalEntryStub emitted per stock movement (data only) -- [ ] Negative-stock policy enforcement (default block) -- [ ] FEFO picking for perishables; block expired / on-hold issue +- [x] JournalEntryStub emitted per stock movement (data only) — `JournalEntryStub` written in `FifoCostingService.PostLedgerAsync` for every ledger entry (In → Dr Inventory `1300` / Cr Clearing `2100`; Out reverses; amount = movement value). Placeholder accounts until a chart of accounts exists. Read via `GET /journal-entries`. **Verified** (GRN In 700, ADJ Out 70). +- [x] Negative-stock policy enforcement (default block) — enforced in `FifoCostingService.ConsumeAsync` → `409 STOCK_NEGATIVE_BLOCKED` (verified). Per-item override still a config stub. +- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built. +- [x] Reason codes (FR-X-04) — `ReasonCode` entity + `GET/POST /reason-codes`; standard set (docs/10 §B.8.3) seeded idempotently at startup (`DataSeeder`). Verified. ## Deferred (Phase 2+ — do NOT build now, hooks only) - [ ] Vendor invoice + three-way match @@ -88,3 +92,31 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - **Verified:** build clean; app boots; full procurement smoke green (see §2 note) — requisition→submit, RFQ→quotation→comparison, PO create/get/edit/approve/cancel, numbering increment, error paths 409/412/422. - **Deferred/next:** Purchase Return (needs GRN+stock), then §3 GRN, §4 Stock Core (FIFO/ledger), §5 stock transactions. Auth/audit (§6) still the gate for flipping §1/§2 to `[x]`. - Housekeeping: an empty user-created migration `20260709124415_initial` sits between InitialCreate and AddProcurement (applied, harmless no-op; emits a cosmetic CS8981 lowercase-name warning). + +### 2026-07-13 — Stock Core (§4) + Goods Receipt (§3, minus purchase return) +- Entities: Batch, Serial, StockLayer (FIFO), StockLedger (append-only), Grn/GrnLine + configs; enums Direction, HoldStatus, GrnStatus. DbSets + migration `AddStockAndGrn` (6 tables) applied. +- Services: `FifoCostingService` (Services/Stock — inbound layer, ledger post, on-hand, valuation), `StockService` (enquiry/ledger/valuation), `GrnService` (create with PO-derived cost + over-receipt + batch resolve + UOM→base; confirm in `ExecuteInTransactionAsync`; release/reject). Controllers `/stock`, `/grns`. +- **Verified against Postgres:** PO→GRN receive (client cost ignored, PO price used) → confirm → 2 FIFO layers + 2 ledger rows (running balance) → PO FullyReceived; on-hand 5000 / valuation 62500 / ledger In; **idempotent re-confirm** (on-hand stayed 5000); over-receipt `422` at the open-qty boundary, exact-fill `201`; batch receive **OnHold** → excluded from `available` → **release** → available; **UOM conversion** 10×Box-12 → 120 base units @10 (value 1200). +- **Deferred (next):** §5 stock transactions — Transfer (dispatch/receive, in-transit, cost-preserving), Adjustment (auto-post + reason code), Count, Reorder alerts — which is where the FIFO **oldest-first consume + row lock** lands; then Purchase Return (§3.4) and GRN reject→return linkage. `ReasonCode` (§6) needed for adjustments/returns. Auth/audit (§6) remains the `[x]` gate. + +### 2026-07-13 (2) — Stock Transactions (§5: Adjustment, Transfer, Reorder) + FIFO consume + Reason codes +- **FIFO consume engine** in `FifoCostingService.ConsumeAsync`: oldest-first, row-locked via `SELECT … FOR UPDATE` (raw SQL, no LINQ composed on top so the lock reaches PG; `{batchId}::bigint` cast avoids a 42P18 null-param error), excludes on-hold + expired layers, throws `STOCK_NEGATIVE_BLOCKED`/`ONHOLD_NOT_ISSUABLE`/`EXPIRED_BATCH_BLOCKED`, returns consumed segments for cost-preserving moves. +- Entities: ReasonCode, StockAdjustment/Line, StockTransfer/Line (+ `UnitCost`/`QtyReceived` extension on the transfer line for cost-preservation) + configs; enums TransferStatus, AdjustmentStatus, ReasonContext. Migration `AddStockTransactions` (5 tables) applied. +- Services/controllers: `ReasonCodeService` (`/reason-codes`, startup seed via `DataSeeder`), `AdjustmentService` (`/stock-adjustments`), `TransferService` (`/stock-transfers` create/dispatch/receive), `ReorderService` (`/stock/reorder-alerts` + suggest-requisition). `StockService.GetOnHandAsync` now computes real inTransit. +- **Verified against Postgres:** adjustment decrease FIFO-consume with blended cost 10.25 across two layers, negative→409, `REASON_CODE_REQUIRED`→400, wrong-context reason→422, increase-at-last-cost; transfer create→dispatch (source onHand↓, inTransit↑, consumedLayers)→receive (dest layer cost-preserved @12, value 3600), dest==src→422, dispatch-short→409; reorder alerts list + draft-PR suggestion; reason codes seeded (5 Adjustment + 4 Return). +- **Deferred (next):** §5 **Count** (create snapshot → enter counts → post variance via the Adjustment engine), **Purchase Return** (§3.4, + GRN reject→return linkage), FEFO pick ordering, `JournalEntryStub`. Auth/audit (§6) remains the `[x]` gate. + +### 2026-07-13 (3) — Count (§5.6) + Purchase Return (§3.4) + shared StockMutator +- **`StockMutator`** (Services/Stock): shared signed-delta poster (negative → FIFO consume; positive → layer at last cost) + ledger, run inside the caller's txn. Adjustment/Count-post/Return all delegate to it → one code path for stock-affecting postings. +- Refactored `AdjustmentService` onto `StockMutator` and added an **intermediate `SaveChanges`** so the header id is flushed before ledger posting — fixes a latent bug where new-in-txn documents wrote `sourceDocId=0` (verified: `ADJ` ledger now `sourceDocId=5`). Also fixed `ledgerRefs:[0]` by mapping ledger ids **after** commit (Count + Return). +- Entities: StockCount/Line (+ CountType, CountStatus), PurchaseReturn/Line (+ ReturnStatus) + configs. Migration `AddCountsAndReturns` (4 tables) applied. +- Services/controllers: `CountService` (`/stock-counts` create/enter/post), `PurchaseReturnService` (`/purchase-returns`). +- **Verified against Postgres:** count snapshot 500 → counted 485 → post variance −15 (on-hand→485, adjustmentId+ledgerRefs), positive variance +10, re-post→409; return 100 outbound (on-hand→385), `REASON_CODE_REQUIRED`→400, non-Return reason→422, over-return→409 `STOCK_NEGATIVE_BLOCKED`. +- **§5 is now complete.** Remaining Phase-1 backend: §6 (auth/login + `[Authorize]`, audit trail, `JournalEntryStub`, negative-stock per-item override, FEFO pick ordering). Auth/audit is still the gate for flipping §1–§5 `[~]`→`[x]`. + +### 2026-07-13 (4) — §6 audit trail + JournalEntryStub (auth deferred by request) +- **Audit trail (FR-X-02):** `AuditLog` (jsonb `changeSet`) written by an `ErpDbContext.SaveChanges/Async` override via `AuditScribe` — captures before save (accurate old→new), writes rows after inserts get their keys. Create = field set, Update = only changed fields `{old,new}`, Delete = prior row; excludes PK/RowVersion and the ledger/layer/sequence/journal/self tables. Actor from `ICurrentUser` (system=1). `ErpDbContext` now takes `ICurrentUser` (design-time migration still works via DI). +- **JournalEntryStub (FR-STK-13):** emitted for every ledger entry in `FifoCostingService.PostLedgerAsync` (In → Dr `1300`/Cr `2100`; Out reverses; amount = value). Placeholder GL accounts. +- Read endpoints (auditor role, beyond documented §11): `GET /audit-logs` (entityType/entityId/userId/from/to), `GET /journal-entries` (sourceDocType/sourceDocId). `AuditService`. Migration `AddAuditAndJournal` (2 tables, jsonb) applied. +- **Verified against Postgres:** item Create logged full field set (userId 1); item Update logged only `Name` + `UpdatedAt` as `{old,new}`; GRN confirm → journal In Dr1300/Cr2100 amount 700; adjustment decrease → journal Out Dr2100/Cr1300 amount 70; StockAdjustment Create audited. +- **Only auth remains for Phase 1.** Everything else in §6 is done. Auth (`POST /auth/login` + global `[Authorize]`) 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).