From a788349138735e38281357183bb9888f2c451463 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Wed, 7 Oct 2026 14:00:32 +0530 Subject: [PATCH] serial numbers --- Backend/ERPCore.Tests/ItemTrackingTests.cs | 366 + .../ERPCore/Controllers/SerialsController.cs | 41 + .../ERPCore/Controllers/StockController.cs | 15 +- .../ERPCore/Controllers/TrackingController.cs | 64 + .../Controllers/WarrantiesController.cs | 30 + Backend/ERPCore/Domain/Entities/GrnLine.cs | 8 + .../ERPCore/Domain/Entities/GrnLineSerial.cs | 22 + .../Domain/Entities/SalesInvoiceLine.cs | 7 + .../Domain/Entities/SalesLineAllocation.cs | 35 + .../Domain/Entities/SalesLineSerial.cs | 29 + .../Domain/Entities/SalesReturnLine.cs | 21 +- .../ERPCore/Domain/Entities/SalesSlipLine.cs | 7 + Backend/ERPCore/Domain/Entities/Serial.cs | 34 +- .../ERPCore/Domain/Entities/SerialMovement.cs | 46 + .../Domain/Entities/StockAdjustmentLine.cs | 1 + .../Domain/Entities/StockTransferLine.cs | 23 + .../Domain/Entities/WarrantyRegistration.cs | 42 + Backend/ERPCore/Domain/Enums/SerialStatus.cs | 25 + .../ERPCore/Domain/Enums/WarrantyStatus.cs | 10 + Backend/ERPCore/Dtos/Grn/GrnDtos.cs | 14 +- .../Dtos/Procurement/PurchaseReturnDtos.cs | 6 + .../ERPCore/Dtos/Sales/SalesInvoiceDtos.cs | 13 +- Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs | 30 +- Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs | 9 +- Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs | 12 +- Backend/ERPCore/Dtos/Stock/TrackingDtos.cs | 40 + Backend/ERPCore/Dtos/Stock/TransferDtos.cs | 14 +- .../BatchSerialConfiguration.cs | 9 +- .../Configurations/GrnConfiguration.cs | 20 + .../ItemTrackingConfiguration.cs | 118 + .../SalesInvoiceConfiguration.cs | 5 + .../SalesReturnConfiguration.cs | 15 + .../StockAdjustmentConfiguration.cs | 2 +- .../ERPCore/Infra/Persistence/ErpDbContext.cs | 8 + ...AddItemTrackingSerialsWarranty.Designer.cs | 9481 +++++++++++++++++ ...07054513_AddItemTrackingSerialsWarranty.cs | 635 ++ .../Migrations/ErpDbContextModelSnapshot.cs | 567 +- Backend/ERPCore/Program.cs | 2 + Backend/ERPCore/Services/AdjustmentService.cs | 37 +- Backend/ERPCore/Services/CountService.cs | 43 +- Backend/ERPCore/Services/GrnService.cs | 131 +- .../Interfaces/IFifoCostingService.cs | 24 +- .../Interfaces/IItemTrackingService.cs | 46 + .../Interfaces/ISalesDomainService.cs | 13 + .../Interfaces/ISerialTrackingService.cs | 55 + .../Services/Interfaces/IStockMutator.cs | 12 +- Backend/ERPCore/Services/ItemService.cs | 19 +- .../ERPCore/Services/PurchaseReturnService.cs | 56 +- .../Services/SalesDocumentWorkflowService.cs | 4 +- .../ERPCore/Services/SalesDomainService.cs | 74 +- .../ERPCore/Services/SalesInvoiceService.cs | 12 +- .../ERPCore/Services/SalesMappingService.cs | 6 +- .../ERPCore/Services/SalesPostingService.cs | 165 +- .../ERPCore/Services/SalesReturnService.cs | 234 +- Backend/ERPCore/Services/SalesSlipService.cs | 23 +- .../Services/Stock/FifoCostingService.cs | 117 +- .../Services/Stock/ItemTrackingService.cs | 263 + .../Services/Stock/SerialTrackingService.cs | 156 + .../ERPCore/Services/Stock/StockMutator.cs | 26 +- Backend/ERPCore/Services/TransferService.cs | 131 +- Backend/ERPCore/System/Errors/ErrorCodes.cs | 5 + .../procurement/purchase-returns/new/page.tsx | 40 +- .../app/dashboard/products/settings/page.tsx | 5 +- .../app/dashboard/receiving/grn/[id]/page.tsx | 32 +- .../app/dashboard/receiving/grn/new/page.tsx | 256 +- .../dashboard/sales/free-issues/new/page.tsx | 7 +- .../dashboard/sales/invoices/[id]/page.tsx | 37 +- .../app/dashboard/sales/invoices/new/page.tsx | 66 +- .../app/dashboard/sales/warranties/page.tsx | 147 + .../dashboard/stock/adjustments/new/page.tsx | 60 +- .../app/dashboard/stock/batches/page.tsx | 147 + .../app/dashboard/stock/serials/page.tsx | 244 + .../dashboard/stock/transfers/new/page.tsx | 42 +- .../components/Layouts/AppSidebar.tsx | 14 +- .../components/Layouts/Breadcrumbs.tsx | 3 + .../erp-system/components/Layouts/Header.tsx | 3 + .../components/sales/LineTrackingPicker.tsx | 227 + .../components/sales/SalesReturnForm.tsx | 103 +- .../components/ui/suggest-input.tsx | 29 +- .../erp-system/hooks/use-tracking-settings.ts | 36 + Frontend/erp-system/lib/api/tracking.ts | 89 + Frontend/erp-system/lib/tracking-tags.ts | 15 + Frontend/erp-system/lib/validations/grn.ts | 19 +- Frontend/erp-system/types/grn.ts | 24 +- Frontend/erp-system/types/procurement.ts | 2 + Frontend/erp-system/types/sales.ts | 37 + Frontend/erp-system/types/stock.ts | 12 + Frontend/erp-system/types/tracking.ts | 120 + docs/ITEM_TRACKING_PLAN.md | 242 + 89 files changed, 15320 insertions(+), 216 deletions(-) create mode 100644 Backend/ERPCore.Tests/ItemTrackingTests.cs create mode 100644 Backend/ERPCore/Controllers/SerialsController.cs create mode 100644 Backend/ERPCore/Controllers/TrackingController.cs create mode 100644 Backend/ERPCore/Controllers/WarrantiesController.cs create mode 100644 Backend/ERPCore/Domain/Entities/GrnLineSerial.cs create mode 100644 Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs create mode 100644 Backend/ERPCore/Domain/Entities/SalesLineSerial.cs create mode 100644 Backend/ERPCore/Domain/Entities/SerialMovement.cs create mode 100644 Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs create mode 100644 Backend/ERPCore/Domain/Enums/SerialStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/WarrantyStatus.cs create mode 100644 Backend/ERPCore/Dtos/Stock/TrackingDtos.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/ItemTrackingConfiguration.cs create mode 100644 Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.Designer.cs create mode 100644 Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IItemTrackingService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ISerialTrackingService.cs create mode 100644 Backend/ERPCore/Services/Stock/ItemTrackingService.cs create mode 100644 Backend/ERPCore/Services/Stock/SerialTrackingService.cs create mode 100644 Frontend/erp-system/app/dashboard/sales/warranties/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/batches/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/serials/page.tsx create mode 100644 Frontend/erp-system/components/sales/LineTrackingPicker.tsx create mode 100644 Frontend/erp-system/hooks/use-tracking-settings.ts create mode 100644 Frontend/erp-system/lib/api/tracking.ts create mode 100644 Frontend/erp-system/lib/tracking-tags.ts create mode 100644 Frontend/erp-system/types/tracking.ts create mode 100644 docs/ITEM_TRACKING_PLAN.md diff --git a/Backend/ERPCore.Tests/ItemTrackingTests.cs b/Backend/ERPCore.Tests/ItemTrackingTests.cs new file mode 100644 index 0000000..3b83fd2 --- /dev/null +++ b/Backend/ERPCore.Tests/ItemTrackingTests.cs @@ -0,0 +1,366 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Auth; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories; +using ERPCore.Services; +using ERPCore.Services.Stock; +using ERPCore.System.Errors; +using ERPCore.Tests.TestSupport; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ERPCore.Tests; + +/// +/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO +/// SQL: the sales-line check () and the +/// shared stock-document rules (). +/// +public class ItemTrackingTests +{ + private static readonly InMemoryDatabaseRoot Root = new(); + + private const int Wh1 = 1; + private const int Wh2 = 2; + private const int SerialItemId = 10; + private const int BatchItemId = 20; + private const int PlainItemId = 30; + + // --- sales line validation -------------------------------------------------------------- + + [Fact] + public async Task SalesLine_SerialItem_RequiresOneSerialPerUnit() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet())); + + Assert.Equal(ErrorCodes.TrackingRequired, ex.Code); + } + + [Fact] + public async Task SalesLine_SerialItem_WithoutSerials_IsRejected_WhenNoPreTrackingStockIsLeft() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet())); + + Assert.Equal(ErrorCodes.TrackingRequired, ex.Code); + } + + [Fact] + public async Task SalesLine_SerialItem_WithoutSerials_IsAllowed_FromPreTrackingStock() + { + await using var db = await SeedAsync(); + // D6: stock received before serial capture — a layer with no serial. + db.StockLayers.Add(new StockLayer { ItemId = SerialItemId, WarehouseId = Wh1, QtyReceived = 3, QtyRemaining = 3, UnitCost = 10, ReceiptDate = DateTime.UtcNow }); + await db.SaveChangesAsync(); + var sales = CreateSalesDomain(db); + + var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, null, new HashSet()); + + Assert.Empty(serials); + } + + [Fact] + public async Task SalesLine_Serial_AtAnotherWarehouse_IsNotAvailable() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + // Serial 3 is in stock at warehouse 2. + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet())); + + Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code); + } + + [Fact] + public async Task SalesLine_SoldSerial_IsNotAvailable() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet())); + + Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code); + } + + [Fact] + public async Task SalesLine_SameSerialOnTwoLines_IsADuplicate() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + var documentSerials = new HashSet(); + + await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials); + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials)); + + Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code); + } + + [Fact] + public async Task SalesLine_ValidSerials_AreReturned() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1, 2 }, new HashSet()); + + Assert.Equal(new[] { 1, 2 }, serials.Select(s => s.SerialId).OrderBy(i => i)); + } + + [Fact] + public async Task SalesLine_UntrackedItem_RejectsSerials() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet())); + + Assert.Equal(ErrorCodes.Validation, ex.Code); + } + + [Fact] + public async Task SalesLine_ExpiredBatch_IsBlocked() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet())); + + Assert.Equal(ErrorCodes.ExpiredBatchBlocked, ex.Code); + } + + [Fact] + public async Task SalesLine_BatchOfAnotherItem_IsRejected() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet())); + + Assert.Equal(ErrorCodes.Validation, ex.Code); + } + + // --- stock documents (transfers / adjustments / purchase returns) ------------------------ + + [Fact] + public async Task Outbound_DamagedSerial_OnlyWhenAllowed() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + // Serial 5 is Damaged at warehouse 1: a transfer (InStock only) refuses it... + var ex = await Assert.ThrowsAsync(() => tracking.ResolveOutboundAsync( + item, Wh1, 1, new[] { 5 }, new HashSet(), new[] { SerialStatus.InStock })); + Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code); + + // ...a write-off or purchase return (InStock or Damaged) takes it. + var serials = await tracking.ResolveOutboundAsync( + item, Wh1, 1, new[] { 5 }, new HashSet(), new[] { SerialStatus.InStock, SerialStatus.Damaged }); + Assert.Single(serials); + } + + [Fact] + public async Task Inbound_SerialAlreadyInStock_IsADuplicate() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + var ex = await Assert.ThrowsAsync(() => tracking.ResolveInboundAsync( + item, Wh1, 1, new[] { "SN-1" }, new HashSet())); + + Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code); + } + + [Fact] + public async Task Inbound_FractionalQty_IsRejected_ForSerialItems() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + var ex = await Assert.ThrowsAsync(() => tracking.ResolveInboundAsync( + item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet())); + + Assert.Equal(ErrorCodes.Validation, ex.Code); + } + + [Fact] + public async Task Inbound_NewAndSoldSerials_AreCreatedOrBroughtBack() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + // SN-4 is Sold (out of stock) — it comes back; SN-NEW does not exist — it is created. + var serials = await tracking.ResolveInboundAsync(item, Wh1, 2, new[] { "SN-4", "SN-NEW" }, new HashSet()); + await tracking.MoveAsync(serials, SerialStatus.InStock, Wh1, null, "ADJ", 1, DateTime.UtcNow); + await db.SaveChangesAsync(); + + var all = await db.Serials.Where(s => s.ItemId == SerialItemId && (s.SerialNo == "SN-4" || s.SerialNo == "SN-NEW")).ToListAsync(); + Assert.All(all, s => Assert.Equal(SerialStatus.InStock, s.Status)); + Assert.All(all, s => Assert.Equal(Wh1, s.WarehouseId)); + Assert.Equal(2, await db.SerialMovements.CountAsync(m => m.SourceDocType == "ADJ")); + } + + [Fact] + public async Task Move_RecordsHistory_WithPreviousWarehouse() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var serial = await db.Serials.FirstAsync(s => s.SerialId == 1); + + await tracking.MoveAsync(new[] { serial }, SerialStatus.InTransit, null, null, "TRF", 7, DateTime.UtcNow); + await db.SaveChangesAsync(); + + var movement = await db.SerialMovements.SingleAsync(m => m.SerialId == 1); + Assert.Equal(SerialStatus.InTransit, movement.Status); + Assert.Equal(Wh1, movement.FromWarehouseId); + Assert.Null(movement.WarehouseId); + Assert.Null(serial.WarehouseId); + } + + // --- auto-generated serial numbers ------------------------------------------------------- + + [Fact] + public async Task NextNumbers_ContinueAfterHighestUsed_AndSkipDraftGrnNumbers() + { + await using var db = await SeedAsync(); + db.Serials.Add(new Serial { SerialId = 50, ItemId = SerialItemId, SerialNo = "PH-0041", Status = SerialStatus.Sold }); + // A draft GRN line already holds PH-0042 (not yet a Serial). + db.GrnLines.Add(new GrnLine { GrnLineId = 900, GrnId = 1, ItemId = SerialItemId, Qty = 1, + Serials = { new GrnLineSerial { SerialNo = "PH-0042" } } }); + await db.SaveChangesAsync(); + var tracking = new ItemTrackingService(db, null!); + + var next = await tracking.NextSerialNumbersAsync(SerialItemId, "PH-", 3); + + Assert.Equal(new[] { "PH-0043", "PH-0044", "PH-0045" }, next); + } + + [Fact] + public async Task NextNumbers_StartAtOne_ForANewPrefix_AndIgnoreOtherItems() + { + await using var db = await SeedAsync(); + db.Serials.Add(new Serial { SerialId = 51, ItemId = PlainItemId, SerialNo = "NEW-0099", Status = SerialStatus.InStock }); + await db.SaveChangesAsync(); + var tracking = new ItemTrackingService(db, null!); + + var next = await tracking.NextSerialNumbersAsync(SerialItemId, "NEW-", 2); + + Assert.Equal(new[] { "NEW-0001", "NEW-0002" }, next); + } + + [Fact] + public async Task NextNumbers_RejectsTooMany() + { + await using var db = await SeedAsync(); + var tracking = new ItemTrackingService(db, null!); + + var ex = await Assert.ThrowsAsync(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000)); + + Assert.Equal(ErrorCodes.Validation, ex.Code); + } + + // --- fixtures --------------------------------------------------------------------------- + + private static async Task SeedAsync() + { + var db = CreateContext(); + db.Items.AddRange( + new Item { ItemId = SerialItemId, Sku = "SER-1", Name = "Phone", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Serial }, + new Item { ItemId = BatchItemId, Sku = "BAT-1", Name = "Milk", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Batch }, + new Item { ItemId = PlainItemId, Sku = "PLN-1", Name = "Bolt", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None }); + db.Batches.AddRange( + new Batch { BatchId = 100, ItemId = BatchItemId, BatchNo = "B-OK", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30) }, + new Batch { BatchId = 101, ItemId = BatchItemId, BatchNo = "B-EXP", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1) }); + db.Serials.AddRange( + new Serial { SerialId = 1, ItemId = SerialItemId, SerialNo = "SN-1", Status = SerialStatus.InStock, WarehouseId = Wh1 }, + new Serial { SerialId = 2, ItemId = SerialItemId, SerialNo = "SN-2", Status = SerialStatus.InStock, WarehouseId = Wh1 }, + new Serial { SerialId = 3, ItemId = SerialItemId, SerialNo = "SN-3", Status = SerialStatus.InStock, WarehouseId = Wh2 }, + new Serial { SerialId = 4, ItemId = SerialItemId, SerialNo = "SN-4", Status = SerialStatus.Sold, WarehouseId = null }, + new Serial { SerialId = 5, ItemId = SerialItemId, SerialNo = "SN-5", Status = SerialStatus.Damaged, WarehouseId = Wh1 }); + await db.SaveChangesAsync(); + return db; + } + + private static ErpDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root) + .Options; + var currentUser = new TestCurrentUser(); + return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser)); + } + + // Pricing is never touched by the tracking check, so it is not wired. + private static SalesDomainService CreateSalesDomain(ErpDbContext db) => new( + new Repository(db), new Repository(db), new Repository(db), + new Repository(db), new Repository(db), null!, new Repository(db), + new Repository(db), new Repository(db), new Repository(db), + CreateSerialTracking(db)); + + private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new( + new Repository(db), new Repository(db), new Repository(db), + new Repository(db), new TestCurrentUser()); + + private static async Task SwitchSerialNumbersAsync(ErpDbContext db, bool enabled) + { + db.ProductConfig.Add(new ProductConfig { ConfigId = ProductConfig.SingletonId, SerialNumbersEnabled = enabled }); + await db.SaveChangesAsync(); + } + + // --- Product Configuration "Serial numbers" switch --------------------------------------- + + [Fact] + public async Task SwitchOff_SerialItemSells_WithoutSerials() + { + await using var db = await SeedAsync(); + await SwitchSerialNumbersAsync(db, enabled: false); + var sales = CreateSalesDomain(db); + + // No pre-tracking stock exists, yet nothing is asked: the item is plain stock now. + var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 5, null, null, new HashSet()); + + Assert.Empty(serials); + } + + [Fact] + public async Task SwitchOff_SerialsSent_AreRefused() + { + await using var db = await SeedAsync(); + await SwitchSerialNumbersAsync(db, enabled: false); + var sales = CreateSalesDomain(db); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + var sale = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet())); + var adjustment = await Assert.ThrowsAsync(() => + tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet())); + + Assert.Equal(ErrorCodes.ConfigDisabled, sale.Code); + Assert.Equal(ErrorCodes.ConfigDisabled, adjustment.Code); + } + + [Fact] + public async Task SwitchOn_IsTheDefault_WhenConfigRowIsMissing() + { + await using var db = await SeedAsync(); + Assert.True(await CreateSerialTracking(db).IsEnabledAsync()); + } +} diff --git a/Backend/ERPCore/Controllers/SerialsController.cs b/Backend/ERPCore/Controllers/SerialsController.cs new file mode 100644 index 0000000..ba90c36 --- /dev/null +++ b/Backend/ERPCore/Controllers/SerialsController.cs @@ -0,0 +1,41 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9). +[Route("api/v1/serials")] +[Authorize(Policy = NavPolicies.Prefix + "stock")] +public sealed class SerialsController : ApiControllerBase +{ + private readonly IItemTrackingService _tracking; + + public SerialsController(IItemTrackingService tracking) => _tracking = tracking; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status, + [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct)); + + /// Exact lookup by serial number or barcode, across items. + [HttpGet("lookup")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> Lookup([FromQuery] string serialNo, CancellationToken ct) + => Ok(await _tracking.FindSerialAsync(serialNo, ct)); + + [HttpGet("{serialId:int}")] + [ProducesResponseType(typeof(SerialDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int serialId, CancellationToken ct) + { + var dto = await _tracking.GetSerialAsync(serialId, ct); + return dto is null ? NotFound() : Ok(dto); + } +} diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs index 7e4436a..01e5363 100644 --- a/Backend/ERPCore/Controllers/StockController.cs +++ b/Backend/ERPCore/Controllers/StockController.cs @@ -15,13 +15,26 @@ public sealed class StockController : ApiControllerBase { private readonly IStockService _stock; private readonly IReorderService _reorder; + private readonly IItemTrackingService _tracking; - public StockController(IStockService stock, IReorderService reorder) + public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking) { _stock = stock; _reorder = reorder; + _tracking = tracking; } + /// + /// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry + /// first. expiringWithinDays keeps batches expiring within that window, expired included. + /// + [HttpGet("batches")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> Batches( + [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? expiringWithinDays, + [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _tracking.ListBatchStockAsync(query, itemId, warehouseId, expiringWithinDays, ct)); + [HttpGet("on-hand")] [ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)] public async Task> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) diff --git a/Backend/ERPCore/Controllers/TrackingController.cs b/Backend/ERPCore/Controllers/TrackingController.cs new file mode 100644 index 0000000..5c2a14f --- /dev/null +++ b/Backend/ERPCore/Controllers/TrackingController.cs @@ -0,0 +1,64 @@ +using ERPCore.Dtos.Stock; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Serial/batch pickers used across GRN, sales, returns and stock screens +/// (docs/ITEM_TRACKING_PLAN.md Phase 8). Read-only lookups like the item scan, so they need +/// only ERP access — not one module's nav permission — as every one of those screens calls them. +/// +[Route("api/v1/tracking")] +public sealed class TrackingController : ApiControllerBase +{ + private readonly IItemTrackingService _tracking; + private readonly ISerialTrackingService _serialTracking; + + public TrackingController(IItemTrackingService tracking, ISerialTrackingService serialTracking) + { + _tracking = tracking; + _serialTracking = serialTracking; + } + + /// + /// Which tracking features are switched on (Product Configuration) — readable by every + /// screen, unlike /product-config which needs the Products permission. + /// + [HttpGet("settings")] + [ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)] + public async Task> Settings(CancellationToken ct) + => Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct))); + + /// + /// Auto-generate: the next count free serial numbers for an item under prefix + /// (continues the highest number already used). Nothing is reserved — the GRN still checks + /// uniqueness when it is saved and confirmed. + /// + [HttpGet("serials/next-numbers")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> NextSerialNumbers( + [FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default) + => Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct)); + + /// Issuable serials of an item at a warehouse (search by number/barcode with q). + [HttpGet("serials/available")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> AvailableSerials( + [FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] string? q, [FromQuery] int take = 200, CancellationToken ct = default) + => Ok(await _tracking.GetAvailableSerialsAsync(itemId, warehouseId, q, take, ct)); + + /// Issuable stock per batch with expiry, soonest expiry first. + [HttpGet("batches/available")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> AvailableBatches( + [FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) + => Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct)); + + /// The FEFO pick posting would make for qty when no batch is chosen. + [HttpGet("batches/suggest")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> SuggestBatches( + [FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct) + => Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct)); +} diff --git a/Backend/ERPCore/Controllers/WarrantiesController.cs b/Backend/ERPCore/Controllers/WarrantiesController.cs new file mode 100644 index 0000000..0a9cb25 --- /dev/null +++ b/Backend/ERPCore/Controllers/WarrantiesController.cs @@ -0,0 +1,30 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by +/// posting a sale, never directly — this controller only reads. +/// +[Route("api/v1/warranties")] +[Authorize(Policy = NavPolicies.Prefix + "sales")] +public sealed class WarrantiesController : ApiControllerBase +{ + private readonly IItemTrackingService _tracking; + + public WarrantiesController(IItemTrackingService tracking) => _tracking = tracking; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] int? customerId, [FromQuery] int? itemId, [FromQuery] string? serialNo, + [FromQuery] WarrantyStatus? status, [FromQuery] int? expiringWithinDays, [FromQuery] bool? expired, + [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _tracking.ListWarrantiesAsync(query, customerId, itemId, serialNo, status, expiringWithinDays, expired, ct)); +} diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs index 74ed6d1..f451205 100644 --- a/Backend/ERPCore/Domain/Entities/GrnLine.cs +++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs @@ -65,4 +65,12 @@ public class GrnLine /// Empty for a non-warranty item. /// public ICollection WarrantyNumbers { get; set; } = new List(); + + /// + /// One row per received unit when is + /// — count must equal . For a serial item + /// under warranty the serial is the warranty identity, so it carries no + /// . Empty otherwise. + /// + public ICollection Serials { get; set; } = new List(); } diff --git a/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs b/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs new file mode 100644 index 0000000..e1bfca6 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs @@ -0,0 +1,22 @@ +namespace ERPCore.Domain.Entities; + +/// +/// A serial number captured on a Draft GRN line for a serial-tracked item +/// ( = ). The line must +/// carry exactly of these. The draft holds the numbers only — the +/// rows (and their qty-1 FIFO layers) are created when the GRN is +/// confirmed, so a draft that is never confirmed reserves nothing (docs/ITEM_TRACKING_PLAN.md Phase 2). +/// +public class GrnLineSerial +{ + public int GrnLineSerialId { get; set; } + + public int GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public string SerialNo { get; set; } = string.Empty; + + /// Set on confirm — the created for this number. + public int? SerialId { get; set; } + public Serial? Serial { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs index d8ff592..27aad21 100644 --- a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs +++ b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs @@ -19,6 +19,13 @@ public class SalesInvoiceLine public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } + /// Pins the line to one batch; null lets posting pick batches FEFO. + public int? BatchId { get; set; } + public Batch? Batch { get; set; } + + /// Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty). + public ICollection Serials { get; set; } = new List(); + public decimal UnitPrice { get; set; } public decimal BaseCost { get; set; } public string PriceSource { get; set; } = string.Empty; diff --git a/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs b/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs new file mode 100644 index 0000000..d545915 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs @@ -0,0 +1,35 @@ +namespace ERPCore.Domain.Entities; + +/// +/// What a posted sales line actually issued: one row per consumed FIFO segment, carrying the +/// batch (FEFO-picked when the line named none) and, for serial items, the serial. The source +/// line is referenced polymorphically ( is a +/// value) — same convention as — +/// so invoices, slips and bundles share one table. Returns read it to restock into the same +/// batch/serial (docs/ITEM_TRACKING_PLAN.md Phases 4 and 6). +/// +public class SalesLineAllocation +{ + public int SalesLineAllocationId { get; set; } + + public string SourceDocType { get; set; } = string.Empty; + public int SourceDocId { get; set; } + public int SourceLineId { get; set; } + + public int ItemId { get; set; } + public int WarehouseId { get; set; } + + public int? BatchId { get; set; } + public Batch? Batch { get; set; } + + public int? SerialId { get; set; } + public Serial? Serial { get; set; } + + public decimal Qty { get; set; } + public decimal UnitCost { get; set; } + + /// Quantity of this allocation already taken back by sales returns. + public decimal QtyReturned { get; set; } + + public DateTime CreatedAt { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs b/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs new file mode 100644 index 0000000..691019d --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs @@ -0,0 +1,29 @@ +namespace ERPCore.Domain.Entities; + +/// +/// A serial chosen on a Draft sales-invoice line for a serial-tracked item — one row per unit +/// (count = Qty + FreeQty). Choosing does not reserve: posting re-checks each serial is still +/// in stock and consumes exactly its layer (docs/ITEM_TRACKING_PLAN.md Phase 4, D4). +/// +public class SalesInvoiceLineSerial +{ + public int SalesInvoiceLineSerialId { get; set; } + + public int SalesInvoiceLineId { get; set; } + public SalesInvoiceLine? SalesInvoiceLine { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } +} + +/// Sales-slip counterpart of . +public class SalesSlipLineSerial +{ + public int SalesSlipLineSerialId { get; set; } + + public int SalesSlipLineId { get; set; } + public SalesSlipLine? SalesSlipLine { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs b/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs index 191adc0..d59214d 100644 --- a/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs +++ b/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs @@ -2,7 +2,9 @@ namespace ERPCore.Domain.Entities; /// /// Sales-return line referencing the original sales invoice line for traceability. -/// is in base UOM. +/// is in base UOM. is the batch the goods are restocked +/// into — the batch they were sold from (docs/ITEM_TRACKING_PLAN.md Phase 6); +/// lists the returned units of a serial-tracked item. /// public class SalesReturnLine { @@ -18,4 +20,21 @@ public class SalesReturnLine public Item? Item { get; set; } public decimal Qty { get; set; } + + public int? BatchId { get; set; } + public Batch? Batch { get; set; } + + public ICollection Serials { get; set; } = new List(); +} + +/// One serial taken back on a . +public class SalesReturnLineSerial +{ + public int SalesReturnLineSerialId { get; set; } + + public int ReturnLineId { get; set; } + public SalesReturnLine? ReturnLine { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs index 5497afe..3570250 100644 --- a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs +++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs @@ -22,9 +22,16 @@ public class SalesSlipLine public int? BatchId { get; set; } public Batch? Batch { get; set; } + /// + /// Legacy single-serial pin, from before a line could carry several. Kept for old drafts and + /// still set when exactly one serial is chosen; is authoritative. + /// public int? SerialId { get; set; } public Serial? Serial { get; set; } + /// Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty). + public ICollection Serials { get; set; } = new List(); + public decimal UnitPrice { get; set; } public decimal BaseCost { get; set; } public string PriceSource { get; set; } = string.Empty; diff --git a/Backend/ERPCore/Domain/Entities/Serial.cs b/Backend/ERPCore/Domain/Entities/Serial.cs index dbbf7a2..2276703 100644 --- a/Backend/ERPCore/Domain/Entities/Serial.cs +++ b/Backend/ERPCore/Domain/Entities/Serial.cs @@ -1,8 +1,13 @@ +using ERPCore.Domain.Enums; + namespace ERPCore.Domain.Entities; /// /// Serial number for a serial-tracked item across its lifecycle (FR-WH-04). -/// Model: docs/10 Part C.4. +/// Created when the GRN that received it is confirmed; each in-stock serial owns exactly one +/// FIFO layer of qty 1 (), so issuing a serial consumes that +/// layer. / are its current location and are +/// cleared once it leaves stock. Model: docs/10 Part C.4, docs/ITEM_TRACKING_PLAN.md Phase 1. /// public class Serial { @@ -12,8 +17,33 @@ public class Serial public Item? Item { get; set; } public string SerialNo { get; set; } = string.Empty; - public string Status { get; set; } = "InStock"; + public SerialStatus Status { get; set; } = SerialStatus.InStock; /// Optional scan code for barcode/RFID lookup (unique when set). public string? Barcode { get; set; } + + /// Current warehouse while in stock; null once sold/returned to vendor/scrapped. + public int? WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public int? BinId { get; set; } + public Bin? Bin { get; set; } + + /// The GRN line that received this unit (provenance); null for count/adjustment finds. + public int? GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public DateTime? ReceivedAt { get; set; } + + /// + /// Warranty coverage snapshotted from at receipt + /// (same rule as ); null for a + /// non-warranty item. The clock does not start here — it starts at sale + /// (). + /// + public int? WarrantyPeriodMonths { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + public uint RowVersion { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/SerialMovement.cs b/Backend/ERPCore/Domain/Entities/SerialMovement.cs new file mode 100644 index 0000000..a872c00 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SerialMovement.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Append-only history of one serial: a row each time a document changes its status or +/// location (receipt, sale, return, transfer, adjustment, count). and +/// are the serial's state after the movement. The source +/// document is referenced polymorphically, as on . This is where serial +/// history is read from — the stock ledger keeps one row per document line, not per unit +/// (docs/ITEM_TRACKING_PLAN.md Phases 2 and 9). +/// +public class SerialMovement +{ + public int SerialMovementId { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } + + public string SourceDocType { get; set; } = string.Empty; + public int SourceDocId { get; set; } + + public SerialStatus Status { get; set; } + public int? FromWarehouseId { get; set; } + public int? WarehouseId { get; set; } + + public int UserId { get; set; } + public DateTime CreatedAt { get; set; } + + /// + /// Records 's current state — call after updating it. Linked by + /// navigation, so it works for a serial inserted in the same save. + /// + public static SerialMovement Of(Serial serial, int? fromWarehouseId, string sourceDocType, int sourceDocId, int userId, DateTime at) => new() + { + Serial = serial, + SerialId = serial.SerialId, + SourceDocType = sourceDocType, + SourceDocId = sourceDocId, + Status = serial.Status, + FromWarehouseId = fromWarehouseId, + WarehouseId = serial.WarehouseId, + UserId = userId, + CreatedAt = at + }; +} diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs index 8bbc1ad..bcbcc84 100644 --- a/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs +++ b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs @@ -18,6 +18,7 @@ public class StockAdjustmentLine public int? BinId { get; set; } public int? BatchId { get; set; } public int? SerialId { get; set; } + public Serial? Serial { get; set; } public decimal QtyDelta { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/StockTransferLine.cs b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs index 240d815..c4266a3 100644 --- a/Backend/ERPCore/Domain/Entities/StockTransferLine.cs +++ b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs @@ -32,4 +32,27 @@ public class StockTransferLine /// Quantity already received at the destination (partial-receive support). public decimal QtyReceived { get; set; } + + /// The units moved, for a serial-tracked item (one per unit of ). + public ICollection Serials { get; set; } = new List(); +} + +/// +/// One serial on a transfer line. / are captured from +/// its own source layer at dispatch, so the destination layer is recreated exactly; +/// is set when it arrives (partial receive names which serials). +/// +public class StockTransferLineSerial +{ + public int StockTransferLineSerialId { get; set; } + + public int TransferLineId { get; set; } + public StockTransferLine? TransferLine { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } + + public int? BatchId { get; set; } + public decimal? UnitCost { get; set; } + public DateTime? ReceivedAt { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs b/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs new file mode 100644 index 0000000..e5c2c3c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs @@ -0,0 +1,42 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// A customer's warranty, started when the sale that issued the goods is posted +/// (docs/ITEM_TRACKING_PLAN.md Phase 5, D3/D7) — stock waiting in a warehouse never consumes +/// warranty. For a serial item there is one row per serial ( set, +/// = 1); for a warranty item that is not serial-tracked there is one row per +/// sales line covering its . is the coverage +/// snapshotted at receipt (), else the item's period. +/// +public class WarrantyRegistration +{ + public int WarrantyRegistrationId { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public int? SerialId { get; set; } + public Serial? Serial { get; set; } + + public int CustomerId { get; set; } + public Customer? Customer { get; set; } + + public string SourceDocType { get; set; } = string.Empty; + public int SourceDocId { get; set; } + public int SourceLineId { get; set; } + + public decimal Qty { get; set; } + + public DateOnly StartDate { get; set; } + public int PeriodMonths { get; set; } + public DateOnly EndDate { get; set; } + + public WarrantyStatus Status { get; set; } = WarrantyStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/SerialStatus.cs b/Backend/ERPCore/Domain/Enums/SerialStatus.cs new file mode 100644 index 0000000..32c43db --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SerialStatus.cs @@ -0,0 +1,25 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Lifecycle state of one serial-tracked unit (FR-WH-04). Stored as a string — the values +/// InStock/Sold written before this enum existed stay valid as-is. +/// Only is issuable. +/// +public enum SerialStatus +{ + InStock, + Reserved, + /// Dispatched on a stock transfer, not yet received — in no warehouse. + InTransit, + Sold, + ReturnedByCustomer, + ReturnedToVendor, + Damaged, + Scrapped, + /// + /// Left stock without being named, while serial numbers were switched off in Product + /// Configuration — the unit was issued oldest-first like untracked stock, so which + /// document took it is not recorded against the serial. + /// + IssuedUntracked +} diff --git a/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs b/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs new file mode 100644 index 0000000..ab53b54 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs @@ -0,0 +1,10 @@ +namespace ERPCore.Domain.Enums; + +/// State of a . Stored as a string. +public enum WarrantyStatus +{ + Active, + /// The unit came back on a sales return — the warranty no longer applies. + Voided, + Claimed +} diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index 84b47cd..9fad1dc 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -7,12 +7,16 @@ namespace ERPCore.Dtos.Grn; public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths); +/// A serial captured on a GRN line; is set once the GRN is confirmed. +public sealed record GrnLineSerialDto(string SerialNo, int? SerialId); + public sealed record GrnLineDto( int GrnLineId, int? PoLineId, int ItemId, int? BinId, decimal Qty, decimal UnitCost, decimal? PoUnitPrice, decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, - HoldStatus HoldStatus, int? BatchId, IReadOnlyList WarrantyNumbers); + HoldStatus HoldStatus, int? BatchId, IReadOnlyList WarrantyNumbers, + string? BatchNo, DateOnly? BatchExpiryDate, IReadOnlyList Serials); public sealed record GrnDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, @@ -70,9 +74,15 @@ public sealed class CreateGrnLineInput public BatchInput? Batch { get; set; } /// /// Required, one per unit (count must equal ), when the item is - /// warranty-tracked (Item.Warranty == Warranty.Warranty). Ignored otherwise. + /// warranty-tracked (Item.Warranty == Warranty.Warranty) and not serial-tracked — + /// a serial item's serial is its warranty identity. Ignored otherwise. /// public List? WarrantyNumbers { get; set; } + /// + /// Required, one per unit (count must equal , so Qty must be whole), when the + /// item is serial-tracked (Item.TrackingMode == Serial). Rejected for any other item. + /// + public List? SerialNumbers { get; set; } } public sealed class CreateGrnRequest diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs index d6fc74a..b8591c8 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs @@ -25,6 +25,12 @@ public sealed class CreatePurchaseReturnLineInput public int? GrnLineId { get; set; } [Required] public int ItemId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// + /// Serials sent back, for a serial-tracked item — one per unit, each at the return warehouse + /// (InStock or Damaged; for a rejected GRN line, received on that line). They become + /// ReturnedToVendor. Rejected for any other item. + /// + public List? SerialIds { get; set; } } public sealed class CreatePurchaseReturnRequest diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index 302b04f..52a51b2 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -7,7 +7,11 @@ public sealed record SalesInvoiceLineDto( int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, - decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); + decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId, + int? BatchId, IReadOnlyList Serials); + +/// A serial chosen on (or sold by) a sales line. +public sealed record SalesLineSerialDto(int SerialId, string SerialNo); public sealed record SalesInvoiceTotalsDto( decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, @@ -95,6 +99,13 @@ public sealed class CreateSalesInvoiceLineRequest [Range(0, 100)] public decimal? TaxPct { get; set; } public bool IsFreeIssue { get; set; } public int? ParentLineId { get; set; } + /// Pins the line to one batch (must belong to the item, not expired). Omit to let posting pick FEFO. + public int? BatchId { get; set; } + /// + /// Serials sold on this line — required for a serial-tracked item, one per unit of + /// Qty + FreeQty, each InStock at the line warehouse. Rejected for any other item. + /// + public List? SerialIds { get; set; } } public sealed class CreateSalesInvoiceRequest diff --git a/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs index b619d4f..3908393 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs @@ -5,7 +5,17 @@ namespace ERPCore.Dtos.Sales; // Responses ----------------------------------------------------------------- -public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty); +public sealed record SalesReturnLineDto( + int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty, + int? BatchId, IReadOnlyList Serials); + +/// +/// What one posted sales invoice line issued, net of returns — the batches/serials a return +/// against it can take back. +/// +public sealed record SalesLineAllocationDto( + int? BatchId, string? BatchNo, DateOnly? ExpiryDate, int? SerialId, string? SerialNo, + decimal Qty, decimal QtyReturned); public sealed record SalesReturnDto( int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, @@ -22,7 +32,8 @@ public sealed record SalesReturnSummaryDto( /// whatever has already been returned against it. The invoice line's own Qty /// is never mutated by a return, so this is computed on read from return history. /// -public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty); +public sealed record SalesInvoiceLineRemainingDto( + int SalesInvoiceLineId, decimal RemainingQty, IReadOnlyList Allocations); // Requests -------------------------------------------------------------------- @@ -32,6 +43,16 @@ public sealed class CreateSalesReturnLineInput public int? SalesInvoiceLineId { get; set; } [Required] public int ItemId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// + /// Batch to restock into. Optional when the invoice line issued a single batch (it is used); + /// required for a batch-tracked item otherwise, and must be one the line issued. + /// + public int? BatchId { get; set; } + /// + /// Returned serials of a serial-tracked item — one per unit, each Sold (and, with an invoice + /// line, sold on that line). Rejected for any other item. + /// + public List? SerialIds { get; set; } } public sealed class CreateSalesReturnRequest @@ -40,5 +61,10 @@ public sealed class CreateSalesReturnRequest [Required] public int WarehouseId { get; set; } /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error. public int? ReasonCodeId { get; set; } + /// + /// Returned serials become Damaged (not resaleable) instead of InStock. Omit to + /// derive it from the reason: DEF (Defective) and QREJ (Quality Reject) mean damaged. + /// + public bool? MarkDamaged { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); } diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs index a65bd4e..9c80883 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs @@ -8,7 +8,7 @@ public sealed record SalesSlipLineDto( int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId, - int? BatchId, int? SerialId); + int? BatchId, int? SerialId, IReadOnlyList Serials); public sealed record SalesSlipTotalsDto( decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, @@ -62,8 +62,13 @@ public sealed class CreateSalesSlipLineRequest public int? ParentLineId { get; set; } /// Pins the line to a specific batch (must belong to ), consumed via FIFO. public int? BatchId { get; set; } - /// Pins the line to a specific serial (must belong to ); marked Sold on posting. + /// Legacy single-serial pin — treated as SerialIds = [SerialId] when is empty. public int? SerialId { get; set; } + /// + /// Serials sold on this line — required for a serial-tracked item, one per unit of + /// Qty + FreeQty, each InStock at the line warehouse. Marked Sold on posting. + /// + public List? SerialIds { get; set; } } public sealed class CreateSalesSlipRequest diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs index 0163c66..1efd1a1 100644 --- a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs @@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Stock; // Responses (docs/11 §5.5) ------------------------------------------------------ -public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta); +public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta, int? SerialId); public sealed record AdjustmentDto( int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status, @@ -26,6 +26,16 @@ public sealed class CreateAdjustmentLineInput public int? BatchId { get; set; } /// Signed base-UOM delta: negative consumes FIFO layers, positive adds stock. public decimal QtyDelta { get; set; } + /// + /// Negative delta on a serial-tracked item: the serials written off (InStock or Damaged at + /// the warehouse, one per unit) — they become Scrapped. + /// + public List? SerialIds { get; set; } + /// + /// Positive delta on a serial-tracked item: the serial numbers found (one per unit) — new + /// numbers are created, a known serial that is out of stock is brought back InStock. + /// + public List? SerialNos { get; set; } } public sealed class CreateAdjustmentRequest diff --git a/Backend/ERPCore/Dtos/Stock/TrackingDtos.cs b/Backend/ERPCore/Dtos/Stock/TrackingDtos.cs new file mode 100644 index 0000000..c39ee57 --- /dev/null +++ b/Backend/ERPCore/Dtos/Stock/TrackingDtos.cs @@ -0,0 +1,40 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Stock; + +// Item tracking lookups and registers (docs/ITEM_TRACKING_PLAN.md Phases 5, 8, 9). + +/// Product Configuration tracking switches, as every screen needs them. +public sealed record TrackingSettingsDto(bool SerialNumbersEnabled); + +/// An issuable serial, for sale/transfer/return pickers. +public sealed record AvailableSerialDto(int SerialId, string SerialNo, int? BatchId, string? BatchNo, SerialStatus Status); + +/// Issuable stock of one batch at a warehouse (BatchId null = unbatched stock). +public sealed record AvailableBatchDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry); + +/// A FEFO pick: of . +public sealed record BatchSuggestionDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty); + +public sealed record SerialSummaryDto( + int SerialId, int ItemId, string Sku, string ItemName, string SerialNo, SerialStatus Status, + int? WarehouseId, string? WarehouseName, int? BinId, DateTime? ReceivedAt, + int? GrnId, string? GrnDocNo, int? WarrantyPeriodMonths); + +public sealed record SerialMovementDto( + int SerialMovementId, string SourceDocType, int SourceDocId, SerialStatus Status, + int? FromWarehouseId, int? WarehouseId, int UserId, DateTime CreatedAt); + +public sealed record SerialDetailDto( + SerialSummaryDto Serial, IReadOnlyList History, IReadOnlyList Warranties); + +public sealed record WarrantyRegistrationDto( + int WarrantyRegistrationId, int ItemId, string Sku, string ItemName, int? SerialId, string? SerialNo, + int CustomerId, string CustomerName, string SourceDocType, int SourceDocId, int SourceLineId, + decimal Qty, DateOnly StartDate, int PeriodMonths, DateOnly EndDate, WarrantyStatus Status, + int DaysRemaining, bool IsExpired); + +/// Stock on hand per batch (with expiry), for the batch / expiry report. +public sealed record BatchStockDto( + int ItemId, string Sku, string ItemName, int WarehouseId, string WarehouseName, + int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry, bool IsExpired); diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs index 08cf57f..ad6c02e 100644 --- a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs @@ -6,7 +6,11 @@ namespace ERPCore.Dtos.Stock; // Responses (docs/11 §5.4) ------------------------------------------------------ public sealed record TransferLineDto( - int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived); + int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived, + IReadOnlyList Serials); + +/// A serial on a transfer line; once it has arrived. +public sealed record TransferLineSerialDto(int SerialId, string SerialNo, bool Received); public sealed record TransferDto( int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId, @@ -34,8 +38,11 @@ public sealed class CreateTransferLineInput [Required] public int ItemId { get; set; } public int? SrcBinId { get; set; } public int? DestBinId { get; set; } + /// Batch to move. Omitted for a batch-tracked item, the line is split FEFO across batches. public int? BatchId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// Serials to move — required for a serial-tracked item, one per unit, each InStock at the source. + public List? SerialIds { get; set; } } public sealed class CreateTransferRequest @@ -52,6 +59,11 @@ public sealed class ReceiveTransferLineInput { [Required] public int TransferLineId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// + /// For a serial line: which in-transit serials arrived (count = Qty). May be omitted when + /// receiving everything still outstanding on the line. + /// + public List? SerialIds { get; set; } } public sealed class ReceiveTransferRequest diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs index db9ea87..6ea7b5b 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs @@ -34,7 +34,9 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration builder.HasKey(s => s.SerialId); builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100); - builder.Property(s => s.Status).IsRequired().HasMaxLength(20); + // String-stored enum: rows written as "InStock"/"Sold" before SerialStatus existed + // read back unchanged. + builder.Property(s => s.Status).HasConversion().IsRequired().HasMaxLength(20); builder.Property(s => s.Barcode).HasMaxLength(100); builder.HasIndex(s => s.Barcode).IsUnique(); @@ -43,7 +45,12 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration .WithMany() .HasForeignKey(s => s.ItemId) .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(s => s.Warehouse).WithMany().HasForeignKey(s => s.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(s => s.Bin).WithMany().HasForeignKey(s => s.BinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(s => s.GrnLine).WithMany().HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Restrict); builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique(); + builder.HasIndex(s => new { s.ItemId, s.Status }); + builder.HasIndex(s => new { s.WarehouseId, s.ItemId, s.Status }); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs index ea1c232..fc71fe8 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -74,3 +74,23 @@ public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguratio builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique(); } } + +public sealed class GrnLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("grn_line_serials"); + builder.HasKey(s => s.GrnLineSerialId); + + builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100); + + builder.HasOne(s => s.GrnLine).WithMany(l => l.Serials) + .HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(s => s.Serial).WithMany() + .HasForeignKey(s => s.SerialId).OnDelete(DeleteBehavior.Restrict); + + // Global uniqueness lives on serials (ItemId, SerialNo); this only stops a number + // being entered twice on one line. + builder.HasIndex(s => new { s.GrnLineId, s.SerialNo }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTrackingConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTrackingConfiguration.cs new file mode 100644 index 0000000..d60ea1e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTrackingConfiguration.cs @@ -0,0 +1,118 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +// Sales-side serial/batch tracking and warranty (docs/ITEM_TRACKING_PLAN.md Phases 4–5). + +public sealed class SalesInvoiceLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_invoice_line_serials"); + builder.HasKey(x => x.SalesInvoiceLineSerialId); + + builder.HasOne(x => x.SalesInvoiceLine).WithMany(l => l.Serials) + .HasForeignKey(x => x.SalesInvoiceLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(x => x.Serial).WithMany() + .HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => new { x.SalesInvoiceLineId, x.SerialId }).IsUnique(); + } +} + +public sealed class SalesSlipLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_slip_line_serials"); + builder.HasKey(x => x.SalesSlipLineSerialId); + + builder.HasOne(x => x.SalesSlipLine).WithMany(l => l.Serials) + .HasForeignKey(x => x.SalesSlipLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(x => x.Serial).WithMany() + .HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => new { x.SalesSlipLineId, x.SerialId }).IsUnique(); + } +} + +public sealed class SalesLineAllocationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_line_allocations"); + builder.HasKey(x => x.SalesLineAllocationId); + + builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50); + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.QtyReturned).HasPrecision(18, 4); + builder.Property(x => x.UnitCost).HasPrecision(18, 6); + + builder.HasOne(x => x.Batch).WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId, x.SourceLineId }); + builder.HasIndex(x => x.SerialId); + builder.HasIndex(x => x.BatchId); + } +} + +public sealed class StockTransferLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_transfer_line_serials"); + builder.HasKey(x => x.StockTransferLineSerialId); + + builder.Property(x => x.UnitCost).HasPrecision(18, 6); + + builder.HasOne(x => x.TransferLine).WithMany(l => l.Serials) + .HasForeignKey(x => x.TransferLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(x => x.Serial).WithMany() + .HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => new { x.TransferLineId, x.SerialId }).IsUnique(); + } +} + +public sealed class SerialMovementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("serial_movements"); + builder.HasKey(x => x.SerialMovementId); + + builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50); + builder.Property(x => x.Status).HasConversion().IsRequired().HasMaxLength(20); + + builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => new { x.SerialId, x.SerialMovementId }); + builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId }); + } +} + +public sealed class WarrantyRegistrationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("warranty_registrations"); + builder.HasKey(x => x.WarrantyRegistrationId); + + builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50); + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.Status).HasConversion().IsRequired().HasMaxLength(20); + + builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => x.SerialId); + builder.HasIndex(x => x.CustomerId); + builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId }); + builder.HasIndex(x => new { x.Status, x.EndDate }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs index f759bdc..4c71e15 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs @@ -111,6 +111,11 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration x.WarehouseId) .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Batch) + .WithMany() + .HasForeignKey(x => x.BatchId) + .OnDelete(DeleteBehavior.Restrict); + builder.Property(x => x.PriceSource).HasMaxLength(50); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs index f6d0c34..b569eec 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs @@ -37,5 +37,20 @@ public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade); builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class SalesReturnLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_return_line_serials"); + builder.HasKey(s => s.SalesReturnLineSerialId); + + builder.HasOne(s => s.ReturnLine).WithMany(l => l.Serials).HasForeignKey(s => s.ReturnLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(s => s.Serial).WithMany().HasForeignKey(s => s.SerialId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(s => new { s.ReturnLineId, s.SerialId }).IsUnique(); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs index 549b0af..2299db2 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs @@ -40,6 +40,6 @@ public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration< 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); + builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); } } diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index fef8e6e..95105d4 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -93,6 +93,14 @@ public class ErpDbContext : DbContext // --- Batch / Serial (docs/10 Part C.4) --- public DbSet Batches => Set(); public DbSet Serials => Set(); + public DbSet GrnLineSerials => Set(); + public DbSet SalesInvoiceLineSerials => Set(); + public DbSet SalesSlipLineSerials => Set(); + public DbSet SalesLineAllocations => Set(); + public DbSet WarrantyRegistrations => Set(); + public DbSet SalesReturnLineSerials => Set(); + public DbSet SerialMovements => Set(); + public DbSet StockTransferLineSerials => Set(); // --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) --- public DbSet StockLayers => Set(); diff --git a/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.Designer.cs b/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.Designer.cs new file mode 100644 index 0000000..6b767ef --- /dev/null +++ b/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.Designer.cs @@ -0,0 +1,9481 @@ +// +using System; +using ERPCore.Infra.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ERPCore.Migrations +{ + [DbContext(typeof(ErpDbContext))] + [Migration("20261007054513_AddItemTrackingSerialsWarranty")] + partial class AddItemTrackingSerialsWarranty + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .UseCollation("utf8mb4_unicode_ci") + .HasAnnotation("ProductVersion", "9.0.20") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => + { + b.Property("AttendanceRecordId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AttendanceRecordId")); + + b.Property("AttendanceDate") + .HasColumnType("datetime(6)"); + + b.Property("AttendanceStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("AttendanceUploadBatchId") + .HasColumnType("int"); + + b.Property("CheckIn") + .HasColumnType("time(6)"); + + b.Property("CheckOut") + .HasColumnType("time(6)"); + + b.Property("DuplicateOfAttendanceRecordId") + .HasColumnType("int"); + + b.Property("EarlyLeaveMinutes") + .HasColumnType("int"); + + b.Property("EditedAt") + .HasColumnType("datetime(6)"); + + b.Property("EditedBy") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsManualOverride") + .HasColumnType("tinyint(1)"); + + b.Property("LateMinutes") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("OvertimeMinutes") + .HasColumnType("int"); + + b.Property("RowValidationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("WorkShiftId") + .HasColumnType("int"); + + b.Property("WorkingMinutes") + .HasColumnType("int"); + + b.HasKey("AttendanceRecordId"); + + b.HasIndex("AttendanceUploadBatchId"); + + b.HasIndex("WorkShiftId"); + + b.HasIndex("EmployeeId", "AttendanceDate"); + + b.ToTable("hr_attendance_records", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b => + { + b.Property("AttendanceUploadBatchId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AttendanceUploadBatchId")); + + b.Property("ConfirmedAt") + .HasColumnType("datetime(6)"); + + b.Property("ConfirmedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("PeriodEnd") + .HasColumnType("datetime(6)"); + + b.Property("PeriodStart") + .HasColumnType("datetime(6)"); + + b.Property("RowCountDuplicate") + .HasColumnType("int"); + + b.Property("RowCountError") + .HasColumnType("int"); + + b.Property("RowCountTotal") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UploadedBy") + .HasColumnType("int"); + + b.HasKey("AttendanceUploadBatchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("PeriodStart", "PeriodEnd"); + + b.ToTable("hr_attendance_upload_batches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AuditId")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("ChangeSet") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EntityId") + .HasColumnType("int"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("UserId") + .HasColumnType("int"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BatchId")); + + b.Property("Barcode") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("BatchNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ExpiryDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.HasKey("BatchId"); + + b.HasIndex("Barcode") + .IsUnique(); + + b.HasIndex("ItemId", "BatchNo") + .IsUnique(); + + b.ToTable("batches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.Property("BinId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BinId")); + + b.Property("BinType") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("BinId"); + + b.HasIndex("WarehouseId", "Code") + .IsUnique(); + + b.ToTable("bins", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Branch", b => + { + b.Property("BranchId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BranchId")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_branches", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("BranchingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("WarehouseBranchingEnabled") + .HasColumnType("tinyint(1)"); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("branch_config", null, t => + { + t.HasCheckConstraint("ck_branch_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BrandId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("BrandId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("brands", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => + { + b.Property("BundleSaleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleId")); + + b.Property("BundleCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BundleDate") + .HasColumnType("datetime(6)"); + + b.Property("BundleName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("BundleNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BundlePrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BundleSaleTemplateId") + .HasColumnType("int"); + + b.Property("CashierUserId") + .HasColumnType("int"); + + b.Property("ComponentSubtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("CustomerSnapshotName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("DayEndId") + .HasColumnType("int"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("MarginAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Draft"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("BundleSaleId"); + + b.HasIndex("BundleNo") + .IsUnique(); + + b.HasIndex("BundleSaleTemplateId"); + + b.HasIndex("CashierUserId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DayEndId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("bundle_sales", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b => + { + b.Property("BundleSaleLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleLineId")); + + b.Property("BundleSaleId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IncludeInBundle") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("IsComponent") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ParentLineId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("BundleSaleLineId"); + + b.HasIndex("BundleSaleId"); + + b.HasIndex("ItemId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("bundle_sale_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b => + { + b.Property("BundleSaleTemplateId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleTemplateId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("TemplateName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("BundleSaleTemplateId"); + + b.HasIndex("TemplateCode") + .IsUnique(); + + b.ToTable("bundle_sale_templates", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b => + { + b.Property("BundleSaleTemplateLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleTemplateLineId")); + + b.Property("BundleSaleTemplateId") + .HasColumnType("int"); + + b.Property("IncludeInBundle") + .HasColumnType("tinyint(1)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("BundleSaleTemplateLineId"); + + b.HasIndex("BundleSaleTemplateId"); + + b.HasIndex("ItemId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("bundle_sale_template_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CategoryId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("CategoryId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b => + { + b.Property("CustomerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CustomerId")); + + b.Property("AddressLine1") + .HasMaxLength(250) + .HasColumnType("varchar(250)"); + + b.Property("AddressLine2") + .HasMaxLength(250) + .HasColumnType("varchar(250)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreditDays") + .HasColumnType("int"); + + b.Property("CreditLimit") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CustomerCode") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CustomerType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("B2C"); + + b.Property("DefaultWarehouseId") + .HasColumnType("int"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("TaxRegistrationNo") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("CustomerId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CustomerCode") + .IsUnique(); + + b.HasIndex("CustomerType"); + + b.HasIndex("DefaultWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("customers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => + { + b.Property("DepartmentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("DepartmentId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("HeadEmployeeId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ParentDepartmentId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("DepartmentId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("HeadEmployeeId"); + + b.HasIndex("ParentDepartmentId"); + + b.HasIndex("Status"); + + b.ToTable("hr_departments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Designation", b => + { + b.Property("DesignationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("DesignationId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("DesignationId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_designations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => + { + b.Property("EmployeeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeId")); + + b.Property("AddressLine1") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AddressLine2") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ConfirmationDate") + .HasColumnType("datetime(6)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DateOfBirth") + .HasColumnType("datetime(6)"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("DesignationId") + .HasColumnType("int"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("varchar(320)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EmergencyContactRelationship") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EmployeeCode") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EmploymentTypeId") + .HasColumnType("int"); + + b.Property("EpfNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EtfNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Gender") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("HireDate") + .HasColumnType("datetime(6)"); + + b.Property("LastWorkingDate") + .HasColumnType("datetime(6)"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nic") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PersonalMobile") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ProfilePhotoPath") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReportingManagerId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("TaxIdentificationNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WorkShiftId") + .HasColumnType("int"); + + b.HasKey("EmployeeId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("DesignationId"); + + b.HasIndex("Email"); + + b.HasIndex("EmployeeCode") + .IsUnique(); + + b.HasIndex("EmploymentTypeId"); + + b.HasIndex("ReportingManagerId"); + + b.HasIndex("Status"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("WorkShiftId"); + + b.ToTable("hr_employees", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.Property("EmployeeBankDetailId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeBankDetailId")); + + b.Property("AccountHolderName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BankName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("BranchName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("SwiftCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("EmployeeBankDetailId"); + + b.HasIndex("EmployeeId"); + + b.ToTable("hr_employee_bank_details", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.Property("EmployeeDocumentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeDocumentId")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("HrDocumentTypeId") + .HasColumnType("int"); + + b.Property("IssueDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UploadedBy") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime(6)"); + + b.Property("VerifiedBy") + .HasColumnType("int"); + + b.HasKey("EmployeeDocumentId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("ExpiryDate"); + + b.HasIndex("HrDocumentTypeId"); + + b.ToTable("hr_employee_documents", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Property("EmployeeLoanId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeLoanId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("InstallmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("InterestRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("LoanKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("NumberOfInstallments") + .HasColumnType("int"); + + b.Property("OutstandingBalance") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StartMonth") + .HasColumnType("int"); + + b.Property("StartYear") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("EmployeeLoanId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("Status"); + + b.ToTable("hr_employee_loans", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Property("EmployeeSalaryStructureId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeSalaryStructureId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("BasicSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("varchar(3)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("EmployeeSalaryStructureId"); + + b.HasIndex("EmployeeId", "EffectiveTo"); + + b.ToTable("hr_employee_salary_structures", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.Property("EmployeeSalaryStructureLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeSalaryStructureLineId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmployeeSalaryStructureId") + .HasColumnType("int"); + + b.Property("SalaryComponentId") + .HasColumnType("int"); + + b.HasKey("EmployeeSalaryStructureLineId"); + + b.HasIndex("EmployeeSalaryStructureId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_employee_salary_structure_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmploymentType", b => + { + b.Property("EmploymentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmploymentTypeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("EmploymentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_employment_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("PoId") + .HasColumnType("int"); + + b.Property("PostedAt") + .HasColumnType("datetime(6)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("GrnId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("PurchaseInvoiceId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("AllowDiscounts") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowVat") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowWarehouseSelection") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("DefaultWarehouseId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("ConfigId"); + + b.HasIndex("DefaultWarehouseId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("grn_config", null, t => + { + t.HasCheckConstraint("ck_grn_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("GrnId") + .HasColumnType("int"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PoLineId") + .HasColumnType("int"); + + b.Property("PoUnitPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("VatAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VatPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b => + { + b.Property("GrnLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineSerialId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("GrnLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("GrnLineId", "SerialNo") + .IsUnique(); + + b.ToTable("grn_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => + { + b.Property("GrnLineWarrantyNumberId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineWarrantyNumberId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("WarrantyNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + + b.HasKey("GrnLineWarrantyNumberId"); + + b.HasIndex("GrnLineId", "WarrantyNo") + .IsUnique(); + + b.ToTable("grn_line_warranty_numbers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b => + { + b.Property("HrDocumentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HrDocumentTypeId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpiryTracked") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequiredAtOnboarding") + .HasColumnType("tinyint(1)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("HrDocumentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_document_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemId")); + + b.Property("Barcode") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("BaseUomId") + .HasColumnType("int"); + + b.Property("BrandId") + .HasColumnType("int"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContentBaseQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ContentBaseUnit") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ContentQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ContentUnit") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DefaultVendorId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SellingFlatPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SellingMaxPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SellingMinPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SubCategoryId") + .HasColumnType("int"); + + b.Property("TaxRateId") + .HasColumnType("int"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Warranty") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("NonWarranty"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + + b.HasKey("ItemId"); + + b.HasIndex("Barcode") + .IsUnique(); + + b.HasIndex("BaseUomId"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("SubCategoryId"); + + b.HasIndex("TaxRateId"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b => + { + b.Property("ItemImageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemImageId")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UploadedBy") + .HasColumnType("int"); + + b.HasKey("ItemImageId"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("item_images", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsMeasurable") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("ItemTypeId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("item_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.Property("LeaveBalanceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveBalanceId")); + + b.Property("AdjustmentDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("CarriedForwardDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EntitledDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("LeaveTypeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("TakenDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("LeaveBalanceId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("EmployeeId", "LeaveTypeId", "Year") + .IsUnique(); + + b.ToTable("hr_leave_balances", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.Property("LeaveRequestId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveRequestId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DaysCount") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("LeaveTypeId") + .HasColumnType("int"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("LeaveRequestId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("Status"); + + b.HasIndex("StartDate", "EndDate"); + + b.ToTable("hr_leave_requests", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveType", b => + { + b.Property("LeaveTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveTypeId")); + + b.Property("AccrualPerYear") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("CarryForwardAllowed") + .HasColumnType("tinyint(1)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CountsAsNoPay") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsPaid") + .HasColumnType("tinyint(1)"); + + b.Property("MaxCarryForwardDays") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequiresApproval") + .HasColumnType("tinyint(1)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("LeaveTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_leave_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.Property("LoanInstallmentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LoanInstallmentId")); + + b.Property("DueMonth") + .HasColumnType("int"); + + b.Property("DueYear") + .HasColumnType("int"); + + b.Property("EmployeeLoanId") + .HasColumnType("int"); + + b.Property("InstallmentNumber") + .HasColumnType("int"); + + b.Property("PaidAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("ScheduledAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("LoanInstallmentId"); + + b.HasIndex("EmployeeLoanId"); + + b.HasIndex("PayrollRunId"); + + b.HasIndex("DueYear", "DueMonth"); + + b.ToTable("hr_loan_installments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b => + { + b.Property("LoginActivityId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LoginActivityId")); + + b.Property("DeviceLabel") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("LoginAt") + .HasColumnType("datetime(6)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginActivityId"); + + b.HasIndex("UserId", "LoginAt"); + + b.ToTable("login_activities", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Property("NavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.HasKey("NavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("nav_items", (string)null); + + b.HasData( + new + { + NavItemId = 1, + Code = "dashboard", + Href = "/dashboard", + Label = "Dashboard", + SortOrder = 1, + Status = "Active" + }, + new + { + NavItemId = 2, + Code = "products", + Href = "/dashboard/products", + Label = "Products", + SortOrder = 2, + Status = "Active" + }, + new + { + NavItemId = 4, + Code = "procurement", + Href = "/dashboard/procurement", + Label = "Procurement", + SortOrder = 4, + Status = "Active" + }, + new + { + NavItemId = 5, + Code = "receiving", + Href = "/dashboard/receiving/grn", + Label = "Receiving", + SortOrder = 5, + Status = "Active" + }, + new + { + NavItemId = 6, + Code = "stock", + Href = "/dashboard/stock", + Label = "Stock", + SortOrder = 6, + Status = "Active" + }, + new + { + NavItemId = 7, + Code = "warehouses", + Href = "/dashboard/warehouse", + Label = "Warehouses", + SortOrder = 7, + Status = "Active" + }, + new + { + NavItemId = 8, + Code = "orders", + Href = "/dashboard/orders", + Label = "Orders", + SortOrder = 8, + Status = "Active" + }, + new + { + NavItemId = 9, + Code = "settings", + Href = "/dashboard/settings", + Label = "Settings", + SortOrder = 9, + Status = "Active" + }, + new + { + NavItemId = 10, + Code = "help", + Href = "/dashboard/help", + Label = "Help", + SortOrder = 10, + Status = "Active" + }, + new + { + NavItemId = 11, + Code = "ledgers", + Href = "/dashboard/ledgers", + Label = "Ledgers", + SortOrder = 11, + Status = "Active" + }, + new + { + NavItemId = 12, + Code = "accounts", + Href = "/dashboard/accounts", + Label = "Accounts", + SortOrder = 12, + Status = "Active" + }, + new + { + NavItemId = 13, + Code = "sales", + Href = "/dashboard/sales", + Label = "Sales", + SortOrder = 13, + Status = "Active" + }, + new + { + NavItemId = 14, + Code = "production", + Href = "/dashboard/production", + Label = "Production", + SortOrder = 14, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Notification", b => + { + b.Property("NotificationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ModuleKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RelatedEntityId") + .HasColumnType("int"); + + b.Property("RelatedEntityType") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("NotificationId"); + + b.HasIndex("CreatedAt"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.Property("NotificationConfigId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("NotificationConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("notification_config", null, t => + { + t.HasCheckConstraint("ck_notification_config_singleton", "`NotificationConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b => + { + b.Property("NotificationConfigModuleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationConfigModuleId")); + + b.Property("EmailEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("InAppEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ModuleKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NotificationConfigId") + .HasColumnType("int"); + + b.Property("SmsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.HasKey("NotificationConfigModuleId"); + + b.HasIndex("ModuleKey") + .IsUnique(); + + b.HasIndex("NotificationConfigId"); + + b.ToTable("notification_config_modules", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationRecipient", b => + { + b.Property("NotificationRecipientId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationRecipientId")); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("NotificationId") + .HasColumnType("int"); + + b.Property("ReadAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("NotificationRecipientId"); + + b.HasIndex("NotificationId"); + + b.HasIndex("UserId", "IsRead", "NotificationId"); + + b.ToTable("notification_recipients", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SequenceId")); + + b.Property("BranchId") + .HasColumnType("int") + .HasColumnName("branch_id"); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("int") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("int") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocType", "BranchId", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Property("PayrollLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollLineId")); + + b.Property("AbsentDays") + .HasColumnType("int"); + + b.Property("BasicSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EpfEmployeeAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EpfEmployerAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EtfEmployerAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("GrossSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("LateDeductionAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("LateMinutesTotal") + .HasColumnType("int"); + + b.Property("LeaveDays") + .HasColumnType("int"); + + b.Property("LoanDeductionAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("NetSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("NoPayAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("OtMinutesTotal") + .HasColumnType("int"); + + b.Property("OtherDeductionsAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("OvertimeAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("int"); + + b.Property("PresentDays") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TotalAllowances") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("WorkingDays") + .HasColumnType("int"); + + b.HasKey("PayrollLineId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("PayrollRunId", "EmployeeId") + .IsUnique(); + + b.ToTable("hr_payroll_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.Property("PayrollLineComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollLineComponentId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ComponentCategory") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PayrollLineId") + .HasColumnType("int"); + + b.Property("SalaryComponentId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("PayrollLineComponentId"); + + b.HasIndex("PayrollLineId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_payroll_line_components", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Property("PayrollRunId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollRunId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GeneratedAt") + .HasColumnType("datetime(6)"); + + b.Property("GeneratedBy") + .HasColumnType("int"); + + b.Property("LockedAt") + .HasColumnType("datetime(6)"); + + b.Property("LockedBy") + .HasColumnType("int"); + + b.Property("PeriodMonth") + .HasColumnType("int"); + + b.Property("PeriodYear") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UnlockReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("UnlockedAt") + .HasColumnType("datetime(6)"); + + b.Property("UnlockedBy") + .HasColumnType("int"); + + b.HasKey("PayrollRunId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("PeriodYear", "PeriodMonth", "BranchId"); + + b.ToTable("hr_payroll_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollStatutorySetting", b => + { + b.Property("PayrollStatutorySettingId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollStatutorySettingId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("EpfEmployeeRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("EpfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("EtfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("OtMultiplierDefault") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.HasKey("PayrollStatutorySettingId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_payroll_statutory_settings", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.Property("PayslipId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayslipId")); + + b.Property("GeneratedAt") + .HasColumnType("datetime(6)"); + + b.Property("PayrollLineId") + .HasColumnType("int"); + + b.Property("ReleasedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReleasedBy") + .HasColumnType("int"); + + b.HasKey("PayslipId"); + + b.HasIndex("PayrollLineId") + .IsUnique(); + + b.ToTable("hr_payslips", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.Property("PermissionId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PermissionId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("NavItemId") + .HasColumnType("int"); + + b.Property("SubNavItemId") + .HasColumnType("int"); + + b.HasKey("PermissionId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.HasIndex("SubNavItemId"); + + b.ToTable("permissions", (string)null); + + b.HasData( + new + { + PermissionId = 1, + Code = "NAV:dashboard", + NavItemId = 1 + }, + new + { + PermissionId = 2, + Code = "NAV:products", + NavItemId = 2 + }, + new + { + PermissionId = 3, + Code = "NAV:procurement.vendors", + SubNavItemId = 30 + }, + new + { + PermissionId = 4, + Code = "NAV:procurement", + NavItemId = 4 + }, + new + { + PermissionId = 5, + Code = "NAV:receiving", + NavItemId = 5 + }, + new + { + PermissionId = 6, + Code = "NAV:stock", + NavItemId = 6 + }, + new + { + PermissionId = 7, + Code = "NAV:warehouses", + NavItemId = 7 + }, + new + { + PermissionId = 8, + Code = "NAV:orders", + NavItemId = 8 + }, + new + { + PermissionId = 9, + Code = "NAV:settings", + NavItemId = 9 + }, + new + { + PermissionId = 10, + Code = "NAV:help", + NavItemId = 10 + }, + new + { + PermissionId = 11, + Code = "NAV:products.item", + SubNavItemId = 1 + }, + new + { + PermissionId = 12, + Code = "NAV:products.category", + SubNavItemId = 2 + }, + new + { + PermissionId = 13, + Code = "NAV:products.brand", + SubNavItemId = 3 + }, + new + { + PermissionId = 14, + Code = "NAV:products.item-type", + SubNavItemId = 4 + }, + new + { + PermissionId = 15, + Code = "NAV:products.uom", + SubNavItemId = 5 + }, + new + { + PermissionId = 16, + Code = "NAV:products.configuration", + SubNavItemId = 6 + }, + new + { + PermissionId = 17, + Code = "NAV:settings.roles", + SubNavItemId = 7 + }, + new + { + PermissionId = 18, + Code = "NAV:settings.users", + SubNavItemId = 8 + }, + new + { + PermissionId = 28, + Code = "NAV:procurement.requisitions", + SubNavItemId = 17 + }, + new + { + PermissionId = 29, + Code = "NAV:procurement.rfqs", + SubNavItemId = 18 + }, + new + { + PermissionId = 30, + Code = "NAV:procurement.purchase-orders", + SubNavItemId = 19 + }, + new + { + PermissionId = 31, + Code = "NAV:procurement.purchase-returns", + SubNavItemId = 20 + }, + new + { + PermissionId = 19, + Code = "NAV:ledgers", + NavItemId = 11 + }, + new + { + PermissionId = 20, + Code = "NAV:ledgers.trial-balance", + SubNavItemId = 9 + }, + new + { + PermissionId = 21, + Code = "NAV:ledgers.balance-sheet", + SubNavItemId = 10 + }, + new + { + PermissionId = 22, + Code = "NAV:ledgers.general-ledger", + SubNavItemId = 11 + }, + new + { + PermissionId = 23, + Code = "NAV:ledgers.profit-and-loss", + SubNavItemId = 12 + }, + new + { + PermissionId = 24, + Code = "NAV:ledgers.cash-flow", + SubNavItemId = 13 + }, + new + { + PermissionId = 25, + Code = "NAV:ledgers.budget-vs-actual", + SubNavItemId = 14 + }, + new + { + PermissionId = 27, + Code = "NAV:ledgers.tax-report", + SubNavItemId = 16 + }, + new + { + PermissionId = 26, + Code = "NAV:accounts.bank-accounts", + SubNavItemId = 15 + }, + new + { + PermissionId = 32, + Code = "NAV:accounts", + NavItemId = 12 + }, + new + { + PermissionId = 33, + Code = "NAV:accounts.cheque-books", + SubNavItemId = 21 + }, + new + { + PermissionId = 34, + Code = "NAV:accounts.received-cheques", + SubNavItemId = 22 + }, + new + { + PermissionId = 35, + Code = "NAV:production", + NavItemId = 14 + }, + new + { + PermissionId = 36, + Code = "NAV:production.templates", + SubNavItemId = 27 + }, + new + { + PermissionId = 37, + Code = "NAV:production.runs", + SubNavItemId = 25 + }, + new + { + PermissionId = 38, + Code = "NAV:production.produced", + SubNavItemId = 26 + }, + new + { + PermissionId = 39, + Code = "NAV:procurement.purchase-invoices", + SubNavItemId = 28 + }, + new + { + PermissionId = 40, + Code = "NAV:settings.branches", + SubNavItemId = 29 + }, + new + { + PermissionId = 41, + Code = "NAV:stock.ledger", + SubNavItemId = 31 + }, + new + { + PermissionId = 42, + Code = "NAV:stock.enquiry", + SubNavItemId = 32 + }, + new + { + PermissionId = 43, + Code = "NAV:stock.valuation", + SubNavItemId = 33 + }, + new + { + PermissionId = 44, + Code = "NAV:stock.transfers", + SubNavItemId = 34 + }, + new + { + PermissionId = 45, + Code = "NAV:stock.adjustments", + SubNavItemId = 35 + }, + new + { + PermissionId = 46, + Code = "NAV:stock.counts", + SubNavItemId = 36 + }, + new + { + PermissionId = 47, + Code = "NAV:stock.reorder-alerts", + SubNavItemId = 37 + }, + new + { + PermissionId = 48, + Code = "NAV:stock.wastage", + SubNavItemId = 38 + }, + new + { + PermissionId = 49, + Code = "NAV:sales.bundle-templates", + SubNavItemId = 23 + }, + new + { + PermissionId = 50, + Code = "NAV:sales.invoices", + SubNavItemId = 40 + }, + new + { + PermissionId = 51, + Code = "NAV:sales.free-issues", + SubNavItemId = 41 + }, + new + { + PermissionId = 52, + Code = "NAV:sales.day-end", + SubNavItemId = 42 + }, + new + { + PermissionId = 53, + Code = "NAV:products.tax-rates", + SubNavItemId = 43 + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PoId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("BatchListsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CustomVariantsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("CustomWarrantyPeriodsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ItemImagesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SellingPricesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("SerialNumbersEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("WarrantyPeriodsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("product_config", null, t => + { + t.HasCheckConstraint("ck_product_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.Property("RunId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunId")); + + b.Property("CancelReasonCodeId") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OutputBinId") + .HasColumnType("int"); + + b.Property("ReworkCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("ScaleFactor") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TargetQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("RunId"); + + b.HasIndex("CancelReasonCodeId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("OutputBinId"); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("TemplateId", "Status"); + + b.ToTable("production_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.Property("TemplateId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TemplateId")); + + b.Property("Annotations") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("TemplateId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.ToTable("production_templates", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.Property("PurchaseInvoiceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoiceId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Draft"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VatTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorInvoiceDate") + .HasColumnType("datetime(6)"); + + b.Property("VendorInvoiceNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("VendorSnapshotName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("VendorSnapshotTaxNo") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("PurchaseInvoiceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("VendorId", "VendorInvoiceNo"); + + b.ToTable("purchase_invoices", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b => + { + b.Property("PurchaseInvoiceLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoiceLineId")); + + b.Property("GrnId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VatTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("PurchaseInvoiceLineId"); + + b.HasIndex("GrnId") + .IsUnique(); + + b.HasIndex("PurchaseInvoiceId"); + + b.ToTable("purchase_invoice_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b => + { + b.Property("PurchaseInvoicePaymentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoicePaymentId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BankAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("GlBankAccountId") + .HasColumnType("bigint"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PaymentDate") + .HasColumnType("datetime(6)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("PurchaseInvoicePaymentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("PurchaseInvoiceId"); + + b.ToTable("purchase_invoice_payments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("tinyint(1)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("ApproverUserId") + .HasColumnType("int"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("PoId"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("BranchId"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnId") + .HasColumnType("int"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RequisitionId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequestedBy") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("BranchId"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RfqId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RfqId") + .HasColumnType("int"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => + { + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RoleId")); + + b.Property("AuthRoleId") + .HasColumnType("char(36)") + .HasColumnName("auth_role_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsSystemRole") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("RoleId"); + + b.HasIndex("AuthRoleId") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("PermissionId") + .HasColumnType("int"); + + b.Property("Actions") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(31); + + b.HasKey("RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => + { + b.Property("RunEdgeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunEdgeId")); + + b.Property("ChildRunStageId") + .HasColumnType("int"); + + b.Property("ParentRunStageId") + .HasColumnType("int"); + + b.Property("RunId") + .HasColumnType("int"); + + b.HasKey("RunEdgeId"); + + b.HasIndex("ChildRunStageId"); + + b.HasIndex("RunId"); + + b.HasIndex("ParentRunStageId", "ChildRunStageId") + .IsUnique(); + + b.ToTable("run_edges", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.Property("RunStageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunStageId")); + + b.Property("ActualEndAt") + .HasColumnType("datetime(6)"); + + b.Property("ActualStartAt") + .HasColumnType("datetime(6)"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("FieldDefs") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FieldValues") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PosX") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PosY") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoleLabel") + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("RunId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TemplateStageId") + .HasColumnType("int"); + + b.HasKey("RunStageId"); + + b.HasIndex("TemplateStageId"); + + b.HasIndex("RunId", "Status"); + + b.ToTable("run_stages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EventId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Payload") + .HasColumnType("longtext"); + + b.Property("RunId") + .HasColumnType("int"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("EventId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("UserId"); + + b.HasIndex("RunId", "EventId"); + + b.ToTable("run_stage_events", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => + { + b.Property("RunInputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunInputId")); + + b.Property("ConsumedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ConsumedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DeliveredQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("FromRunOutputId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PlannedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ReturnedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RunInputId"); + + b.HasIndex("FromRunOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RunStageId"); + + b.ToTable("run_stage_inputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => + { + b.Property("RunOutputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunOutputId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PlannedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ProducedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("ScrapReasonCodeId") + .HasColumnType("int"); + + b.Property("ScrappedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TransferredQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UomId") + .HasColumnType("int"); + + b.HasKey("RunOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("ScrapReasonCodeId"); + + b.HasIndex("UomId"); + + b.ToTable("run_stage_outputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalaryComponent", b => + { + b.Property("SalaryComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalaryComponentId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEpfEtfApplicable") + .HasColumnType("tinyint(1)"); + + b.Property("IsTaxable") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("SalaryComponentId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_salary_components", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("AllowCashCustomer") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("AllowDiscounts") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowFreeItems") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowOverStockSale") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("AllowWarehouseSelection") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CashCustomerId") + .HasColumnType("int"); + + b.Property("DefaultWarehouseId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("ConfigId"); + + b.HasIndex("CashCustomerId"); + + b.HasIndex("DefaultWarehouseId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("sales_config", null, t => + { + t.HasCheckConstraint("ck_sales_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.Property("SalesDayEndId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesDayEndId")); + + b.Property("BalanceTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BundleCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("BusinessDate") + .HasColumnType("date"); + + b.Property("CashierUserId") + .HasColumnType("int"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("ClosedBy") + .HasColumnType("int"); + + b.Property("CostOfGoodsSold") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SlipCount") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("SalesDayEndId"); + + b.HasIndex("ClosedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("CashierUserId", "BusinessDate") + .IsUnique(); + + b.ToTable("sales_day_ends", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.Property("SalesInvoiceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("CreatorUserId") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("CustomerSnapshotName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CustomerSnapshotTaxNo") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("InvoiceDate") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("B2C"); + + b.Property("NetPayable") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoundOff") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Draft"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatorUserId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("InvoiceDate"); + + b.HasIndex("InvoiceNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_invoices", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.Property("SalesInvoiceLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceLineId")); + + b.Property("BaseCost") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("DiscountAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountMode") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("FreeQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IsFreeIssue") + .HasColumnType("tinyint(1)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ParentLineId") + .HasColumnType("int"); + + b.Property("PriceSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SalesInvoiceId") + .HasColumnType("int"); + + b.Property("TaxAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SalesInvoiceId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_invoice_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b => + { + b.Property("SalesInvoiceLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceLineSerialId")); + + b.Property("SalesInvoiceLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SalesInvoiceLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_invoice_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => + { + b.Property("SalesInvoicePaymentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoicePaymentId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BankAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("GlBankAccountId") + .HasColumnType("bigint"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PaymentDate") + .HasColumnType("datetime(6)"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SalesInvoiceId") + .HasColumnType("int"); + + b.HasKey("SalesInvoicePaymentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("SalesInvoiceId"); + + b.ToTable("sales_invoice_payments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b => + { + b.Property("SalesLineAllocationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesLineAllocationId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReturned") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SourceLineId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesLineAllocationId"); + + b.HasIndex("BatchId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SourceDocType", "SourceDocId", "SourceLineId"); + + b.ToTable("sales_line_allocations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnId") + .HasColumnType("int"); + + b.Property("SalesInvoiceLineId") + .HasColumnType("int"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.HasIndex("SalesInvoiceLineId"); + + b.ToTable("sales_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b => + { + b.Property("SalesReturnLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesReturnLineSerialId")); + + b.Property("ReturnLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesReturnLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("ReturnLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_return_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.Property("SalesSlipId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CashierUserId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("CustomerSnapshotName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("DayEndId") + .HasColumnType("int"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SlipDate") + .HasColumnType("datetime(6)"); + + b.Property("SlipNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Draft"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesSlipId"); + + b.HasIndex("CashierUserId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DayEndId"); + + b.HasIndex("SlipDate"); + + b.HasIndex("SlipNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_slips", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.Property("SalesSlipLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipLineId")); + + b.Property("BaseCost") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("DiscountAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountMode") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("FreeQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IsFreeIssue") + .HasColumnType("tinyint(1)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ParentLineId") + .HasColumnType("int"); + + b.Property("PriceSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SalesSlipId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("TaxAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesSlipLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SalesSlipId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_slip_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b => + { + b.Property("SalesSlipLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipLineSerialId")); + + b.Property("SalesSlipLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesSlipLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SalesSlipLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_slip_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SerialId")); + + b.Property("Barcode") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + + b.HasKey("SerialId"); + + b.HasIndex("Barcode") + .IsUnique(); + + b.HasIndex("BinId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.HasIndex("ItemId", "Status"); + + b.HasIndex("WarehouseId", "ItemId", "Status"); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b => + { + b.Property("SerialMovementId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SerialMovementId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FromWarehouseId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SerialMovementId"); + + b.HasIndex("SerialId", "SerialMovementId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("serial_movements", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => + { + b.Property("EdgeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EdgeId")); + + b.Property("ChildStageId") + .HasColumnType("int"); + + b.Property("ParentStageId") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("EdgeId"); + + b.HasIndex("ChildStageId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("ParentStageId", "ChildStageId") + .IsUnique(); + + b.ToTable("stage_edges", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => + { + b.Property("InputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("InputId")); + + b.Property("FromOutputId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyPerBatch") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("StageId") + .HasColumnType("int"); + + b.HasKey("InputId"); + + b.HasIndex("FromOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("StageId"); + + b.ToTable("stage_inputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => + { + b.Property("OutputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("OutputId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("QtyPerBatch") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("StageId") + .HasColumnType("int"); + + b.Property("UomId") + .HasColumnType("int"); + + b.HasKey("OutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("StageId"); + + b.HasIndex("UomId"); + + b.ToTable("stage_outputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AdjustmentId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("BranchId"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("int"); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CountId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("CountId"); + + b.HasIndex("BranchId"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CountLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CountId") + .HasColumnType("int"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime(6)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TransferId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DestWarehouseId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SrcWarehouseId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("BranchId"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("DestBinId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SrcBinId") + .HasColumnType("int"); + + b.Property("TransferId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(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.StockTransferLineSerial", b => + { + b.Property("StockTransferLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StockTransferLineSerialId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("TransferLineId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.HasKey("StockTransferLineSerialId"); + + b.HasIndex("BatchId"); + + b.HasIndex("SerialId"); + + b.HasIndex("TransferLineId", "SerialId") + .IsUnique(); + + b.ToTable("stock_transfer_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.Property("SubNavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SubNavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NavItemId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.HasKey("SubNavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.ToTable("sub_nav_items", (string)null); + + b.HasData( + new + { + SubNavItemId = 1, + Code = "products.item", + Href = "/dashboard/products", + Label = "Item", + NavItemId = 2, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 2, + Code = "products.category", + Href = "/dashboard/products/categories", + Label = "Category", + NavItemId = 2, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 3, + Code = "products.brand", + Href = "/dashboard/products/brands", + Label = "Brand", + NavItemId = 2, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 4, + Code = "products.item-type", + Href = "/dashboard/products/item-types", + Label = "Item Type", + NavItemId = 2, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 5, + Code = "products.uom", + Href = "/dashboard/products/uoms", + Label = "UOM", + NavItemId = 2, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 6, + Code = "products.configuration", + Href = "/dashboard/products/settings", + Label = "Configuration", + NavItemId = 2, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 43, + Code = "products.tax-rates", + Href = "/dashboard/products/tax-rates", + Label = "Tax Rates", + NavItemId = 2, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 29, + Code = "settings.branches", + Href = "/dashboard/settings/branches", + Label = "Branches", + NavItemId = 9, + SortOrder = 0, + Status = "Active" + }, + new + { + SubNavItemId = 7, + Code = "settings.roles", + Href = "/dashboard/settings/roles", + Label = "Roles", + NavItemId = 9, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 8, + Code = "settings.users", + Href = "/dashboard/settings/users", + Label = "Users", + NavItemId = 9, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 24, + Code = "settings.notification-config", + Href = "/dashboard/settings/notification-config", + Label = "Notification Config", + NavItemId = 9, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 40, + Code = "sales.invoices", + Href = "/dashboard/sales/invoices", + Label = "Sales", + NavItemId = 13, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 23, + Code = "sales.bundle-templates", + Href = "/dashboard/sales/bundles/templates", + Label = "Bundle Templates", + NavItemId = 13, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 41, + Code = "sales.free-issues", + Href = "/dashboard/sales/free-issues", + Label = "Free Issues", + NavItemId = 13, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 42, + Code = "sales.day-end", + Href = "/dashboard/sales/day-end", + Label = "Day End", + NavItemId = 13, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 30, + Code = "procurement.vendors", + Href = "/dashboard/vendors", + Label = "Vendors", + NavItemId = 4, + SortOrder = 0, + Status = "Active" + }, + new + { + SubNavItemId = 17, + Code = "procurement.requisitions", + Href = "/dashboard/procurement/requisitions", + Label = "Requisitions", + NavItemId = 4, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 18, + Code = "procurement.rfqs", + Href = "/dashboard/procurement/rfqs", + Label = "RFQs", + NavItemId = 4, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 19, + Code = "procurement.purchase-orders", + Href = "/dashboard/procurement/purchase-orders", + Label = "Purchase Orders", + NavItemId = 4, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 20, + Code = "procurement.purchase-returns", + Href = "/dashboard/procurement/purchase-returns", + Label = "Purchase Returns", + NavItemId = 4, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 28, + Code = "procurement.purchase-invoices", + Href = "/dashboard/procurement/purchase-invoices", + Label = "Purchase Invoices", + NavItemId = 4, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 9, + Code = "ledgers.trial-balance", + Href = "/dashboard/ledgers/trial-balance", + Label = "Trial Balance", + NavItemId = 11, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 10, + Code = "ledgers.balance-sheet", + Href = "/dashboard/ledgers/balance-sheet", + Label = "Balance Sheet", + NavItemId = 11, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 11, + Code = "ledgers.general-ledger", + Href = "/dashboard/ledgers/general-ledger", + Label = "General Ledger", + NavItemId = 11, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 12, + Code = "ledgers.profit-and-loss", + Href = "/dashboard/ledgers/profit-and-loss", + Label = "Profit & Loss", + NavItemId = 11, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 13, + Code = "ledgers.cash-flow", + Href = "/dashboard/ledgers/cash-flow", + Label = "Cash Flow", + NavItemId = 11, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 14, + Code = "ledgers.budget-vs-actual", + Href = "/dashboard/ledgers/budget-vs-actual", + Label = "Budget vs Actual", + NavItemId = 11, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 16, + Code = "ledgers.tax-report", + Href = "/dashboard/ledgers/tax-report", + Label = "Tax Report", + NavItemId = 11, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 15, + Code = "accounts.bank-accounts", + Href = "/dashboard/accounts/bank-accounts", + Label = "Cash / Bank Accounts", + NavItemId = 12, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 21, + Code = "accounts.cheque-books", + Href = "/dashboard/accounts/cheque-books", + Label = "Cheque Books", + NavItemId = 12, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 22, + Code = "accounts.received-cheques", + Href = "/dashboard/accounts/received-cheques", + Label = "Received Cheques", + NavItemId = 12, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 27, + Code = "production.templates", + Href = "/dashboard/production/templates", + Label = "Templates", + NavItemId = 14, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 25, + Code = "production.runs", + Href = "/dashboard/production/runs", + Label = "Runs", + NavItemId = 14, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 26, + Code = "production.produced", + Href = "/dashboard/production/produced", + Label = "Produced", + NavItemId = 14, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 31, + Code = "stock.ledger", + Href = "/dashboard/stock/ledger", + Label = "Stock Ledger", + NavItemId = 6, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 32, + Code = "stock.enquiry", + Href = "/dashboard/stock/enquiry", + Label = "Stock Enquiry", + NavItemId = 6, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 33, + Code = "stock.valuation", + Href = "/dashboard/stock/valuation", + Label = "Valuation", + NavItemId = 6, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 34, + Code = "stock.transfers", + Href = "/dashboard/stock/transfers", + Label = "Transfers", + NavItemId = 6, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 35, + Code = "stock.adjustments", + Href = "/dashboard/stock/adjustments", + Label = "Adjustments", + NavItemId = 6, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 36, + Code = "stock.counts", + Href = "/dashboard/stock/counts", + Label = "Counts", + NavItemId = 6, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 37, + Code = "stock.reorder-alerts", + Href = "/dashboard/stock/reorder-alerts", + Label = "Reorder Alerts", + NavItemId = 6, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 38, + Code = "stock.wastage", + Href = "/dashboard/stock/wastage", + Label = "Wastage", + NavItemId = 6, + SortOrder = 8, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => + { + b.Property("TicketId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TicketId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("TicketId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.ToTable("support_tickets", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicketMessage", b => + { + b.Property("MessageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("MessageId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsFromAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("SenderUserId") + .HasColumnType("int"); + + b.Property("TicketId") + .HasColumnType("int"); + + b.HasKey("MessageId"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("support_ticket_messages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TaxRate", b => + { + b.Property("TaxRateId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TaxRateId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Percentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("TaxRateId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("tax_rates", (string)null); + + b.HasData( + new + { + TaxRateId = 1, + Code = "STANDARD", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Standard", + Percentage = 15m, + RowVersion = 1u, + Status = "Active" + }, + new + { + TaxRateId = 2, + Code = "ZERO", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Zero-Rated", + Percentage = 0m, + RowVersion = 1u, + Status = "Active" + }, + new + { + TaxRateId = 3, + Code = "EXEMPT", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Exempt", + Percentage = 0m, + RowVersion = 1u, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TaxSlab", b => + { + b.Property("TaxSlabId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TaxSlabId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("LowerBound") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Rate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpperBound") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("TaxSlabId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_tax_slabs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.Property("StageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StageId")); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("FieldDefs") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PosX") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PosY") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoleLabel") + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("StageId"); + + b.HasIndex("TemplateId"); + + b.ToTable("template_stages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UomId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("char(36)") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("varchar(320)"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("VendorId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("varchar(3)") + .HasDefaultValue("LKR"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("TaxReg") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("VendorId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("RfqId") + .HasColumnType("int"); + + b.Property("VendorId") + .HasColumnType("int"); + + 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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LeadDays") + .HasColumnType("int"); + + b.Property("QuotationId") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(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("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarehouseId")); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Phone") + .HasColumnType("longtext"); + + b.HasKey("WarehouseId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b => + { + b.Property("WarrantyPeriodId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarrantyPeriodId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Months") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("WarrantyPeriodId"); + + b.HasIndex("Months") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("warranty_periods", null, t => + { + t.HasCheckConstraint("ck_warranty_periods_months", "`Months` BETWEEN 1 AND 600"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b => + { + b.Property("WarrantyRegistrationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarrantyRegistrationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PeriodMonths") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SourceLineId") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("WarrantyRegistrationId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("Status", "EndDate"); + + b.ToTable("warranty_registrations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b => + { + b.Property("WorkShiftId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WorkShiftId")); + + b.Property("BreakMinutes") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndTime") + .HasColumnType("time(6)"); + + b.Property("GraceMinutes") + .HasColumnType("int"); + + b.Property("IsOvernight") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OtMultiplier") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StandardWorkingMinutes") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("time(6)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WorkingDaysMask") + .HasColumnType("int"); + + b.HasKey("WorkShiftId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_work_shifts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => + { + b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch") + .WithMany() + .HasForeignKey("AttendanceUploadBatchId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceUploadBatch"); + + b.Navigation("Employee"); + + b.Navigation("WorkShift"); + }); + + 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.BranchConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate") + .WithMany() + .HasForeignKey("BundleSaleTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd") + .WithMany("BundleSales") + .HasForeignKey("DayEndId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSaleTemplate"); + + b.Navigation("CashierUser"); + + b.Navigation("Customer"); + + b.Navigation("DayEnd"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale") + .WithMany("Lines") + .HasForeignKey("BundleSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSale"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate") + .WithMany("Lines") + .HasForeignKey("BundleSaleTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSaleTemplate"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("DefaultWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Employee", "HeadEmployee") + .WithMany() + .HasForeignKey("HeadEmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Department", "ParentDepartment") + .WithMany() + .HasForeignKey("ParentDepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + + b.Navigation("HeadEmployee"); + + b.Navigation("ParentDepartment"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Designation", "Designation") + .WithMany() + .HasForeignKey("DesignationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.EmploymentType", "EmploymentType") + .WithMany() + .HasForeignKey("EmploymentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Employee", "ReportingManager") + .WithMany() + .HasForeignKey("ReportingManagerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Employee", "UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Department"); + + b.Navigation("Designation"); + + b.Navigation("EmploymentType"); + + b.Navigation("ReportingManager"); + + b.Navigation("User"); + + b.Navigation("WorkShift"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.HrDocumentType", "HrDocumentType") + .WithMany() + .HasForeignKey("HrDocumentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("HrDocumentType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeSalaryStructure", "EmployeeSalaryStructure") + .WithMany("Lines") + .HasForeignKey("EmployeeSalaryStructureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EmployeeSalaryStructure"); + + b.Navigation("SalaryComponent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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.PurchaseInvoice", "PurchaseInvoice") + .WithMany() + .HasForeignKey("PurchaseInvoiceId") + .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("Branch"); + + b.Navigation("Creator"); + + b.Navigation("PurchaseInvoice"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DefaultWarehouse"); + + b.Navigation("UpdatedByUser"); + }); + + 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.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany("Serials") + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("GrnLine"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany("WarrantyNumbers") + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + }); + + 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.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + + 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.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TaxRate", "TaxRate") + .WithMany() + .HasForeignKey("TaxRateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); + + b.Navigation("TaxRate"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + 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.LeaveBalance", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeLoan", "EmployeeLoan") + .WithMany("Installments") + .HasForeignKey("EmployeeLoanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany() + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EmployeeLoan"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b => + { + b.HasOne("ERPCore.Domain.Entities.NotificationConfig", "NotificationConfig") + .WithMany("Modules") + .HasForeignKey("NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationRecipient", b => + { + b.HasOne("ERPCore.Domain.Entities.Notification", "Notification") + .WithMany("Recipients") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany("Lines") + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithMany("Components") + .HasForeignKey("PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("PayrollLine"); + + b.Navigation("SalaryComponent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Payslip", "PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PayrollLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany() + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem") + .WithMany() + .HasForeignKey("SubNavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("NavItem"); + + b.Navigation("SubNavItem"); + }); + + 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.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "CancelReason") + .WithMany() + .HasForeignKey("CancelReasonCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Bin", "OutputBin") + .WithMany() + .HasForeignKey("OutputBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Runs") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CancelReason"); + + b.Navigation("Creator"); + + b.Navigation("OutputBin"); + + b.Navigation("Template"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany() + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice") + .WithMany("Lines") + .HasForeignKey("PurchaseInvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grn"); + + b.Navigation("PurchaseInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice") + .WithMany("Payments") + .HasForeignKey("PurchaseInvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Approver") + .WithMany() + .HasForeignKey("ApproverUserId"); + + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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("Approver"); + + b.Navigation("Branch"); + + 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.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + 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.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + 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.RolePermission", b => + { + b.HasOne("ERPCore.Domain.Entities.Permission", "Permission") + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => + { + b.HasOne("ERPCore.Domain.Entities.RunStage", "ChildRunStage") + .WithMany() + .HasForeignKey("ChildRunStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "ParentRunStage") + .WithMany() + .HasForeignKey("ParentRunStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Edges") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildRunStage"); + + b.Navigation("ParentRunStage"); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Stages") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "TemplateStage") + .WithMany() + .HasForeignKey("TemplateStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Run"); + + b.Navigation("TemplateStage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Events") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Events") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("RunStage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => + { + b.HasOne("ERPCore.Domain.Entities.RunStageOutput", "FromRunOutput") + .WithMany() + .HasForeignKey("FromRunOutputId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Inputs") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromRunOutput"); + + b.Navigation("Item"); + + b.Navigation("RunStage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Outputs") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ScrapReason") + .WithMany() + .HasForeignKey("ScrapReasonCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Item"); + + b.Navigation("RunStage"); + + b.Navigation("ScrapReason"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer") + .WithMany() + .HasForeignKey("CashCustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CashCustomer"); + + b.Navigation("DefaultWarehouse"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "ClosedByUser") + .WithMany() + .HasForeignKey("ClosedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CashierUser"); + + b.Navigation("ClosedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatorUserId"); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Customer"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice") + .WithMany("Lines") + .HasForeignKey("SalesInvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Item"); + + b.Navigation("SalesInvoice"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine") + .WithMany("Serials") + .HasForeignKey("SalesInvoiceLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SalesInvoiceLine"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice") + .WithMany("Payments") + .HasForeignKey("SalesInvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("SalesInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .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("Customer"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine") + .WithMany() + .HasForeignKey("SalesInvoiceLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Item"); + + b.Navigation("Return"); + + b.Navigation("SalesInvoiceLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesReturnLine", "ReturnLine") + .WithMany("Serials") + .HasForeignKey("ReturnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ReturnLine"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd") + .WithMany("Slips") + .HasForeignKey("DayEndId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CashierUser"); + + b.Navigation("Customer"); + + b.Navigation("DayEnd"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesSlip", "SalesSlip") + .WithMany("Lines") + .HasForeignKey("SalesSlipId") + .OnDelete(DeleteBehavior.Cascade) + .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("Item"); + + b.Navigation("SalesSlip"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesSlipLine", "SalesSlipLine") + .WithMany("Serials") + .HasForeignKey("SalesSlipLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SalesSlipLine"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .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.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Bin"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b => + { + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => + { + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ChildStage") + .WithMany() + .HasForeignKey("ChildStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ParentStage") + .WithMany() + .HasForeignKey("ParentStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Edges") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildStage"); + + b.Navigation("ParentStage"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => + { + b.HasOne("ERPCore.Domain.Entities.StageOutput", "FromOutput") + .WithMany() + .HasForeignKey("FromOutputId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") + .WithMany("Inputs") + .HasForeignKey("StageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromOutput"); + + b.Navigation("Item"); + + b.Navigation("Stage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") + .WithMany("Outputs") + .HasForeignKey("StageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Item"); + + b.Navigation("Stage"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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("Branch"); + + 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", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + 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.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Count"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + + 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.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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("Branch"); + + 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.StockTransferLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.StockTransferLine", "TransferLine") + .WithMany("Serials") + .HasForeignKey("TransferLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Serial"); + + b.Navigation("TransferLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany("Children") + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NavItem"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicketMessage", b => + { + b.HasOne("ERPCore.Domain.Entities.SupportTicket", "Ticket") + .WithMany("Messages") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Stages") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + 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.Warehouse", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b => + { + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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.Navigation("Customer"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Navigation("Installments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Navigation("Serials"); + + b.Navigation("WarrantyNumbers"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Notification", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.Navigation("Edges"); + + b.Navigation("Events"); + + b.Navigation("Stages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.Navigation("Edges"); + + b.Navigation("Runs"); + + b.Navigation("Stages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.Navigation("Lines"); + + b.Navigation("Payments"); + }); + + 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.RunStage", b => + { + b.Navigation("Events"); + + b.Navigation("Inputs"); + + b.Navigation("Outputs"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.Navigation("BundleSales"); + + b.Navigation("Slips"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.Navigation("Lines"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.Navigation("Serials"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.Navigation("Serials"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.Navigation("Serials"); + }); + + 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.StockTransferLine", b => + { + b.Navigation("Serials"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.Navigation("Inputs"); + + b.Navigation("Outputs"); + }); + + 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/Migrations/20261007054513_AddItemTrackingSerialsWarranty.cs b/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.cs new file mode 100644 index 0000000..b8236d3 --- /dev/null +++ b/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.cs @@ -0,0 +1,635 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ERPCore.Migrations +{ + /// + public partial class AddItemTrackingSerialsWarranty : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BinId", + table: "serials", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "serials", + type: "datetime(6)", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "GrnLineId", + table: "serials", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReceivedAt", + table: "serials", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "serials", + type: "int unsigned", + nullable: false, + defaultValue: 0u); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "serials", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "WarehouseId", + table: "serials", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "WarrantyPeriodMonths", + table: "serials", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "BatchId", + table: "sales_return_lines", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "BatchId", + table: "sales_invoice_lines", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "grn_line_serials", + columns: table => new + { + GrnLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + GrnLineId = table.Column(type: "int", nullable: false), + SerialNo = table.Column(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"), + SerialId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_grn_line_serials", x => x.GrnLineSerialId); + table.ForeignKey( + name: "FK_grn_line_serials_grn_lines_GrnLineId", + column: x => x.GrnLineId, + principalTable: "grn_lines", + principalColumn: "GrnLineId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_grn_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "sales_invoice_line_serials", + columns: table => new + { + SalesInvoiceLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SalesInvoiceLineId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sales_invoice_line_serials", x => x.SalesInvoiceLineSerialId); + table.ForeignKey( + name: "FK_sales_invoice_line_serials_sales_invoice_lines_SalesInvoiceL~", + column: x => x.SalesInvoiceLineId, + principalTable: "sales_invoice_lines", + principalColumn: "SalesInvoiceLineId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_sales_invoice_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "sales_line_allocations", + columns: table => new + { + SalesLineAllocationId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SourceDocType = table.Column(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"), + SourceDocId = table.Column(type: "int", nullable: false), + SourceLineId = table.Column(type: "int", nullable: false), + ItemId = table.Column(type: "int", nullable: false), + WarehouseId = table.Column(type: "int", nullable: false), + BatchId = table.Column(type: "int", nullable: true), + SerialId = table.Column(type: "int", nullable: true), + Qty = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + UnitCost = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false), + QtyReturned = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sales_line_allocations", x => x.SalesLineAllocationId); + table.ForeignKey( + name: "FK_sales_line_allocations_batches_BatchId", + column: x => x.BatchId, + principalTable: "batches", + principalColumn: "BatchId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_sales_line_allocations_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "sales_return_line_serials", + columns: table => new + { + SalesReturnLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + ReturnLineId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sales_return_line_serials", x => x.SalesReturnLineSerialId); + table.ForeignKey( + name: "FK_sales_return_line_serials_sales_return_lines_ReturnLineId", + column: x => x.ReturnLineId, + principalTable: "sales_return_lines", + principalColumn: "ReturnLineId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_sales_return_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "sales_slip_line_serials", + columns: table => new + { + SalesSlipLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SalesSlipLineId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sales_slip_line_serials", x => x.SalesSlipLineSerialId); + table.ForeignKey( + name: "FK_sales_slip_line_serials_sales_slip_lines_SalesSlipLineId", + column: x => x.SalesSlipLineId, + principalTable: "sales_slip_lines", + principalColumn: "SalesSlipLineId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_sales_slip_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "serial_movements", + columns: table => new + { + SerialMovementId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SerialId = table.Column(type: "int", nullable: false), + SourceDocType = table.Column(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"), + SourceDocId = table.Column(type: "int", nullable: false), + Status = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"), + FromWarehouseId = table.Column(type: "int", nullable: true), + WarehouseId = table.Column(type: "int", nullable: true), + UserId = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_serial_movements", x => x.SerialMovementId); + table.ForeignKey( + name: "FK_serial_movements_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "stock_transfer_line_serials", + columns: table => new + { + StockTransferLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + TransferLineId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: false), + BatchId = table.Column(type: "int", nullable: true), + UnitCost = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: true), + ReceivedAt = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_stock_transfer_line_serials", x => x.StockTransferLineSerialId); + table.ForeignKey( + name: "FK_stock_transfer_line_serials_batches_BatchId", + column: x => x.BatchId, + principalTable: "batches", + principalColumn: "BatchId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_stock_transfer_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_stock_transfer_line_serials_stock_transfer_lines_TransferLin~", + column: x => x.TransferLineId, + principalTable: "stock_transfer_lines", + principalColumn: "TransferLineId", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "warranty_registrations", + columns: table => new + { + WarrantyRegistrationId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + ItemId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: true), + CustomerId = table.Column(type: "int", nullable: false), + SourceDocType = table.Column(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"), + SourceDocId = table.Column(type: "int", nullable: false), + SourceLineId = table.Column(type: "int", nullable: false), + Qty = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + StartDate = table.Column(type: "date", nullable: false), + PeriodMonths = table.Column(type: "int", nullable: false), + EndDate = table.Column(type: "date", nullable: false), + Status = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: true), + RowVersion = table.Column(type: "int unsigned", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_warranty_registrations", x => x.WarrantyRegistrationId); + table.ForeignKey( + name: "FK_warranty_registrations_customers_CustomerId", + column: x => x.CustomerId, + principalTable: "customers", + principalColumn: "CustomerId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_warranty_registrations_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_warranty_registrations_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateIndex( + name: "IX_serials_BinId", + table: "serials", + column: "BinId"); + + migrationBuilder.CreateIndex( + name: "IX_serials_GrnLineId", + table: "serials", + column: "GrnLineId"); + + migrationBuilder.CreateIndex( + name: "IX_serials_ItemId_Status", + table: "serials", + columns: new[] { "ItemId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_serials_WarehouseId_ItemId_Status", + table: "serials", + columns: new[] { "WarehouseId", "ItemId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_sales_return_lines_BatchId", + table: "sales_return_lines", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_invoice_lines_BatchId", + table: "sales_invoice_lines", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_grn_line_serials_GrnLineId_SerialNo", + table: "grn_line_serials", + columns: new[] { "GrnLineId", "SerialNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_grn_line_serials_SerialId", + table: "grn_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_invoice_line_serials_SalesInvoiceLineId_SerialId", + table: "sales_invoice_line_serials", + columns: new[] { "SalesInvoiceLineId", "SerialId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sales_invoice_line_serials_SerialId", + table: "sales_invoice_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_line_allocations_BatchId", + table: "sales_line_allocations", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_line_allocations_SerialId", + table: "sales_line_allocations", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_line_allocations_SourceDocType_SourceDocId_SourceLineId", + table: "sales_line_allocations", + columns: new[] { "SourceDocType", "SourceDocId", "SourceLineId" }); + + migrationBuilder.CreateIndex( + name: "IX_sales_return_line_serials_ReturnLineId_SerialId", + table: "sales_return_line_serials", + columns: new[] { "ReturnLineId", "SerialId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sales_return_line_serials_SerialId", + table: "sales_return_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_slip_line_serials_SalesSlipLineId_SerialId", + table: "sales_slip_line_serials", + columns: new[] { "SalesSlipLineId", "SerialId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sales_slip_line_serials_SerialId", + table: "sales_slip_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_serial_movements_SerialId_SerialMovementId", + table: "serial_movements", + columns: new[] { "SerialId", "SerialMovementId" }); + + migrationBuilder.CreateIndex( + name: "IX_serial_movements_SourceDocType_SourceDocId", + table: "serial_movements", + columns: new[] { "SourceDocType", "SourceDocId" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_line_serials_BatchId", + table: "stock_transfer_line_serials", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_line_serials_SerialId", + table: "stock_transfer_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_line_serials_TransferLineId_SerialId", + table: "stock_transfer_line_serials", + columns: new[] { "TransferLineId", "SerialId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_CustomerId", + table: "warranty_registrations", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_ItemId", + table: "warranty_registrations", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_SerialId", + table: "warranty_registrations", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_SourceDocType_SourceDocId", + table: "warranty_registrations", + columns: new[] { "SourceDocType", "SourceDocId" }); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_Status_EndDate", + table: "warranty_registrations", + columns: new[] { "Status", "EndDate" }); + + migrationBuilder.AddForeignKey( + name: "FK_sales_invoice_lines_batches_BatchId", + table: "sales_invoice_lines", + column: "BatchId", + principalTable: "batches", + principalColumn: "BatchId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_sales_return_lines_batches_BatchId", + table: "sales_return_lines", + column: "BatchId", + principalTable: "batches", + principalColumn: "BatchId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_serials_bins_BinId", + table: "serials", + column: "BinId", + principalTable: "bins", + principalColumn: "BinId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_serials_grn_lines_GrnLineId", + table: "serials", + column: "GrnLineId", + principalTable: "grn_lines", + principalColumn: "GrnLineId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_serials_warehouses_WarehouseId", + table: "serials", + column: "WarehouseId", + principalTable: "warehouses", + principalColumn: "WarehouseId", + onDelete: ReferentialAction.Restrict); + + // Backfill serial rows that predate this migration: a real CreatedAt, a live + // concurrency token (0 is never live — see ErpDbContext.BumpRowVersions), and the + // warehouse of an in-stock serial's own open layer, if it has one. + migrationBuilder.Sql(""" + UPDATE `serials` SET `CreatedAt` = UTC_TIMESTAMP(6) WHERE `CreatedAt` < '1900-01-01'; + UPDATE `serials` SET `RowVersion` = 1 WHERE `RowVersion` = 0; + UPDATE `serials` s + JOIN `stock_layers` l ON l.`SerialId` = s.`SerialId` AND l.`QtyRemaining` > 0 + SET s.`WarehouseId` = l.`WarehouseId` + WHERE s.`Status` = 'InStock' AND s.`WarehouseId` IS NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_sales_invoice_lines_batches_BatchId", + table: "sales_invoice_lines"); + + migrationBuilder.DropForeignKey( + name: "FK_sales_return_lines_batches_BatchId", + table: "sales_return_lines"); + + migrationBuilder.DropForeignKey( + name: "FK_serials_bins_BinId", + table: "serials"); + + migrationBuilder.DropForeignKey( + name: "FK_serials_grn_lines_GrnLineId", + table: "serials"); + + migrationBuilder.DropForeignKey( + name: "FK_serials_warehouses_WarehouseId", + table: "serials"); + + migrationBuilder.DropTable( + name: "grn_line_serials"); + + migrationBuilder.DropTable( + name: "sales_invoice_line_serials"); + + migrationBuilder.DropTable( + name: "sales_line_allocations"); + + migrationBuilder.DropTable( + name: "sales_return_line_serials"); + + migrationBuilder.DropTable( + name: "sales_slip_line_serials"); + + migrationBuilder.DropTable( + name: "serial_movements"); + + migrationBuilder.DropTable( + name: "stock_transfer_line_serials"); + + migrationBuilder.DropTable( + name: "warranty_registrations"); + + migrationBuilder.DropIndex( + name: "IX_serials_BinId", + table: "serials"); + + migrationBuilder.DropIndex( + name: "IX_serials_GrnLineId", + table: "serials"); + + migrationBuilder.DropIndex( + name: "IX_serials_ItemId_Status", + table: "serials"); + + migrationBuilder.DropIndex( + name: "IX_serials_WarehouseId_ItemId_Status", + table: "serials"); + + migrationBuilder.DropIndex( + name: "IX_sales_return_lines_BatchId", + table: "sales_return_lines"); + + migrationBuilder.DropIndex( + name: "IX_sales_invoice_lines_BatchId", + table: "sales_invoice_lines"); + + migrationBuilder.DropColumn( + name: "BinId", + table: "serials"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "serials"); + + migrationBuilder.DropColumn( + name: "GrnLineId", + table: "serials"); + + migrationBuilder.DropColumn( + name: "ReceivedAt", + table: "serials"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "serials"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "serials"); + + migrationBuilder.DropColumn( + name: "WarehouseId", + table: "serials"); + + migrationBuilder.DropColumn( + name: "WarrantyPeriodMonths", + table: "serials"); + + migrationBuilder.DropColumn( + name: "BatchId", + table: "sales_return_lines"); + + migrationBuilder.DropColumn( + name: "BatchId", + table: "sales_invoice_lines"); + } + } +} diff --git a/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs index 59f4860..c32dfc2 100644 --- a/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs @@ -1693,6 +1693,35 @@ namespace ERPCore.Migrations b.ToTable("grn_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b => + { + b.Property("GrnLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineSerialId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("GrnLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("GrnLineId", "SerialNo") + .IsUnique(); + + b.ToTable("grn_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => { b.Property("GrnLineWarrantyNumberId") @@ -4740,6 +4769,9 @@ namespace ERPCore.Migrations .HasPrecision(18, 4) .HasColumnType("decimal(18,4)"); + b.Property("BatchId") + .HasColumnType("int"); + b.Property("Description") .IsRequired() .HasMaxLength(200) @@ -4810,6 +4842,8 @@ namespace ERPCore.Migrations b.HasKey("SalesInvoiceLineId"); + b.HasIndex("BatchId"); + b.HasIndex("ItemId"); b.HasIndex("SalesInvoiceId"); @@ -4819,6 +4853,30 @@ namespace ERPCore.Migrations b.ToTable("sales_invoice_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b => + { + b.Property("SalesInvoiceLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceLineSerialId")); + + b.Property("SalesInvoiceLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SalesInvoiceLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_invoice_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => { b.Property("SalesInvoicePaymentId") @@ -4868,6 +4926,63 @@ namespace ERPCore.Migrations b.ToTable("sales_invoice_payments", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b => + { + b.Property("SalesLineAllocationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesLineAllocationId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReturned") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SourceLineId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesLineAllocationId"); + + b.HasIndex("BatchId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SourceDocType", "SourceDocId", "SourceLineId"); + + b.ToTable("sales_line_allocations", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => { b.Property("ReturnId") @@ -4932,6 +5047,9 @@ namespace ERPCore.Migrations MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnLineId")); + b.Property("BatchId") + .HasColumnType("int"); + b.Property("ItemId") .HasColumnType("int"); @@ -4947,6 +5065,8 @@ namespace ERPCore.Migrations b.HasKey("ReturnLineId"); + b.HasIndex("BatchId"); + b.HasIndex("ItemId"); b.HasIndex("ReturnId"); @@ -4956,6 +5076,30 @@ namespace ERPCore.Migrations b.ToTable("sales_return_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b => + { + b.Property("SalesReturnLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesReturnLineSerialId")); + + b.Property("ReturnLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesReturnLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("ReturnLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_return_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => { b.Property("SalesSlipId") @@ -5151,6 +5295,30 @@ namespace ERPCore.Migrations b.ToTable("sales_slip_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b => + { + b.Property("SalesSlipLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipLineSerialId")); + + b.Property("SalesSlipLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesSlipLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SalesSlipLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_slip_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { b.Property("SerialId") @@ -5163,9 +5331,25 @@ namespace ERPCore.Migrations .HasMaxLength(100) .HasColumnType("varchar(100)"); + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrnLineId") + .HasColumnType("int"); + b.Property("ItemId") .HasColumnType("int"); + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + b.Property("SerialNo") .IsRequired() .HasMaxLength(100) @@ -5176,17 +5360,79 @@ namespace ERPCore.Migrations .HasMaxLength(20) .HasColumnType("varchar(20)"); + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + b.HasKey("SerialId"); b.HasIndex("Barcode") .IsUnique(); + b.HasIndex("BinId"); + + b.HasIndex("GrnLineId"); + b.HasIndex("ItemId", "SerialNo") .IsUnique(); + b.HasIndex("ItemId", "Status"); + + b.HasIndex("WarehouseId", "ItemId", "Status"); + b.ToTable("serials", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b => + { + b.Property("SerialMovementId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SerialMovementId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FromWarehouseId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SerialMovementId"); + + b.HasIndex("SerialId", "SerialMovementId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("serial_movements", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => { b.Property("EdgeId") @@ -5744,6 +5990,42 @@ namespace ERPCore.Migrations b.ToTable("stock_transfer_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b => + { + b.Property("StockTransferLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StockTransferLineSerialId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("TransferLineId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.HasKey("StockTransferLineSerialId"); + + b.HasIndex("BatchId"); + + b.HasIndex("SerialId"); + + b.HasIndex("TransferLineId", "SerialId") + .IsUnique(); + + b.ToTable("stock_transfer_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => { b.Property("SubCategoryId") @@ -6787,6 +7069,77 @@ namespace ERPCore.Migrations }); }); + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b => + { + b.Property("WarrantyRegistrationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarrantyRegistrationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PeriodMonths") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SourceLineId") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("WarrantyRegistrationId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("Status", "EndDate"); + + b.ToTable("warranty_registrations", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b => { b.Property("WorkShiftId") @@ -7306,6 +7659,24 @@ namespace ERPCore.Migrations b.Navigation("PoLine"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany("Serials") + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("GrnLine"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => { b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") @@ -8143,6 +8514,11 @@ namespace ERPCore.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.Item", "Item") .WithMany() .HasForeignKey("ItemId") @@ -8161,6 +8537,8 @@ namespace ERPCore.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.Navigation("Batch"); + b.Navigation("Item"); b.Navigation("SalesInvoice"); @@ -8168,6 +8546,25 @@ namespace ERPCore.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine") + .WithMany("Serials") + .HasForeignKey("SalesInvoiceLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SalesInvoiceLine"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -8187,6 +8584,23 @@ namespace ERPCore.Migrations b.Navigation("SalesInvoice"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -8224,6 +8638,11 @@ namespace ERPCore.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.Item", "Item") .WithMany() .HasForeignKey("ItemId") @@ -8241,6 +8660,8 @@ namespace ERPCore.Migrations .HasForeignKey("SalesInvoiceLineId") .OnDelete(DeleteBehavior.Restrict); + b.Navigation("Batch"); + b.Navigation("Item"); b.Navigation("Return"); @@ -8248,6 +8669,25 @@ namespace ERPCore.Migrations b.Navigation("SalesInvoiceLine"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesReturnLine", "ReturnLine") + .WithMany("Serials") + .HasForeignKey("ReturnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ReturnLine"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => { b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") @@ -8323,15 +8763,66 @@ namespace ERPCore.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesSlipLine", "SalesSlipLine") + .WithMany("Serials") + .HasForeignKey("SalesSlipLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SalesSlipLine"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .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.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Bin"); + + b.Navigation("GrnLine"); + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b => + { + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Serial"); }); modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => @@ -8470,7 +8961,7 @@ namespace ERPCore.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("ERPCore.Domain.Entities.Serial", null) + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") .WithMany() .HasForeignKey("SerialId") .OnDelete(DeleteBehavior.Restrict); @@ -8478,6 +8969,8 @@ namespace ERPCore.Migrations b.Navigation("Adjustment"); b.Navigation("Item"); + + b.Navigation("Serial"); }); modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => @@ -8695,6 +9188,30 @@ namespace ERPCore.Migrations b.Navigation("Transfer"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.StockTransferLine", "TransferLine") + .WithMany("Serials") + .HasForeignKey("TransferLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Serial"); + + b.Navigation("TransferLine"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => { b.HasOne("ERPCore.Domain.Entities.Category", "Category") @@ -8809,6 +9326,32 @@ namespace ERPCore.Migrations b.Navigation("Branch"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b => + { + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + 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.Navigation("Customer"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => { b.Navigation("Lines"); @@ -8841,6 +9384,8 @@ namespace ERPCore.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => { + b.Navigation("Serials"); + b.Navigation("WarrantyNumbers"); }); @@ -8944,16 +9489,31 @@ namespace ERPCore.Migrations b.Navigation("Payments"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.Navigation("Serials"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => { b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.Navigation("Serials"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => { b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.Navigation("Serials"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => { b.Navigation("Lines"); @@ -8969,6 +9529,11 @@ namespace ERPCore.Migrations b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Navigation("Serials"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => { b.Navigation("Messages"); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 5ab668c..fe20411 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -166,6 +166,8 @@ 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(); diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs index 0bda2f7..7791412 100644 --- a/Backend/ERPCore/Services/AdjustmentService.cs +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -36,6 +36,7 @@ public sealed class AdjustmentService : IAdjustmentService private readonly IBranchConfigService _branchConfig; private readonly INotificationService _notifications; private readonly IUnitOfWork _uow; + private readonly ISerialTrackingService _serialTracking; private readonly IGeneralLedgerService _gl; private readonly IGlAccountConfigService _glAccounts; @@ -45,6 +46,7 @@ public sealed class AdjustmentService : IAdjustmentService INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { + _serialTracking = serialTracking; _adjustments = adjustments; _warehouses = warehouses; _items = items; @@ -155,17 +157,24 @@ public sealed class AdjustmentService : IAdjustmentService if (reason.Context != ReasonContext.Adjustment) throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422); + // Serials per line: outbound ones are validated as on hand; inbound ones are created (or + // re-found) in the context now and get their ids on the flush below. + var serialsByLine = new List>(); + var documentSerials = new HashSet(); + var documentSerialNos = new HashSet(StringComparer.OrdinalIgnoreCase); 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 item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + serialsByLine.Add(line.QtyDelta < 0 + ? await _serialTracking.ResolveOutboundAsync(item, request.WarehouseId, -line.QtyDelta, line.SerialIds, + documentSerials, new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct) + : await _serialTracking.ResolveInboundAsync(item, request.WarehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct)); } 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 => { @@ -179,19 +188,31 @@ public sealed class AdjustmentService : IAdjustmentService Status = AdjustmentStatus.Posted, CreatedBy = _currentUser.AuditUserId, CreatedAt = now, - Lines = request.Lines.Select(l => new StockAdjustmentLine + Lines = request.Lines.Select((l, i) => new StockAdjustmentLine { ItemId = l.ItemId, BinId = l.BinId, BatchId = l.BatchId, - QtyDelta = l.QtyDelta + QtyDelta = l.QtyDelta, + Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null }).ToList() }; await _adjustments.AddAsync(entity, token); - await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId + await _uow.SaveChangesAsync(token); // flush so AdjustmentId (and new SerialIds) are valid + var deltas = request.Lines.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta, + serialsByLine[i].Select(s => s.SerialId).ToList())).ToList(); var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token); + for (var i = 0; i < request.Lines.Count; i++) + { + var line = request.Lines[i]; + await _serialTracking.MoveAsync(serialsByLine[i], + line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock, + line.QtyDelta < 0 ? null : request.WarehouseId, line.QtyDelta < 0 ? null : line.BinId, + DocumentTypes.Adjustment, entity.AdjustmentId, now, token); + } + entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token); if (entity.GlJournalNo is not null) entity.GlPostedAt = now; @@ -207,6 +228,6 @@ public sealed class AdjustmentService : IAdjustmentService a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt, a.GlJournalNo, a.GlPostedAt, a.Lines.OrderBy(l => l.AdjLineId) - .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(), + .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId)).ToList(), ledgerRefs); } diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index 0f68255..1011121 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -50,6 +50,7 @@ public sealed class CountService : ICountService IGeneralLedgerService gl, IRepository stockLayers, IRepository batches, IRepository serials, IGlAccountConfigService glAccounts) { + _serialTracking = serialTracking; _counts = counts; _adjustments = adjustments; _warehouses = warehouses; @@ -171,17 +172,32 @@ public sealed class CountService : ICountService .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty }); } + // Stock that reached the item without a batch (e.g. an adjustment) is counted on its own line. + var unbatched = await _stockLayers.Query().AsNoTracking() + .Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == null) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + if (unbatched != 0) + lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = unbatched }); } - else if (item.TrackingMode == TrackingMode.Serial) + else if (await _serialTracking.IsSerialTrackedAsync(item, ct)) // off: counted as one aggregate line { + // Serials that should be at this warehouse (one line each, counted 0 or 1), plus + // one aggregate line for any pre-tracking serial-less stock still here (D6). var serials = await _serials.Query().AsNoTracking() - .Where(s => s.ItemId == id && s.Status == "InStock").ToListAsync(ct); + .Where(s => s.ItemId == id && s.WarehouseId == request.WarehouseId + && (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged)) + .OrderBy(s => s.SerialNo).ToListAsync(ct); foreach (var serial in serials) { var open = await _stockLayers.Query().AsNoTracking().AnyAsync( l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct); lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 }); } + var untracked = await _stockLayers.Query().AsNoTracking() + .Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == null) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + if (untracked != 0 || serials.Count == 0) + lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked }); } else { @@ -225,6 +241,8 @@ public sealed class CountService : ICountService { 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); + if (line.SerialId is not null && input.CountedQty is not (0m or 1m)) + throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422); line.CountedQty = input.CountedQty; line.Variance = input.CountedQty - line.SystemQty; } @@ -246,9 +264,11 @@ public sealed class CountService : ICountService 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)) + // Batch and serial lines adjust exactly their own lot/unit. + var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList(); + var deltas = varianceLines + .Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.Variance!.Value, + l.SerialId is int serialId ? new List { serialId } : null)) .ToList(); // No variances → just close the count, no adjustment. @@ -280,6 +300,8 @@ public sealed class CountService : ICountService { ItemId = d.ItemId, BinId = d.BinId, + BatchId = d.BatchId, + SerialId = d.SerialIds?.FirstOrDefault(), QtyDelta = d.QtyDelta }).ToList() }; @@ -288,6 +310,17 @@ public sealed class CountService : ICountService var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token); + // A serial counted missing is written off (Scrapped); one found keeps/regains InStock here. + foreach (var line in varianceLines.Where(l => l.SerialId is not null)) + { + var serial = await _serials.Query().FirstAsync(s => s.SerialId == line.SerialId, token); + var found = line.Variance > 0; + await _serialTracking.MoveAsync(new[] { serial }, + found ? SerialStatus.InStock : SerialStatus.Scrapped, + found ? count.WarehouseId : null, found ? line.BinId : null, + DocumentTypes.Count, count.CountId, now, token); + } + adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token); if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now; diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 570a994..4e8bf23 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -34,6 +34,9 @@ public sealed class GrnService : IGrnService private readonly IRepository _bins; private readonly IRepository _vendors; private readonly IRepository _batches; + private readonly IRepository _serials; + private readonly IRepository _serialMovements; + private readonly ISerialTrackingService _serialTracking; private readonly IRepository _layers; private readonly IRepository _ledger; private readonly IRepository _taxRates; @@ -50,7 +53,7 @@ public sealed class GrnService : IGrnService public GrnService( IRepository grns, IRepository pos, IRepository poLines, IRepository items, IRepository warehouses, - IRepository bins, IRepository vendors, IRepository batches, + IRepository bins, IRepository vendors, IRepository batches, IRepository serials, IRepository serialMovements, ISerialTrackingService serialTracking, IRepository layers, IRepository ledger, IRepository taxRates, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig, IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts) @@ -63,6 +66,9 @@ public sealed class GrnService : IGrnService _bins = bins; _vendors = vendors; _batches = batches; + _serials = serials; + _serialMovements = serialMovements; + _serialTracking = serialTracking; _layers = layers; _ledger = ledger; _taxRates = taxRates; @@ -110,6 +116,8 @@ public sealed class GrnService : IGrnService { var grn = await _grns.Query().AsNoTracking() .Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers) + .Include(g => g.Lines).ThenInclude(l => l.Serials) + .Include(g => g.Lines).ThenInclude(l => l.Batch) .Include(g => g.PurchaseInvoice) .FirstOrDefaultAsync(g => g.GrnId == grnId, ct); return grn is null ? null : Map(grn); @@ -155,6 +163,7 @@ public sealed class GrnService : IGrnService var lines = new List(); var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>(); + var requestSerials = new HashSet<(int ItemId, string SerialNo)>(); foreach (var input in request.Lines) { @@ -203,7 +212,9 @@ public sealed class GrnService : IGrnService var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero); var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct); - var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty); + var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty, + serialTracked: await _serialTracking.IsSerialTrackedAsync(item, ct)); + var serials = await ResolveSerialNumbersAsync(item, input.SerialNumbers, input.Qty, requestSerials, ct); lines.Add(new GrnLine { @@ -221,7 +232,8 @@ public sealed class GrnService : IGrnService ReceivedValue = receivedValue, LineTotal = receivedValue + vatAmount, HoldStatus = input.HoldStatus, - WarrantyNumbers = warrantyNumbers + WarrantyNumbers = warrantyNumbers, + Serials = serials }); } @@ -252,7 +264,7 @@ public sealed class GrnService : IGrnService public async Task ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default) { var grn = await _grns.Query() - .Include(g => g.Lines) + .Include(g => g.Lines).ThenInclude(l => l.Serials) .FirstOrDefaultAsync(g => g.GrnId == grnId, ct) ?? throw new NotFoundException($"GRN {grnId} was not found."); @@ -278,10 +290,21 @@ public sealed class GrnService : IGrnService var qtyBase = line.Qty; var unitCostBase = line.NetUnitCost; - var layer = await _fifo.CreateInboundLayerAsync( - line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, - qtyBase, unitCostBase, now, token); - createdLayers.Add(layer); + if (line.Serials.Count > 0) + { + // Serial-tracked: one Serial and one qty-1 layer per unit, so a sale can later + // consume exactly the serial it names. The ledger stays one row per line + // (below): every ledger row also posts a GL entry, and per-unit rows would mean + // one GL call per serial — serial history is read from the document tables. + createdLayers.AddRange(await ReceiveSerialsAsync(grn, line, unitCostBase, now, token)); + } + else + { + 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)) @@ -411,12 +434,16 @@ public sealed class GrnService : IGrnService /// period is not entered at receipt; it is snapshotted from , /// which the item must have been given at creation ( enforces that). /// - private static List ResolveWarrantyNumbers(Item item, List? numbers, decimal qty) + private static List ResolveWarrantyNumbers(Item item, List? numbers, decimal qty, bool serialTracked) { if (item.Warranty != Warranty.Warranty) return new List(); if (item.WarrantyPeriodMonths is null) throw new DomainException( ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422); + // A serial item's serial number is its warranty identity (docs/ITEM_TRACKING_PLAN.md D1); + // its coverage is snapshotted onto each Serial at confirm instead. With serial numbers + // switched off the item has no serials, so it takes warranty numbers like any other. + if (serialTracked) return new List(); var trimmed = (numbers ?? new List()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); if (trimmed.Count != qty) @@ -429,6 +456,88 @@ public sealed class GrnService : IGrnService return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList(); } + /// + /// A serial-tracked item requires exactly one serial number per received unit — so the + /// quantity must be whole. Numbers must be unique within the request and must not already + /// exist for the item. Any other item must not carry serial numbers. + /// + private async Task> ResolveSerialNumbersAsync( + Item item, List? numbers, decimal qty, HashSet<(int, string)> requestSerials, CancellationToken ct) + { + var trimmed = (numbers ?? new List()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); + if (!await _serialTracking.IsSerialTrackedAsync(item, ct)) + { + _serialTracking.RejectSerialsWhileDisabled(item, trimmed.Count); + if (trimmed.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serial numbers are not accepted.", 422); + return new List(); + } + + if (qty != decimal.Truncate(qty)) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is serial-tracked; the quantity must be a whole number.", 422); + if (trimmed.Count != qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; provide exactly {qty} serial number(s), got {trimmed.Count}.", 422); + if (trimmed.Any(n => n.Length > 100)) + throw new DomainException(ErrorCodes.Validation, "A serial number cannot be longer than 100 characters.", 422); + + // Case-insensitive, matching the utf8mb4_unicode_ci unique index on serials (ItemId, SerialNo). + foreach (var n in trimmed) + if (!requestSerials.Add((item.ItemId, n.ToUpperInvariant()))) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number {n} for item {item.Sku} is entered more than once.", 422); + + await EnsureSerialsFreeAsync(item.ItemId, item.Sku, trimmed, ct); + return trimmed.Select(n => new GrnLineSerial { SerialNo = n }).ToList(); + } + + private async Task EnsureSerialsFreeAsync(int itemId, string sku, List numbers, CancellationToken ct) + { + var taken = await _serials.Query().AsNoTracking() + .Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo)) + .Select(s => s.SerialNo).Take(5).ToListAsync(ct); + if (taken.Count > 0) + throw new DomainException(ErrorCodes.SerialDuplicate, + $"Serial number(s) already exist for item {sku}: {string.Join(", ", taken)}.", 409); + } + + /// + /// Confirm-time half of serial capture: re-checks the numbers are still free (another GRN + /// may have confirmed them since this draft was created — the unique index is the final + /// guard), then creates each with its own qty-1 FIFO layer. + /// + private async Task> ReceiveSerialsAsync(Grn grn, GrnLine line, decimal unitCost, DateTime now, CancellationToken ct) + { + var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct); + await EnsureSerialsFreeAsync(item.ItemId, item.Sku, line.Serials.Select(s => s.SerialNo).ToList(), ct); + + var warrantyMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null; + var layers = new List(); + foreach (var captured in line.Serials.OrderBy(s => s.GrnLineSerialId)) + { + var serial = new Serial + { + ItemId = line.ItemId, + SerialNo = captured.SerialNo, + Status = SerialStatus.InStock, + WarehouseId = grn.WarehouseId, + BinId = line.BinId, + GrnLineId = line.GrnLineId, + ReceivedAt = now, + CreatedAt = now, + WarrantyPeriodMonths = warrantyMonths + }; + await _serials.AddAsync(serial, ct); + captured.Serial = serial; // FK fixed up on SaveChanges + await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.Grn, grn.GrnId, _currentUser.AuditUserId, now), ct); + + var layer = await _fifo.CreateInboundLayerAsync( + line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, 1m, unitCost, now, ct); + layer.Serial = serial; + layers.Add(layer); + } + return layers; + } + private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct) { if (poId is null) return; @@ -472,5 +581,7 @@ public sealed class GrnService : IGrnService l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero), l.HoldStatus, l.BatchId, - l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList()); + l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList(), + l.Batch?.BatchNo, l.Batch?.ExpiryDate, + l.Serials.OrderBy(s => s.GrnLineSerialId).Select(s => new GrnLineSerialDto(s.SerialNo, s.SerialId)).ToList())).ToList()); } diff --git a/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs b/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs index ac7e043..be04655 100644 --- a/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs +++ b/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs @@ -31,9 +31,26 @@ public interface IFifoCostingService /// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the /// quantity stock can't cover is recorded as a negative layer at the last known unit cost. /// + /// + /// Serial-tracked issue: consume exactly these serials' qty-1 layers (count must equal + /// ), or throw SERIAL_NOT_AVAILABLE. When null, serial layers + /// are never touched — serial stock only leaves by naming its serial — so only untracked + /// (legacy, pre-serial-capture) layers are issued. Shortfall is never allowed with serials. + /// + /// + /// Issue order is FEFO then FIFO: layers whose batch expires soonest go first, then oldest + /// receipt. Items without batches therefore keep plain FIFO. + /// Task> ConsumeAsync( int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default, - bool allowShortfall = false); + bool allowShortfall = false, IReadOnlyCollection? serialIds = null); + + /// + /// Read-only (no locks) FEFO preview of how would be issued across + /// batches — for UI suggestions. Expired, on-hold and serial layers are excluded. + /// + Task> SuggestBatchesAsync( + int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default); /// /// Append an immutable ledger entry. Value defaults to round(qtyBase × unitCost, 4). @@ -60,4 +77,7 @@ public interface IFifoCostingService } /// A quantity consumed from one FIFO layer at that layer's unit cost. -public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost); +public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost, int? BatchId = null, int? SerialId = null); + +/// A suggested batch pick: of (null = unbatched stock). +public sealed record BatchAllocation(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty); diff --git a/Backend/ERPCore/Services/Interfaces/IItemTrackingService.cs b/Backend/ERPCore/Services/Interfaces/IItemTrackingService.cs new file mode 100644 index 0000000..9d9b32c --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IItemTrackingService.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; + +namespace ERPCore.Services.Interfaces; + +/// Read side of item tracking: pickers, serial and warranty registers, batch stock. +public interface IItemTrackingService +{ + /// + /// The next free serial numbers for an item under + /// : continues after the highest number already used with that + /// prefix (received serials and numbers on draft GRNs), zero-padded to at least 4 digits — + /// e.g. prefix "PH-" after PH-0041 gives PH-0042, PH-0043, … + /// + Task> NextSerialNumbersAsync(int itemId, string prefix, int count, CancellationToken ct = default); + + /// In-stock serials of an item at a warehouse, optionally filtered by number. + Task> GetAvailableSerialsAsync(int itemId, int warehouseId, string? q, int take, CancellationToken ct = default); + + /// Issuable (not expired, not on hold) stock per batch, soonest expiry first. + Task> GetAvailableBatchesAsync(int itemId, int warehouseId, CancellationToken ct = default); + + /// How posting would pick FEFO across batches. + Task> SuggestBatchesAsync(int itemId, int warehouseId, decimal qty, CancellationToken ct = default); + + Task> ListSerialsAsync( + PageQuery query, int? itemId, int? warehouseId, SerialStatus? status, CancellationToken ct = default); + + Task GetSerialAsync(int serialId, CancellationToken ct = default); + + /// Exact serial-number lookup (all items) — e.g. a warranty desk scanning a unit. + Task> FindSerialAsync(string serialNo, CancellationToken ct = default); + + /// + /// Warranty register. keeps active warranties ending within + /// that many days; true/false keeps only expired / unexpired ones. + /// + Task> ListWarrantiesAsync( + PageQuery query, int? customerId, int? itemId, string? serialNo, WarrantyStatus? status, + int? expiringWithinDays, bool? expired, CancellationToken ct = default); + + /// Batch stock on hand (positive), optionally only batches expiring within N days (expired included). + Task> ListBatchStockAsync( + PageQuery query, int? itemId, int? warehouseId, int? expiringWithinDays, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs index 2344e57..0f41825 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; using ERPCore.Dtos.Sales; @@ -63,6 +64,18 @@ public interface ISalesDomainService bool isFreeIssue); Task IsStockedItemAsync(int itemId, CancellationToken ct = default); + + /// + /// Batch/serial rules for one sales line (docs/ITEM_TRACKING_PLAN.md Phase 4). A serial-tracked + /// item needs one serial per unit of , each InStock at + /// and not already used on another line of the same document + /// () — unless enough pre-tracking (serial-less) stock is + /// still on hand there (D6). A pinned batch must belong to the item and not be expired. + /// Returns the chosen serials as tracked entities, for the line's serial rows. + /// + Task> ValidateLineTrackingAsync( + int itemId, int warehouseId, decimal totalQty, int? batchId, IReadOnlyCollection? serialIds, + ISet documentSerials, CancellationToken ct = default); } public sealed record SalesLineComputation( diff --git a/Backend/ERPCore/Services/Interfaces/ISerialTrackingService.cs b/Backend/ERPCore/Services/Interfaces/ISerialTrackingService.cs new file mode 100644 index 0000000..20ac2bf --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISerialTrackingService.cs @@ -0,0 +1,55 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Services.Interfaces; + +/// +/// Shared serial rules for stock documents other than GRN and sales — purchase returns, +/// transfers, adjustments and counts (docs/ITEM_TRACKING_PLAN.md Phases 6–7). Validation is +/// done before the document's transaction; runs inside it. +/// +public interface ISerialTrackingService +{ + /// + /// Product Configuration "Serial numbers" switch (). + /// Off: no document asks for or accepts serials, and serial-mode items are handled as + /// untracked stock. Defaults to on when the config row is missing. + /// + Task IsEnabledAsync(CancellationToken ct = default); + + /// True when is serial-tracked and the switch is on. + Task IsSerialTrackedAsync(Item item, CancellationToken ct = default); + + /// + /// Throws CONFIG_DISABLED when serials were sent for a serial-mode item while the switch + /// is off — they are refused rather than silently dropped. + /// + void RejectSerialsWhileDisabled(Item item, int serialCount); + + /// + /// Serials leaving . For a serial-tracked item: one per unit of + /// , each currently at that warehouse in one of + /// and not used elsewhere on the same document + /// (); with none named, only pre-tracking (serial-less) + /// stock may go (D6). Any other item must name none. Returns tracked entities. + /// + Task> ResolveOutboundAsync( + Item item, int warehouseId, decimal qty, IReadOnlyCollection? serialIds, + ISet documentSerials, IReadOnlyCollection allowedStatuses, CancellationToken ct = default); + + /// + /// Serials arriving by adjustment or count: each number must be new for the item (it is + /// added to the context, unsaved and without a location — save before building + /// s, which need its id) or an existing serial + /// that is not currently in stock anywhere (it is brought back). One per unit of + /// ; required for a serial-tracked item, rejected otherwise. + /// + Task> ResolveInboundAsync( + Item item, int warehouseId, decimal qty, IReadOnlyCollection? serialNos, + ISet documentSerialNos, CancellationToken ct = default); + + /// Sets status and location on each serial and appends its . + Task MoveAsync( + IEnumerable serials, SerialStatus status, int? warehouseId, int? binId, + string sourceDocType, int sourceDocId, DateTime now, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IStockMutator.cs b/Backend/ERPCore/Services/Interfaces/IStockMutator.cs index 1614d19..829f763 100644 --- a/Backend/ERPCore/Services/Interfaces/IStockMutator.cs +++ b/Backend/ERPCore/Services/Interfaces/IStockMutator.cs @@ -2,8 +2,16 @@ using ERPCore.Domain.Entities; namespace ERPCore.Services.Interfaces; -/// A signed base-UOM change to an item's stock at one warehouse. -public sealed record StockDelta(int ItemId, int? BinId, int? BatchId, decimal QtyDelta); +/// +/// A signed base-UOM change to an item's stock at one warehouse. +/// (serial-tracked items): an outbound delta consumes exactly those +/// serials' layers; an inbound delta creates one qty-1 layer per serial. Either way the count +/// must equal ||, and the caller owns the serials' status/location. +/// overrides the inbound cost (default: last cost at the warehouse). +/// +public sealed record StockDelta( + int ItemId, int? BinId, int? BatchId, decimal QtyDelta, + IReadOnlyList? SerialIds = null, decimal? UnitCost = null); /// /// Shared poster for stock-affecting documents (adjustments, count variances, diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index a6d13b4..944d6e6 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -298,7 +298,7 @@ public sealed class ItemService : IItemService await ValidateReferencesAsync( request.CategoryId, request.SubCategoryId, request.BrandId, request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct); - ValidateFeatureConfiguration(request, config); + ValidateFeatureConfiguration(request, config, item.TrackingMode); // The base UOM is the sole meaning of every quantity recorded against this item — // stock layers, ledger rows and document lines are all plain counts of it. Once any @@ -490,15 +490,20 @@ public sealed class ItemService : IItemService request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, request.TrackingMode, request.Warranty, config); - private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config) + /// + /// An item keeps the tracking mode it already has even after that feature is switched off — + /// otherwise it could not be edited at all; only a change to a disabled mode is refused. + /// + private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode) => ValidateFeatureConfiguration( request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, - request.TrackingMode, request.Warranty, config); + request.TrackingMode, request.Warranty, config, currentTrackingMode); private static void ValidateFeatureConfiguration( decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice, - TrackingMode trackingMode, Warranty warranty, ProductConfigDto config) + TrackingMode trackingMode, Warranty warranty, ProductConfigDto config, TrackingMode? currentTrackingMode = null) { + var trackingUnchanged = trackingMode == currentTrackingMode; if (!config.SellingPricesEnabled && (sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null)) throw new DomainException( @@ -506,13 +511,13 @@ public sealed class ItemService : IItemService "Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.", 422); - if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial) + if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial && !trackingUnchanged) throw new DomainException( ErrorCodes.ConfigDisabled, "Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.", 422); - if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch) + if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch && !trackingUnchanged) throw new DomainException( ErrorCodes.ConfigDisabled, "Batch lists are disabled in the product configuration; trackingMode cannot be Batch.", @@ -620,7 +625,7 @@ public sealed class ItemService : IItemService private static ItemScanResultDto ToScanResult(Serial s) => new( s.Item!.ItemId, s.Item.Sku, s.Item.Name, s.Item.TrackingMode, s.Item.BaseUomId, "Serial", - null, null, null, s.SerialId, s.SerialNo, s.Status); + null, null, null, s.SerialId, s.SerialNo, s.Status.ToString()); private static ItemDetailDto ToDetail(Item i) => new( i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs index 54581e5..1dff8b2 100644 --- a/Backend/ERPCore/Services/PurchaseReturnService.cs +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -38,6 +38,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService private readonly IRepository _ledger; private readonly IStockMutator _mutator; private readonly IFifoCostingService _fifo; + private readonly IRepository _serials; + private readonly ISerialTrackingService _serialTracking; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; private readonly INotificationService _notifications; @@ -49,9 +51,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService IRepository returns, IRepository vendors, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository grnLines, IRepository layers, IRepository ledger, IStockMutator mutator, IFifoCostingService fifo, + IRepository serials, ISerialTrackingService serialTracking, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { + _serials = serials; + _serialTracking = serialTracking; _returns = returns; _vendors = vendors; _warehouses = warehouses; @@ -130,11 +135,24 @@ public sealed class PurchaseReturnService : IPurchaseReturnService // layer(s) below, not the generic mutator — track which by GrnLineId so the two // paths don't double-handle the same line. var rejectedGrnLineIds = new HashSet(); + var documentSerials = new HashSet(); + var serialsByLine = new Dictionary>(); 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 item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + + // Rejected-line serials are checked against that line's own layers below; anything + // else must name serials actually on hand (D6: or leave only pre-tracking stock). + var isRejectedLine = line.GrnLineId is not null && await _grnLines.Query().AnyAsync(g => g.GrnLineId == line.GrnLineId && g.HoldStatus == HoldStatus.Rejected, ct); + if (!isRejectedLine || line.SerialIds is { Count: > 0 }) + serialsByLine[line] = await _serialTracking.ResolveOutboundAsync( + item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials, + new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct); + if (isRejectedLine && serialsByLine.TryGetValue(line, out var named) && named.Any(s => s.GrnLineId != line.GrnLineId)) + throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials were not received on GRN line {line.GrnLineId}.", 422); + if (line.GrnLineId is not null) { var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct) @@ -158,7 +176,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService var actor = _currentUser.AuditUserId; var deltas = request.Lines .Where(l => l.GrnLineId is null || !rejectedGrnLineIds.Contains(l.GrnLineId.Value)) - .Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList(); + .Select(l => new StockDelta(l.ItemId, null, null, -l.Qty, + serialsByLine.TryGetValue(l, out var s) ? s.Select(x => x.SerialId).ToList() : null)).ToList(); var rejectedLines = request.Lines.Where(l => l.GrnLineId is not null && rejectedGrnLineIds.Contains(l.GrnLineId.Value)).ToList(); var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token => @@ -196,10 +215,31 @@ public sealed class PurchaseReturnService : IPurchaseReturnService .OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId) .ToListAsync(token); + // A serial line's layers are one per unit: named serials take exactly theirs; + // with none named, the whole remaining line may go back (every serial on it). + var named = serialsByLine.TryGetValue(line, out var lineSerials) ? lineSerials : Array.Empty(); + if (named.Count > 0) + { + var ids = named.Select(s => s.SerialId).ToHashSet(); + layers = layers.Where(l => l.SerialId != null && ids.Contains(l.SerialId.Value)).ToList(); + if (layers.Count != named.Count) + throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials are no longer on GRN line {line.GrnLineId}'s rejected stock.", 409); + } + else if (layers.Any(l => l.SerialId != null) && await _serialTracking.IsEnabledAsync(token)) + { + if (line.Qty != layers.Sum(l => l.QtyRemaining)) + throw new DomainException(ErrorCodes.TrackingRequired, + $"GRN line {line.GrnLineId} is serial-tracked; select which {line.Qty} serial(s) go back.", 422); + named = await _serials.Query() + .Where(s => layers.Select(l => l.SerialId).Contains(s.SerialId)).ToListAsync(token); + serialsByLine[line] = named; + } + if (!balances.TryGetValue(line.ItemId, out var bal)) bal = await _fifo.GetOnHandAsync(line.ItemId, request.WarehouseId, token); var remaining = line.Qty; + var takenSerialIds = new List(); foreach (var layer in layers) { if (remaining <= 0) break; @@ -207,6 +247,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService layer.QtyRemaining -= take; remaining -= take; bal -= take; + if (layer.SerialId is int takenSerial && layer.QtyRemaining == 0) takenSerialIds.Add(takenSerial); var entry = await _fifo.PostLedgerAsync( layer.ItemId, layer.WarehouseId, null, layer.BatchId, null, actor, @@ -214,8 +255,17 @@ public sealed class PurchaseReturnService : IPurchaseReturnService refs.Add(entry); } balances[line.ItemId] = bal; + + // Serial numbers switched off: serial units went back oldest-first without being + // named — still record them as returned so the register doesn't show them in stock. + if (named.Count == 0 && takenSerialIds.Count > 0) + serialsByLine[line] = await _serials.Query().Where(s => takenSerialIds.Contains(s.SerialId)).ToListAsync(token); } + foreach (var serials in serialsByLine.Values) + await _serialTracking.MoveAsync(serials, SerialStatus.ReturnedToVendor, null, null, + DocumentTypes.PurchaseReturn, ret.ReturnId, now, token); + var totalValue = refs.Sum(r => r.Value); if (totalValue > 0) { diff --git a/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs b/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs index e3a8780..20b54ce 100644 --- a/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs +++ b/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs @@ -20,7 +20,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService public async Task LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default) { - var invoice = await _invoices.Query().Include(x => x.Lines) + var invoice = await _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); if (invoice.RowVersion != expectedRowVersion) @@ -32,7 +32,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService public async Task LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default) { - var slip = await _slips.Query().Include(x => x.Lines) + var slip = await _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); if (slip.RowVersion != expectedRowVersion) diff --git a/Backend/ERPCore/Services/SalesDomainService.cs b/Backend/ERPCore/Services/SalesDomainService.cs index 8e974b1..a07c8d6 100644 --- a/Backend/ERPCore/Services/SalesDomainService.cs +++ b/Backend/ERPCore/Services/SalesDomainService.cs @@ -16,6 +16,10 @@ public sealed class SalesDomainService : ISalesDomainService private readonly IRepository _taxRates; private readonly ISalesPricingService _pricing; private readonly IRepository _salesConfig; + private readonly IRepository _batches; + private readonly IRepository _serials; + private readonly IRepository _layers; + private readonly ISerialTrackingService _serialTracking; public SalesDomainService( IRepository customers, @@ -24,8 +28,16 @@ public sealed class SalesDomainService : ISalesDomainService IRepository items, IRepository taxRates, ISalesPricingService pricing, - IRepository salesConfig) + IRepository salesConfig, + IRepository batches, + IRepository serials, + IRepository layers, + ISerialTrackingService serialTracking) { + _serialTracking = serialTracking; + _batches = batches; + _serials = serials; + _layers = layers; _customers = customers; _warehouses = warehouses; _users = users; @@ -154,6 +166,66 @@ public sealed class SalesDomainService : ISalesDomainService .Select(x => x.StockNature == StockNature.Stocked) .FirstAsync(ct); + public async Task> ValidateLineTrackingAsync( + int itemId, int warehouseId, decimal totalQty, int? batchId, IReadOnlyCollection? serialIds, + ISet documentSerials, CancellationToken ct = default) + { + var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == itemId, ct); + var ids = serialIds?.ToList() ?? new List(); + + if (batchId is not null) + { + var batch = await _batches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BatchId == batchId && b.ItemId == itemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Batch {batchId} does not belong to item {item.Sku}.", 422); + if (batch.ExpiryDate is DateOnly expiry && expiry < DateOnly.FromDateTime(DateTime.UtcNow)) + throw new DomainException(ErrorCodes.ExpiredBatchBlocked, $"Batch {batch.BatchNo} expired on {expiry:yyyy-MM-dd} and cannot be sold.", 422); + } + + // Serial numbers switched off in Product Configuration: a serial-mode item sells like + // untracked stock (no serials asked, none accepted). + if (!await _serialTracking.IsSerialTrackedAsync(item, ct)) + { + _serialTracking.RejectSerialsWhileDisabled(item, ids.Count); + if (ids.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422); + return Array.Empty(); + } + + if (ids.Count == 0) + { + // D6: stock received before serial capture has no serials — it can still be sold + // without naming any, but only while enough of it is left at this warehouse. + if (item.StockNature != StockNature.Stocked) return Array.Empty(); + var untracked = await _layers.Query().AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.SerialId == null && l.QtyRemaining > 0) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + if (untracked < totalQty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; select {totalQty} serial number(s).", 422); + return Array.Empty(); + } + + if (ids.Count != totalQty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; select exactly {totalQty} serial number(s), got {ids.Count}.", 422); + foreach (var id in ids) + if (!documentSerials.Add(id)) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is selected more than once on this document.", 422); + + // Tracked on purpose: the caller hangs these off the new line's serial rows as navigations. + var serials = await _serials.Query().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct); + foreach (var id in ids) + { + var serial = serials.FirstOrDefault(s => s.SerialId == id); + if (serial is null || serial.ItemId != itemId) + throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422); + if (serial.Status != SerialStatus.InStock || serial.WarehouseId != warehouseId) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial {serial.SerialNo} is {serial.Status}{(serial.Status == SerialStatus.InStock ? " at another warehouse" : string.Empty)} and cannot be sold from warehouse {warehouseId}.", 422); + } + return serials; + } + private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) { var computed = mode == SalesDiscountMode.Amount diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 9b4b7bb..23c73b7 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -81,7 +81,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService public async Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default) { - var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); return invoice is null ? null : new ETagged(_mapping.MapInvoice(invoice), invoice.RowVersion); } @@ -155,14 +155,14 @@ public sealed class SalesInvoiceService : ISalesInvoiceService public async Task PostAsync(int salesInvoiceId, CancellationToken ct = default) { await _posting.PostInvoiceAsync(salesInvoiceId, ct); - var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); return _mapping.MapInvoice(invoice); } public async Task CancelAsync(int salesInvoiceId, CancellationToken ct = default) { - var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + var invoice = await _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); if (invoice.Status != SalesInvoiceStatus.Draft) throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled."); @@ -188,10 +188,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422); if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct)) throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422); + var documentSerials = new HashSet(); foreach (var r in requests) { var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, r.SerialIds, documentSerials, ct); var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var unitPrice = resolved.UnitPrice; var priceSource = resolved.PriceSource; @@ -217,7 +219,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService TaxPct = taxPct, TaxAmount = calc.TaxAmount, IsFreeIssue = r.IsFreeIssue, - ParentLineId = r.ParentLineId + ParentLineId = r.ParentLineId, + BatchId = r.BatchId, + Serials = serials.Select(s => new SalesInvoiceLineSerial { SerialId = s.SerialId, Serial = s }).ToList() }); } return lines; diff --git a/Backend/ERPCore/Services/SalesMappingService.cs b/Backend/ERPCore/Services/SalesMappingService.cs index bb1d866..1aafd88 100644 --- a/Backend/ERPCore/Services/SalesMappingService.cs +++ b/Backend/ERPCore/Services/SalesMappingService.cs @@ -38,7 +38,8 @@ public sealed class SalesMappingService : ISalesMappingService invoice.Lines.Select(l => new SalesInvoiceLineDto( l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, - l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); + l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, + l.BatchId, l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList()); public SalesSlipDto MapSlip(SalesSlip slip) => new( @@ -49,5 +50,6 @@ public sealed class SalesMappingService : ISalesMappingService l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, - l.BatchId, l.SerialId)).ToList()); + l.BatchId, l.SerialId, + l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList()); } diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs index adfcd1f..4385931 100644 --- a/Backend/ERPCore/Services/SalesPostingService.cs +++ b/Backend/ERPCore/Services/SalesPostingService.cs @@ -21,6 +21,10 @@ public sealed class SalesPostingService : ISalesPostingService private readonly IRepository _bundles; private readonly IRepository _items; private readonly IRepository _serials; + private readonly IRepository _allocations; + private readonly IRepository _warranties; + private readonly IRepository _serialMovements; + private readonly ISerialTrackingService _serialTracking; private readonly IFifoCostingService _fifo; private readonly ISalesDomainService _sales; private readonly ICurrentUser _currentUser; @@ -34,6 +38,10 @@ public sealed class SalesPostingService : ISalesPostingService IRepository bundles, IRepository items, IRepository serials, + IRepository allocations, + IRepository warranties, + IRepository serialMovements, + ISerialTrackingService serialTracking, IFifoCostingService fifo, ISalesDomainService sales, ICurrentUser currentUser, @@ -46,6 +54,10 @@ public sealed class SalesPostingService : ISalesPostingService _bundles = bundles; _items = items; _serials = serials; + _allocations = allocations; + _warranties = warranties; + _serialMovements = serialMovements; + _serialTracking = serialTracking; _fifo = fifo; _sales = sales; _currentUser = currentUser; @@ -150,15 +162,17 @@ public sealed class SalesPostingService : ISalesPostingService public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default) => PostAsync( - load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct), + load: () => _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct), notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, null, null)), + getLines: x => x.Lines.Select(l => new PostingLine(l.SalesInvoiceLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, + l.BatchId, l.Serials.Select(s => s.SerialId).ToList())), setPosted: x => x.Status = SalesInvoiceStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.SalesInvoice, getDocId: x => x.SalesInvoiceId, + getCustomerId: x => x.CustomerId, ct: ct, afterConsumption: PostInvoiceRevenueAsync); @@ -208,15 +222,19 @@ public sealed class SalesPostingService : ISalesPostingService public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default) => PostAsync( - load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct), + load: () => _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct), notFoundMessage: $"Sales slip {salesSlipId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId, l.SerialId)), + // An old draft may still carry only the legacy single SerialId. + getLines: x => x.Lines.Select(l => new PostingLine(l.SalesSlipLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId, + l.Serials.Count > 0 ? l.Serials.Select(s => s.SerialId).ToList() + : l.SerialId is int single ? new List { single } : new List())), setPosted: x => x.Status = SalesSlipStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.SalesSlip, getDocId: x => x.SalesSlipId, + getCustomerId: x => x.CustomerId, ct: ct); public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default) @@ -225,11 +243,12 @@ public sealed class SalesPostingService : ISalesPostingService notFoundMessage: $"Bundle sale {bundleSaleId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, null)), + getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.BundleSaleLineId, l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, new List())), setPosted: x => x.Status = BundleSaleStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.BundleSale, getDocId: x => x.BundleSaleId, + getCustomerId: x => x.CustomerId, ct: ct); private async Task PostAsync( @@ -242,6 +261,7 @@ public sealed class SalesPostingService : ISalesPostingService Action setUpdated, string sourceDocType, Func getDocId, + Func getCustomerId, CancellationToken ct, /// Run inside the same transaction, after consumption and status flip, with the /// total COGS this call just consumed — the invoice-only revenue-recognition hook. @@ -262,27 +282,65 @@ public sealed class SalesPostingService : ISalesPostingService await _uow.ExecuteInTransactionAsync(async token => { var totalCogs = 0m; + var now = DateTime.UtcNow; + var docId = getDocId(doc); foreach (var line in getLines(doc)) { if (line.Qty <= 0) continue; - if (!await _sales.IsStockedItemAsync(line.ItemId, token)) + var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token); + if (item.StockNature != StockNature.Stocked) continue; + var serials = await LoadSerialsForPostingAsync(item, line, token); + // Line quantities are already a count of the item's base UOM — sales documents // carry no unit of their own — so this is the quantity FIFO consumes verbatim. - var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall); - var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); - totalCogs += cost * line.Qty; - await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId, - Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token); + // Serial lines consume exactly their serials' layers and can never go short; batch + // lines with no pinned batch are picked FEFO (docs/ITEM_TRACKING_PLAN.md Phase 3). + var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, + // With serial numbers switched off, a serial-mode item is plain stock and may go short. + allowShortfall && serials.Count == 0 && !await _serialTracking.IsSerialTrackedAsync(item, token), + serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null); + totalCogs += consumed.Sum(x => x.Qty * x.UnitCost); - // ConsumeAsync has no serial parameter — closing the loop on a serial-pinned - // line means flipping the serial's own status once its stock is actually gone. - if (line.SerialId.HasValue) + // One ledger row per batch actually issued (usually one) — a FEFO pick can span + // several, and each ledger row must carry the batch it moved. + foreach (var group in consumed.GroupBy(x => x.BatchId)) { - var serial = await _serials.Query().FirstOrDefaultAsync(s => s.SerialId == line.SerialId, token); - if (serial is not null) serial.Status = "Sold"; + var qty = group.Sum(x => x.Qty); + var cost = qty == 0 ? 0m : group.Sum(x => x.Qty * x.UnitCost) / qty; + await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, group.Key, + serials.Count == 1 ? serials[0].SerialId : null, _currentUser.AuditUserId, + Direction.Out, qty, cost, 0m, sourceDocType, docId, now, token); } + + foreach (var segment in consumed) + { + await _allocations.AddAsync(new SalesLineAllocation + { + SourceDocType = sourceDocType, + SourceDocId = docId, + SourceLineId = line.LineId, + ItemId = line.ItemId, + WarehouseId = line.WarehouseId, + BatchId = segment.BatchId, + SerialId = segment.SerialId, + Qty = segment.Qty, + UnitCost = segment.UnitCost, + CreatedAt = now + }, token); + } + + foreach (var serial in serials) + { + serial.Status = SerialStatus.Sold; + serial.WarehouseId = null; + serial.BinId = null; + serial.UpdatedAt = now; + await _serialMovements.AddAsync(SerialMovement.Of(serial, line.WarehouseId, sourceDocType, docId, _currentUser.AuditUserId, now), token); + } + + await RegisterWarrantyAsync(item, line, serials, getCustomerId(doc), sourceDocType, docId, now, token); } setPosted(doc); @@ -292,5 +350,78 @@ public sealed class SalesPostingService : ISalesPostingService }, ct); } - private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty, int? BatchId, int? SerialId); + /// + /// Re-checks a line's chosen serials at post time — the draft did not reserve them, so another + /// document may have sold one since — and returns them tracked for the status flip. A + /// serial-tracked line with none chosen may only post from pre-tracking stock (D6); the + /// consume call enforces that by never touching serial layers without serial ids. A bundle + /// line has nowhere to choose serials, so a serial item in a bundle surfaces the same way. + /// + private async Task> LoadSerialsForPostingAsync(Item item, PostingLine line, CancellationToken ct) + { + if (line.SerialIds.Count == 0) + { + // Same D6 rule the draft was checked against (enough serial-less stock left here), + // re-run because that stock may have been issued since. + if (item.TrackingMode == TrackingMode.Serial) + await _sales.ValidateLineTrackingAsync(item.ItemId, line.WarehouseId, line.Qty, null, null, new HashSet(), ct); + return Array.Empty(); + } + + if (line.SerialIds.Count != line.Qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} needs exactly {line.Qty} serial number(s); {line.SerialIds.Count} are selected.", 422); + + var serials = await _serials.Query().Where(s => line.SerialIds.Contains(s.SerialId)).ToListAsync(ct); + var unavailable = serials.Where(s => s.ItemId != item.ItemId || s.Status != SerialStatus.InStock || s.WarehouseId != line.WarehouseId) + .Select(s => s.SerialNo).ToList(); + if (unavailable.Count > 0 || serials.Count != line.SerialIds.Count) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial(s) {string.Join(", ", unavailable)} of item {item.Sku} are no longer in stock at this warehouse.", 409); + return serials; + } + + /// + /// Starts the customer's warranty at posting (docs/ITEM_TRACKING_PLAN.md Phase 5): one + /// registration per serial, or one per line for a warranty item that is not serial-tracked. + /// Coverage is the period snapshotted on the serial at receipt, else the item's own. + /// + private async Task RegisterWarrantyAsync( + Item item, PostingLine line, IReadOnlyList serials, int customerId, + string sourceDocType, int docId, DateTime now, CancellationToken ct) + { + if (item.Warranty != Warranty.Warranty) return; + var start = DateOnly.FromDateTime(now); + + WarrantyRegistration Build(int? serialId, decimal qty, int months) => new() + { + ItemId = item.ItemId, + SerialId = serialId, + CustomerId = customerId, + SourceDocType = sourceDocType, + SourceDocId = docId, + SourceLineId = line.LineId, + Qty = qty, + StartDate = start, + PeriodMonths = months, + EndDate = start.AddMonths(months), + Status = WarrantyStatus.Active, + CreatedAt = now + }; + + if (serials.Count > 0) + { + foreach (var serial in serials) + if ((serial.WarrantyPeriodMonths ?? item.WarrantyPeriodMonths) is int months) + await _warranties.AddAsync(Build(serial.SerialId, 1m, months), ct); + return; + } + + if (item.WarrantyPeriodMonths is int itemMonths) + await _warranties.AddAsync(Build(null, line.Qty, itemMonths), ct); + } + + private sealed record PostingLine( + int LineId, int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty, + int? BatchId, IReadOnlyList SerialIds); } diff --git a/Backend/ERPCore/Services/SalesReturnService.cs b/Backend/ERPCore/Services/SalesReturnService.cs index d648a63..2aba1d0 100644 --- a/Backend/ERPCore/Services/SalesReturnService.cs +++ b/Backend/ERPCore/Services/SalesReturnService.cs @@ -17,15 +17,24 @@ namespace ERPCore.Services; /// /// Sales-return service. Auto-posts with a mandatory Return reason code and /// generates an inbound stock movement via the shared -/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW +/// (positive delta — creates an inbound FIFO layer). Single UoW /// transaction, mirroring with the direction /// reversed. Also posts a real GL journal entry reversing Inventory/COGS for the /// stock movement's value (see for why revenue/tax /// aren't part of it) — reuses the Sales Day End accounts since that's the module /// whose COGS this reverses. +/// +/// Item tracking (docs/ITEM_TRACKING_PLAN.md Phase 6): a return against an invoice line restocks +/// into the batch(es)/serials that line actually issued (), at +/// the cost they were issued at; returned serials go back to InStock — or Damaged — at the return +/// warehouse, and their warranty is voided. +/// /// public sealed class SalesReturnService : ISalesReturnService { + // Return reasons whose goods are not resaleable (see DataSeeder.StandardReasonCodes). + private static readonly HashSet DamagedReasonCodes = new(StringComparer.OrdinalIgnoreCase) { "DEF", "QREJ" }; + private readonly IRepository _returns; private readonly IRepository _customers; private readonly IRepository _warehouses; @@ -34,6 +43,12 @@ public sealed class SalesReturnService : ISalesReturnService private readonly IRepository _salesInvoiceLines; private readonly IRepository _returnLines; private readonly IRepository _ledger; + private readonly IRepository _batches; + private readonly IRepository _serials; + private readonly IRepository _allocations; + private readonly IRepository _warranties; + private readonly IRepository _serialMovements; + private readonly ISerialTrackingService _serialTracking; private readonly IStockMutator _mutator; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -44,7 +59,9 @@ public sealed class SalesReturnService : ISalesReturnService public SalesReturnService( IRepository returns, IRepository customers, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository salesInvoiceLines, - IRepository returnLines, IRepository ledger, IStockMutator mutator, + IRepository returnLines, IRepository ledger, + IRepository batches, IRepository serials, IRepository allocations, + IRepository warranties, IRepository serialMovements, ISerialTrackingService serialTracking, IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { @@ -56,6 +73,12 @@ public sealed class SalesReturnService : ISalesReturnService _salesInvoiceLines = salesInvoiceLines; _returnLines = returnLines; _ledger = ledger; + _batches = batches; + _serials = serials; + _allocations = allocations; + _warranties = warranties; + _serialMovements = serialMovements; + _serialTracking = serialTracking; _mutator = mutator; _numbers = numbers; _currentUser = currentUser; @@ -91,7 +114,7 @@ public sealed class SalesReturnService : ISalesReturnService public async Task GetAsync(int returnId, CancellationToken ct = default) { var ret = await _returns.Query().AsNoTracking() - .Include(r => r.Lines) + .Include(r => r.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(r => r.ReturnId == returnId, ct); if (ret is null) return null; @@ -119,15 +142,19 @@ public sealed class SalesReturnService : ISalesReturnService ?? 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); + var damaged = request.MarkDamaged ?? DamagedReasonCodes.Contains(reason.Code); // Original sold qty is never mutated — "remaining returnable" is computed from // return history instead, so the invoice keeps recording what was actually sold. var pendingByInvoiceLine = new Dictionary(); + var requestSerials = new HashSet(); + var resolved = new List(); 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 item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + var allocations = new List(); if (line.SalesInvoiceLineId is not null) { var invoiceLineId = line.SalesInvoiceLineId.Value; @@ -145,11 +172,20 @@ public sealed class SalesReturnService : ISalesReturnService if (line.Qty > remaining) throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422); pendingByInvoiceLine[invoiceLineId] = pending + line.Qty; + + // Tracked: QtyReturned is updated below, inside the transaction. + allocations = await _allocations.Query() + .Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceLineId == invoiceLineId) + .OrderBy(a => a.SalesLineAllocationId) + .ToListAsync(ct); } + + resolved.Add(await ResolveTrackingAsync(item, line, allocations, requestSerials, ct)); } var now = DateTime.UtcNow; - var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, l.Qty)).ToList(); + var deltas = resolved.Select(r => new StockDelta( + r.Input.ItemId, null, r.BatchId, r.Input.Qty, r.Serials.Select(s => s.SerialId).ToList(), r.UnitCost)).ToList(); var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token => { @@ -163,11 +199,13 @@ public sealed class SalesReturnService : ISalesReturnService Status = ReturnStatus.Posted, CreatedBy = _currentUser.AuditUserId, CreatedAt = now, - Lines = request.Lines.Select(l => new SalesReturnLine + Lines = resolved.Select(r => new SalesReturnLine { - SalesInvoiceLineId = l.SalesInvoiceLineId, - ItemId = l.ItemId, - Qty = l.Qty + SalesInvoiceLineId = r.Input.SalesInvoiceLineId, + ItemId = r.Input.ItemId, + Qty = r.Input.Qty, + BatchId = r.BatchId, + Serials = r.Serials.Select(s => new SalesReturnLineSerial { SerialId = s.SerialId, Serial = s }).ToList() }).ToList() }; await _returns.AddAsync(ret, token); @@ -175,6 +213,13 @@ public sealed class SalesReturnService : ISalesReturnService var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token); + foreach (var r in resolved) + { + await ApplyReturnEffectsAsync(r, request.WarehouseId, damaged, now, token); + foreach (var serial in r.Serials) + await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.SalesReturn, ret.ReturnId, _currentUser.AuditUserId, now), token); + } + var totalValue = refs.Sum(r => r.Value); if (totalValue > 0) { @@ -218,17 +263,182 @@ public sealed class SalesReturnService : ISalesReturnService .Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) }) .ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct); + var allocations = await _allocations.Query().AsNoTracking() + .Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceDocId == salesInvoiceId) + .Select(a => new + { + a.SourceLineId, + Dto = new SalesLineAllocationDto( + a.BatchId, a.Batch != null ? a.Batch.BatchNo : null, a.Batch != null ? a.Batch.ExpiryDate : null, + a.SerialId, a.Serial != null ? a.Serial.SerialNo : null, a.Qty, a.QtyReturned) + }) + .ToListAsync(ct); + return lines .Select(l => new SalesInvoiceLineRemainingDto( l.SalesInvoiceLineId, - l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m))) + l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m), + allocations.Where(a => a.SourceLineId == l.SalesInvoiceLineId).Select(a => a.Dto).ToList())) .ToList(); } + /// + /// Works out which batch/serials one return line restocks, and at what cost. Against an + /// invoice line, both must come from what that line issued; free-standing, a serial item + /// must name serials that are Sold and a batch item must name its batch. Legacy invoice lines + /// posted before allocations existed (no allocation rows) return untracked, as before. + /// + private async Task ResolveTrackingAsync( + Item item, CreateSalesReturnLineInput line, List allocations, + HashSet requestSerials, CancellationToken ct) + { + var serialIds = line.SerialIds?.ToList() ?? new List(); + var batchId = line.BatchId; + var serials = new List(); + var hasAllocations = allocations.Count > 0; + + // Serial numbers switched off: a serial-mode item comes back as untracked stock. + var serialTracked = await _serialTracking.IsSerialTrackedAsync(item, ct); + if (!serialTracked) _serialTracking.RejectSerialsWhileDisabled(item, serialIds.Count); + if (!serialTracked && serialIds.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422); + + if (serialTracked) + { + var lineSoldSerials = hasAllocations && allocations.Any(a => a.SerialId != null); + if (serialIds.Count == 0) + { + if (line.SalesInvoiceLineId is null || lineSoldSerials) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; select the {line.Qty} returned serial number(s).", 422); + } + else + { + if (serialIds.Count != line.Qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku}: returning {line.Qty} unit(s) needs exactly that many serials, got {serialIds.Count}.", 422); + foreach (var id in serialIds) + if (!requestSerials.Add(id)) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is returned more than once on this document.", 422); + + serials = await _serials.Query().Where(s => serialIds.Contains(s.SerialId)).ToListAsync(ct); + foreach (var id in serialIds) + { + var serial = serials.FirstOrDefault(s => s.SerialId == id); + if (serial is null || serial.ItemId != item.ItemId) + throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422); + if (serial.Status != SerialStatus.Sold) + throw new DomainException(ErrorCodes.SerialNotAvailable, $"Serial {serial.SerialNo} is {serial.Status}, not Sold — it cannot be returned.", 422); + if (line.SalesInvoiceLineId is not null + && !allocations.Any(a => a.SerialId == id && a.QtyReturned < a.Qty)) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial {serial.SerialNo} was not sold on sales invoice line {line.SalesInvoiceLineId} (or is already returned).", 422); + } + // A serial goes back into the batch it was sold from. + batchId ??= allocations.FirstOrDefault(a => a.SerialId == serialIds[0])?.BatchId; + } + } + else if (hasAllocations) + { + var issuedBatches = allocations.Where(a => a.QtyReturned < a.Qty).Select(a => a.BatchId).Distinct().ToList(); + if (batchId is null) + { + if (issuedBatches.Count > 1) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Sales invoice line {line.SalesInvoiceLineId} issued several batches of item {item.Sku}; choose the batch being returned.", 422); + batchId = issuedBatches.FirstOrDefault(); + } + else if (!issuedBatches.Contains(batchId)) + { + throw new DomainException(ErrorCodes.Validation, + $"Batch {batchId} was not issued on sales invoice line {line.SalesInvoiceLineId}.", 422); + } + } + else if (item.TrackingMode == TrackingMode.Batch && batchId is null && line.SalesInvoiceLineId is null) + { + throw new DomainException(ErrorCodes.TrackingRequired, $"Item {item.Sku} is batch-tracked; choose the batch being returned.", 422); + } + + if (batchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == batchId && b.ItemId == item.ItemId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Batch {batchId} does not belong to item {item.Sku}.", 422); + + // Restock at what the goods were issued at, so the COGS reversal mirrors the sale. + var costed = allocations.Where(a => serials.Count > 0 + ? a.SerialId != null && serialIds.Contains(a.SerialId.Value) + : a.BatchId == batchId) + .ToList(); + decimal? unitCost = costed.Sum(a => a.Qty) > 0 + ? Math.Round(costed.Sum(a => a.Qty * a.UnitCost) / costed.Sum(a => a.Qty), 6, MidpointRounding.AwayFromZero) + : null; + + return new ResolvedReturnLine(line, item, batchId, serials, allocations, unitCost); + } + + /// + /// After the stock is back: serials return to stock (or Damaged) at the return warehouse, the + /// issuing allocations record what came back, and the matching warranty is voided — per + /// serial, or reduced by the returned quantity for a non-serial warranty line. + /// + private async Task ApplyReturnEffectsAsync(ResolvedReturnLine r, int warehouseId, bool damaged, DateTime now, CancellationToken ct) + { + foreach (var serial in r.Serials) + { + serial.Status = damaged ? SerialStatus.Damaged : SerialStatus.InStock; + serial.WarehouseId = warehouseId; + serial.BinId = null; + serial.UpdatedAt = now; + } + + if (r.Serials.Count > 0) + { + var ids = r.Serials.Select(s => s.SerialId).ToHashSet(); + foreach (var a in r.Allocations.Where(a => a.SerialId != null && ids.Contains(a.SerialId.Value))) + a.QtyReturned = a.Qty; + + var registrations = await _warranties.Query() + .Where(w => w.SerialId != null && ids.Contains(w.SerialId.Value) && w.Status == WarrantyStatus.Active) + .ToListAsync(ct); + foreach (var w in registrations) + { + w.Status = WarrantyStatus.Voided; + w.UpdatedAt = now; + } + return; + } + + var left = r.Input.Qty; + foreach (var a in r.Allocations.Where(a => a.BatchId == r.BatchId && a.QtyReturned < a.Qty)) + { + if (left <= 0) break; + var take = Math.Min(left, a.Qty - a.QtyReturned); + a.QtyReturned += take; + left -= take; + } + + if (r.Item.Warranty == Warranty.Warranty && r.Input.SalesInvoiceLineId is int invoiceLineId) + { + var registration = await _warranties.Query() + .FirstOrDefaultAsync(w => w.SourceDocType == DocumentTypes.SalesInvoice && w.SourceLineId == invoiceLineId + && w.SerialId == null && w.Status == WarrantyStatus.Active, ct); + if (registration is not null) + { + registration.Qty = Math.Max(0m, registration.Qty - r.Input.Qty); + if (registration.Qty == 0m) registration.Status = WarrantyStatus.Voided; + registration.UpdatedAt = now; + } + } + } + private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList ledgerRefs) => new( r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt, r.GlJournalNo, r.GlPostedAt, r.Lines.OrderBy(l => l.ReturnLineId) - .Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(), + .Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty, l.BatchId, + l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())) + .ToList(), ledgerRefs); + + private sealed record ResolvedReturnLine( + CreateSalesReturnLineInput Input, Item Item, int? BatchId, List Serials, + List Allocations, decimal? UnitCost); } diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs index bdafaad..fb8802e 100644 --- a/Backend/ERPCore/Services/SalesSlipService.cs +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -79,7 +79,7 @@ public sealed class SalesSlipService : ISalesSlipService public async Task?> GetAsync(int salesSlipId, CancellationToken ct = default) { - var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); return slip is null ? null : new ETagged(_mapping.MapSlip(slip), slip.RowVersion); } @@ -104,7 +104,7 @@ public sealed class SalesSlipService : ISalesSlipService public async Task?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default) { - var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); return slip is null ? null : new ETagged(MapFreeIssue(slip), slip.RowVersion); } @@ -180,7 +180,7 @@ public sealed class SalesSlipService : ISalesSlipService public async Task PostAsync(int salesSlipId, CancellationToken ct = default) { await _posting.PostSlipAsync(salesSlipId, ct); - var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstAsync(x => x.SalesSlipId == salesSlipId, ct); await _notifications.RaiseAsync("sale.posted", "Sale Posted", $"Sales slip {slip.SlipNo} was posted.", nameof(SalesSlip), slip.SalesSlipId, ct); return _mapping.MapSlip(slip); @@ -188,7 +188,7 @@ public sealed class SalesSlipService : ISalesSlipService public async Task CancelAsync(int salesSlipId, CancellationToken ct = default) { - var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + var slip = await _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); if (slip.Status != SalesSlipStatus.Draft) throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled."); @@ -201,10 +201,15 @@ public sealed class SalesSlipService : ISalesSlipService private async Task> BuildLinesAsync(int headerWarehouseId, List requests, CancellationToken ct) { var lines = new List(); + var documentSerials = new HashSet(); foreach (var r in requests) { var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + // The legacy single SerialId is a one-element SerialIds. + var serialIds = r.SerialIds is { Count: > 0 } ? r.SerialIds + : r.SerialId is int single ? new List { single } : null; + var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, serialIds, documentSerials, ct); var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var unitPrice = resolved.UnitPrice; var priceSource = resolved.PriceSource; @@ -212,11 +217,6 @@ public sealed class SalesSlipService : ISalesSlipService var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, taxPct, r.IsFreeIssue); - if (r.BatchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == r.BatchId && b.ItemId == r.ItemId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Batch {r.BatchId} does not belong to item {r.ItemId}.", 422); - if (r.SerialId is not null && !await _serials.Query().AnyAsync(s => s.SerialId == r.SerialId && s.ItemId == r.ItemId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Serial {r.SerialId} does not belong to item {r.ItemId}.", 422); - lines.Add(new SalesSlipLine { ItemId = r.ItemId, @@ -225,7 +225,8 @@ public sealed class SalesSlipService : ISalesSlipService FreeQty = r.FreeQty, WarehouseId = r.WarehouseId, BatchId = r.BatchId, - SerialId = r.SerialId, + SerialId = serials.Count == 1 ? serials[0].SerialId : null, + Serials = serials.Select(s => new SalesSlipLineSerial { SerialId = s.SerialId, Serial = s }).ToList(), UnitPrice = unitPrice, BaseCost = unitPrice, PriceSource = priceSource, @@ -319,7 +320,7 @@ public sealed class SalesSlipService : ISalesSlipService x.CreatedAt, x.UpdatedAt, summary, - x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, l.BatchId, l.SerialId)).ToList()); + _mapping.MapSlip(x).Lines); } private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x); diff --git a/Backend/ERPCore/Services/Stock/FifoCostingService.cs b/Backend/ERPCore/Services/Stock/FifoCostingService.cs index d82f2a0..eeeb91e 100644 --- a/Backend/ERPCore/Services/Stock/FifoCostingService.cs +++ b/Backend/ERPCore/Services/Stock/FifoCostingService.cs @@ -63,11 +63,28 @@ public sealed class FifoCostingService : IFifoCostingService public async Task> ConsumeAsync( int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default, - bool allowShortfall = false) + bool allowShortfall = false, IReadOnlyCollection? serialIds = null) { var now = DateTime.UtcNow; var today = DateOnly.FromDateTime(now); + if (serialIds is not null) + { + if (serialIds.Distinct().Count() != serialIds.Count || serialIds.Count != qtyBase) + throw new DomainException(ErrorCodes.Validation, + $"Issuing {qtyBase} of item {itemId} requires exactly {qtyBase} distinct serial(s), got {serialIds.Count}.", 422); + allowShortfall = false; // a serial cannot be sold before it exists + } + + // Serial layers are held back from an issue that names no serials — but only while the + // item is serial-tracked and Product Configuration has serial numbers switched on. With + // the switch off (or the item moved off Serial), they are ordinary stock: issued + // oldest-first, and their serial records closed out below. + var holdSerialLayers = serialIds is null + && await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId && i.TrackingMode == TrackingMode.Serial, ct) + && (await _db.ProductConfig.AsNoTracking().Where(c => c.ConfigId == ProductConfig.SingletonId) + .Select(c => (bool?)c.SerialNumbersEnabled).FirstOrDefaultAsync(ct) ?? true); + if (batchId is not null) { var expiry = await _db.Batches.AsNoTracking() @@ -107,6 +124,18 @@ public sealed class FifoCostingService : IFifoCostingService """) .ToListAsync(ct); + // A serial item holds one layer per unit, so don't lock them all: keep only the named + // serials' layers (or, with none named, the untracked ones) plus shortfall layers. + if (candidateIds.Count > 0 && (serialIds is not null || holdSerialLayers)) + { + var ids = candidateIds; + candidateIds = await _db.StockLayers.AsNoTracking() + .Where(l => ids.Contains(l.LayerId) && (l.QtyRemaining < 0 || (serialIds == null + ? l.SerialId == null + : l.SerialId != null && serialIds.Contains(l.SerialId.Value)))) + .Select(l => l.LayerId).ToListAsync(ct); + } + // `IN ()` is a syntax error, so short-circuit to the normal insufficient-stock path. List locked; if (candidateIds.Count == 0) @@ -167,11 +196,13 @@ public sealed class FifoCostingService : IFifoCostingService // Stock sold before it arrived is owed first: net shortfall layers off against issuable // stock (oldest-first) before anything new is issued, so it can't be sold twice. The // sale already posted its ledger Out, so settling writes no ledger entry of its own. + // Serial layers are excluded from settlement: netting a shortfall against one would empty + // a serial's layer while the serial itself still reads InStock. var owed = locked.Where(l => l.QtyRemaining < 0).ToList(); var open = locked.Where(l => l.QtyRemaining > 0).ToList(); foreach (var shortfall in owed) { - foreach (var layer in open) + foreach (var layer in open.Where(l => l.SerialId == null)) { if (shortfall.QtyRemaining >= 0) break; var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining); @@ -182,7 +213,38 @@ public sealed class FifoCostingService : IFifoCostingService // Only now apply the caller's batch filter: settlement above is deliberately // batch-agnostic (a shortfall layer has no batch), but the issue itself is not. - var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList(); + // Serial filter: named serials only, or — with none named — no serial layer while they + // are held back (see holdSerialLayers). + var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId) + && (serialIds == null + ? !holdSerialLayers || l.SerialId == null + : l.SerialId != null && serialIds.Contains(l.SerialId.Value))) + .ToList(); + + if (serialIds is not null) + { + var found = layers.Select(l => l.SerialId!.Value).ToHashSet(); + var missing = serialIds.Where(id => !found.Contains(id)).ToList(); + if (missing.Count > 0) + { + var numbers = await _db.Serials.AsNoTracking().Where(s => missing.Contains(s.SerialId)) + .Select(s => s.SerialNo).ToListAsync(ct); + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial(s) {string.Join(", ", numbers.Count > 0 ? numbers : missing.Select(m => m.ToString()))} are not available to issue from warehouse {warehouseId} (not in stock here, on hold, or expired).", 409); + } + } + + // FEFO then FIFO: soonest-expiring batch first; unbatched/no-expiry stock after. + var layerBatchIds = layers.Where(l => l.BatchId != null).Select(l => l.BatchId!.Value).Distinct().ToList(); + if (layerBatchIds.Count > 0) + { + var expiries = await _db.Batches.AsNoTracking().Where(b => layerBatchIds.Contains(b.BatchId)) + .ToDictionaryAsync(b => b.BatchId, b => b.ExpiryDate, ct); + layers = layers + .OrderBy(l => l.BatchId is int b && expiries.TryGetValue(b, out var e) && e is not null ? e.Value : DateOnly.MaxValue) + .ThenBy(l => l.ReceiptDate).ThenBy(l => l.LayerId) + .ToList(); + } var available = layers.Sum(l => l.QtyRemaining); if (available < qtyBase && !allowShortfall) @@ -190,6 +252,7 @@ public sealed class FifoCostingService : IFifoCostingService var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId) + && (serialIds == null ? l.SerialId == null : serialIds.Contains(l.SerialId ?? 0)) && l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected), ct); if (heldExists) throw new DomainException(ErrorCodes.OnHoldNotIssuable, @@ -207,7 +270,27 @@ public sealed class FifoCostingService : IFifoCostingService var take = Math.Min(remaining, layer.QtyRemaining); layer.QtyRemaining -= take; remaining -= take; - segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost)); + segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost, layer.BatchId, layer.SerialId)); + } + + // Serial units issued without being named (serial numbers switched off): close their + // records so the register doesn't keep showing them in stock. + if (serialIds is null) + { + var issuedSerialIds = layers.Where(l => l.SerialId != null && l.QtyRemaining == 0 + && segments.Any(s => s.LayerId == l.LayerId)) + .Select(l => l.SerialId!.Value).ToList(); + if (issuedSerialIds.Count > 0) + { + var issued = await _db.Serials.Where(s => issuedSerialIds.Contains(s.SerialId)).ToListAsync(ct); + foreach (var serial in issued) + { + serial.Status = SerialStatus.IssuedUntracked; + serial.WarehouseId = null; + serial.BinId = null; + serial.UpdatedAt = now; + } + } } // Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes @@ -232,6 +315,32 @@ public sealed class FifoCostingService : IFifoCostingService return segments; } + public async Task> SuggestBatchesAsync( + int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var open = await _db.StockLayers.AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && l.SerialId == null + && (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available) + && (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today)) + .Select(l => new { l.BatchId, BatchNo = l.Batch != null ? l.Batch.BatchNo : null, Expiry = l.Batch != null ? l.Batch.ExpiryDate : null, l.ReceiptDate, l.LayerId, l.QtyRemaining }) + .ToListAsync(ct); + + var result = new List(); + var remaining = qtyBase; + foreach (var group in open + .GroupBy(l => new { l.BatchId, l.BatchNo, l.Expiry }) + .OrderBy(g => g.Key.Expiry ?? DateOnly.MaxValue) + .ThenBy(g => g.Min(l => l.ReceiptDate))) + { + if (remaining <= 0) break; + var take = Math.Min(remaining, group.Sum(l => l.QtyRemaining)); + result.Add(new BatchAllocation(group.Key.BatchId, group.Key.BatchNo, group.Key.Expiry, take)); + remaining -= take; + } + return result; + } + // Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item). private async Task LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct) { diff --git a/Backend/ERPCore/Services/Stock/ItemTrackingService.cs b/Backend/ERPCore/Services/Stock/ItemTrackingService.cs new file mode 100644 index 0000000..53f7373 --- /dev/null +++ b/Backend/ERPCore/Services/Stock/ItemTrackingService.cs @@ -0,0 +1,263 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; +using ERPCore.Infra.Persistence; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Stock; + +/// +public sealed class ItemTrackingService : IItemTrackingService +{ + private readonly ErpDbContext _db; + private readonly IFifoCostingService _fifo; + + public ItemTrackingService(ErpDbContext db, IFifoCostingService fifo) + { + _db = db; + _fifo = fifo; + } + + private static DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow); + + public const int MaxGeneratedSerials = 1000; + + public async Task> NextSerialNumbersAsync(int itemId, string prefix, int count, CancellationToken ct = default) + { + prefix = prefix.Trim(); + if (count < 1 || count > MaxGeneratedSerials) + throw new DomainException(ErrorCodes.Validation, $"Generate between 1 and {MaxGeneratedSerials} serial numbers at a time.", 422); + if (prefix.Length > 80) + throw new DomainException(ErrorCodes.Validation, "The serial prefix cannot be longer than 80 characters.", 422); + if (!await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId, ct)) + throw new NotFoundException($"Item {itemId} was not found."); + + // Everything already taken under this prefix: received serials of the item, plus numbers + // sitting on draft GRN lines (they become serials when the GRN is confirmed). + var used = await _db.Serials.AsNoTracking() + .Where(s => s.ItemId == itemId && s.SerialNo.StartsWith(prefix)) + .Select(s => s.SerialNo) + .Concat(_db.GrnLineSerials.AsNoTracking() + .Where(s => s.GrnLine!.ItemId == itemId && s.SerialId == null && s.SerialNo.StartsWith(prefix)) + .Select(s => s.SerialNo)) + .ToListAsync(ct); + + // Highest numeric tail after the prefix ("PH-0041" → 41); width follows the widest seen. + long max = 0; + var width = 4; + foreach (var number in used) + { + var tail = number[prefix.Length..]; + if (tail.Length == 0 || tail.Length > 18 || !tail.All(char.IsAsciiDigit)) continue; + max = Math.Max(max, long.Parse(tail)); + width = Math.Max(width, tail.Length); + } + + var taken = used.ToHashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(count); + for (var n = max + 1; result.Count < count; n++) + { + var candidate = prefix + n.ToString().PadLeft(width, '0'); + if (candidate.Length > 100) + throw new DomainException(ErrorCodes.Validation, "Generated serial numbers would exceed 100 characters — use a shorter prefix.", 422); + if (taken.Add(candidate)) result.Add(candidate); + } + return result; + } + + public async Task> GetAvailableSerialsAsync( + int itemId, int warehouseId, string? q, int take, CancellationToken ct = default) + { + var today = Today; + // Issuable = InStock here AND its own layer is open, not on hold, not in an expired batch — + // the same test posting applies, so the picker never offers a serial posting would refuse. + var query = _db.StockLayers.AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.SerialId != null && l.QtyRemaining > 0 + && l.Serial!.Status == SerialStatus.InStock && l.Serial.WarehouseId == warehouseId + && (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available) + && (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today)); + if (!string.IsNullOrWhiteSpace(q)) + { + var term = q.Trim(); + query = query.Where(l => EF.Functions.Like(l.Serial!.SerialNo, $"%{term}%") || l.Serial.Barcode == term); + } + + return await query + .OrderBy(l => l.Serial!.SerialNo) + .Take(Math.Clamp(take, 1, 500)) + .Select(l => new AvailableSerialDto(l.SerialId!.Value, l.Serial!.SerialNo, l.BatchId, + l.Batch != null ? l.Batch.BatchNo : null, l.Serial.Status)) + .ToListAsync(ct); + } + + public async Task> GetAvailableBatchesAsync(int itemId, int warehouseId, CancellationToken ct = default) + { + var today = Today; + var rows = await _db.StockLayers.AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && l.SerialId == null + && (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available) + && (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today)) + .GroupBy(l => new { l.BatchId, BatchNo = l.Batch != null ? l.Batch.BatchNo : null, Expiry = l.Batch != null ? l.Batch.ExpiryDate : null }) + .Select(g => new { g.Key.BatchId, g.Key.BatchNo, g.Key.Expiry, Qty = g.Sum(l => l.QtyRemaining) }) + .ToListAsync(ct); + + return rows + .OrderBy(r => r.Expiry ?? DateOnly.MaxValue).ThenBy(r => r.BatchNo) + .Select(r => new AvailableBatchDto(r.BatchId, r.BatchNo, r.Expiry, r.Qty, + r.Expiry is DateOnly e ? e.DayNumber - today.DayNumber : null)) + .ToList(); + } + + public async Task> SuggestBatchesAsync(int itemId, int warehouseId, decimal qty, CancellationToken ct = default) + => (await _fifo.SuggestBatchesAsync(itemId, warehouseId, qty, ct)) + .Select(a => new BatchSuggestionDto(a.BatchId, a.BatchNo, a.ExpiryDate, a.Qty)).ToList(); + + public async Task> ListSerialsAsync( + PageQuery query, int? itemId, int? warehouseId, SerialStatus? status, CancellationToken ct = default) + { + var q = _db.Serials.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(s => EF.Functions.Like(s.SerialNo, $"%{term}%") || s.Barcode == term + || EF.Functions.Like(s.Item!.Sku, $"%{term}%") || EF.Functions.Like(s.Item.Name, $"%{term}%")); + } + if (itemId is not null) q = q.Where(s => s.ItemId == itemId); + if (warehouseId is not null) q = q.Where(s => s.WarehouseId == warehouseId); + if (status is not null) q = q.Where(s => s.Status == status); + + var total = await q.CountAsync(ct); + var rows = await ProjectSerials(q.OrderByDescending(s => s.SerialId).Skip(query.Skip).Take(query.PageSize)).ToListAsync(ct); + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetSerialAsync(int serialId, CancellationToken ct = default) + { + var serial = await ProjectSerials(_db.Serials.AsNoTracking().Where(s => s.SerialId == serialId)).FirstOrDefaultAsync(ct); + return serial is null ? null : await BuildDetailAsync(serial, ct); + } + + public async Task> FindSerialAsync(string serialNo, CancellationToken ct = default) + { + var term = serialNo.Trim(); + var serials = await ProjectSerials(_db.Serials.AsNoTracking().Where(s => s.SerialNo == term || s.Barcode == term)) + .Take(20).ToListAsync(ct); + var result = new List(); + foreach (var s in serials) result.Add(await BuildDetailAsync(s, ct)); + return result; + } + + public async Task> ListWarrantiesAsync( + PageQuery query, int? customerId, int? itemId, string? serialNo, WarrantyStatus? status, + int? expiringWithinDays, bool? expired, CancellationToken ct = default) + { + var today = Today; + var q = _db.WarrantyRegistrations.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(w => (w.Serial != null && EF.Functions.Like(w.Serial.SerialNo, $"%{term}%")) + || EF.Functions.Like(w.Item!.Sku, $"%{term}%") || EF.Functions.Like(w.Item.Name, $"%{term}%")); + } + if (customerId is not null) q = q.Where(w => w.CustomerId == customerId); + if (itemId is not null) q = q.Where(w => w.ItemId == itemId); + if (!string.IsNullOrWhiteSpace(serialNo)) + { + var term = serialNo.Trim(); + q = q.Where(w => w.Serial != null && (w.Serial.SerialNo == term || w.Serial.Barcode == term)); + } + if (status is not null) q = q.Where(w => w.Status == status); + if (expiringWithinDays is int days) + { + var until = today.AddDays(days); + q = q.Where(w => w.Status == WarrantyStatus.Active && w.EndDate >= today && w.EndDate <= until); + } + if (expired == true) q = q.Where(w => w.EndDate < today); + if (expired == false) q = q.Where(w => w.EndDate >= today); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(w => w.EndDate).ThenBy(w => w.WarrantyRegistrationId) + .Skip(query.Skip).Take(query.PageSize) + .Select(WarrantyProjection()) + .ToListAsync(ct); + return PagedResponse.Create(rows.Select(r => WithDays(r, today)).ToList(), query.Page, query.PageSize, total); + } + + public async Task> ListBatchStockAsync( + PageQuery query, int? itemId, int? warehouseId, int? expiringWithinDays, CancellationToken ct = default) + { + var today = Today; + var layers = _db.StockLayers.AsNoTracking().Where(l => l.QtyRemaining > 0 && l.BatchId != null); + if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId); + if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId); + if (expiringWithinDays is int days) + { + var until = today.AddDays(days); + layers = layers.Where(l => l.Batch!.ExpiryDate != null && l.Batch.ExpiryDate <= until); + } + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + layers = layers.Where(l => EF.Functions.Like(l.Batch!.BatchNo, $"%{term}%") + || EF.Functions.Like(l.Item!.Sku, $"%{term}%") || EF.Functions.Like(l.Item.Name, $"%{term}%")); + } + + var grouped = layers.GroupBy(l => new + { + l.ItemId, Sku = l.Item!.Sku, ItemName = l.Item.Name, + l.WarehouseId, WarehouseName = l.Warehouse != null ? l.Warehouse.Name : null, + l.BatchId, BatchNo = l.Batch!.BatchNo, Expiry = l.Batch.ExpiryDate + }).Select(g => new { g.Key, Qty = g.Sum(l => l.QtyRemaining) }); + + var total = await grouped.CountAsync(ct); + var rows = await grouped + .OrderBy(g => g.Key.Expiry == null).ThenBy(g => g.Key.Expiry).ThenBy(g => g.Key.Sku) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + return PagedResponse.Create(rows.Select(r => new BatchStockDto( + r.Key.ItemId, r.Key.Sku, r.Key.ItemName, r.Key.WarehouseId, r.Key.WarehouseName ?? $"Warehouse {r.Key.WarehouseId}", + r.Key.BatchId, r.Key.BatchNo, r.Key.Expiry, r.Qty, + r.Key.Expiry is DateOnly e ? e.DayNumber - today.DayNumber : null, + r.Key.Expiry is DateOnly x && x < today)).ToList(), query.Page, query.PageSize, total); + } + + private static IQueryable ProjectSerials(IQueryable q) => q.Select(s => new SerialSummaryDto( + s.SerialId, s.ItemId, s.Item!.Sku, s.Item.Name, s.SerialNo, s.Status, + s.WarehouseId, s.Warehouse != null ? s.Warehouse.Name : null, s.BinId, s.ReceivedAt, + s.GrnLine != null ? s.GrnLine.GrnId : null, s.GrnLine != null && s.GrnLine.Grn != null ? s.GrnLine.Grn.DocNo : null, + s.WarrantyPeriodMonths)); + + private async Task BuildDetailAsync(SerialSummaryDto serial, CancellationToken ct) + { + var today = Today; + var history = await _db.SerialMovements.AsNoTracking() + .Where(m => m.SerialId == serial.SerialId) + .OrderBy(m => m.SerialMovementId) + .Select(m => new SerialMovementDto(m.SerialMovementId, m.SourceDocType, m.SourceDocId, m.Status, + m.FromWarehouseId, m.WarehouseId, m.UserId, m.CreatedAt)) + .ToListAsync(ct); + var warranties = await _db.WarrantyRegistrations.AsNoTracking() + .Where(w => w.SerialId == serial.SerialId) + .OrderByDescending(w => w.WarrantyRegistrationId) + .Select(WarrantyProjection()) + .ToListAsync(ct); + return new SerialDetailDto(serial, history, warranties.Select(w => WithDays(w, today)).ToList()); + } + + private static global::System.Linq.Expressions.Expression> WarrantyProjection() + => w => new WarrantyRegistrationDto( + w.WarrantyRegistrationId, w.ItemId, w.Item!.Sku, w.Item.Name, w.SerialId, w.Serial != null ? w.Serial.SerialNo : null, + w.CustomerId, w.Customer != null ? (w.Customer.DisplayName ?? w.Customer.Name) : string.Empty, + w.SourceDocType, w.SourceDocId, w.SourceLineId, w.Qty, w.StartDate, w.PeriodMonths, w.EndDate, w.Status, + 0, false); + + private static WarrantyRegistrationDto WithDays(WarrantyRegistrationDto w, DateOnly today) => w with + { + DaysRemaining = Math.Max(0, w.EndDate.DayNumber - today.DayNumber), + IsExpired = w.EndDate < today + }; +} diff --git a/Backend/ERPCore/Services/Stock/SerialTrackingService.cs b/Backend/ERPCore/Services/Stock/SerialTrackingService.cs new file mode 100644 index 0000000..aad87d9 --- /dev/null +++ b/Backend/ERPCore/Services/Stock/SerialTrackingService.cs @@ -0,0 +1,156 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Auth; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Stock; + +/// +public sealed class SerialTrackingService : ISerialTrackingService +{ + private readonly IRepository _serials; + private readonly IRepository _movements; + private readonly IRepository _layers; + private readonly IRepository _config; + private readonly ICurrentUser _currentUser; + private bool? _enabled; // per request (scoped service) — one config read + + public SerialTrackingService( + IRepository serials, IRepository movements, + IRepository layers, IRepository config, ICurrentUser currentUser) + { + _serials = serials; + _movements = movements; + _layers = layers; + _config = config; + _currentUser = currentUser; + } + + public async Task IsEnabledAsync(CancellationToken ct = default) + => _enabled ??= await _config.Query().AsNoTracking() + .Where(c => c.ConfigId == ProductConfig.SingletonId) + .Select(c => (bool?)c.SerialNumbersEnabled) + .FirstOrDefaultAsync(ct) ?? true; + + public async Task IsSerialTrackedAsync(Item item, CancellationToken ct = default) + => item.TrackingMode == TrackingMode.Serial && await IsEnabledAsync(ct); + + public void RejectSerialsWhileDisabled(Item item, int serialCount) + { + if (item.TrackingMode == TrackingMode.Serial && serialCount > 0) + throw new DomainException(ErrorCodes.ConfigDisabled, + $"Serial numbers are switched off in Product Configuration; item {item.Sku} is handled without serials.", 422); + } + + public async Task> ResolveOutboundAsync( + Item item, int warehouseId, decimal qty, IReadOnlyCollection? serialIds, + ISet documentSerials, IReadOnlyCollection allowedStatuses, CancellationToken ct = default) + { + var ids = serialIds?.ToList() ?? new List(); + if (!await IsSerialTrackedAsync(item, ct)) + { + RejectSerialsWhileDisabled(item, ids.Count); + if (ids.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422); + return Array.Empty(); + } + + if (ids.Count == 0) + { + var untracked = await _layers.Query().AsNoTracking() + .Where(l => l.ItemId == item.ItemId && l.WarehouseId == warehouseId && l.SerialId == null && l.QtyRemaining > 0) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + if (untracked < qty) + throw new DomainException(ErrorCodes.TrackingRequired, $"Item {item.Sku} is serial-tracked; select {qty} serial number(s).", 422); + return Array.Empty(); + } + + if (ids.Count != qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku}: {qty} unit(s) need exactly that many serials, got {ids.Count}.", 422); + foreach (var id in ids) + if (!documentSerials.Add(id)) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is used more than once on this document.", 422); + + var serials = await _serials.Query().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct); + foreach (var id in ids) + { + var serial = serials.FirstOrDefault(s => s.SerialId == id); + if (serial is null || serial.ItemId != item.ItemId) + throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422); + if (serial.WarehouseId != warehouseId || !allowedStatuses.Contains(serial.Status)) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial {serial.SerialNo} is {serial.Status}{(serial.WarehouseId is int w && w != warehouseId ? $" at warehouse {w}" : string.Empty)} and cannot leave warehouse {warehouseId}.", 422); + } + return serials; + } + + public async Task> ResolveInboundAsync( + Item item, int warehouseId, decimal qty, IReadOnlyCollection? serialNos, + ISet documentSerialNos, CancellationToken ct = default) + { + var numbers = (serialNos ?? Array.Empty()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); + if (!await IsSerialTrackedAsync(item, ct)) + { + RejectSerialsWhileDisabled(item, numbers.Count); + if (numbers.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serial numbers are not accepted.", 422); + return Array.Empty(); + } + + if (qty != decimal.Truncate(qty)) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is serial-tracked; the quantity must be a whole number.", 422); + if (numbers.Count != qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; enter exactly {qty} serial number(s), got {numbers.Count}.", 422); + if (numbers.Any(n => n.Length > 100)) + throw new DomainException(ErrorCodes.Validation, "A serial number cannot be longer than 100 characters.", 422); + foreach (var n in numbers) + if (!documentSerialNos.Add($"{item.ItemId}:{n.ToUpperInvariant()}")) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number {n} is entered more than once.", 422); + + var existing = await _serials.Query().Where(s => s.ItemId == item.ItemId && numbers.Contains(s.SerialNo)).ToListAsync(ct); + var inStock = existing.Where(s => s.WarehouseId != null).Select(s => s.SerialNo).ToList(); + if (inStock.Count > 0) + throw new DomainException(ErrorCodes.SerialDuplicate, + $"Serial number(s) {string.Join(", ", inStock)} of item {item.Sku} are already in stock.", 409); + + var now = DateTime.UtcNow; + var result = new List(existing); + foreach (var n in numbers.Where(n => !existing.Any(e => string.Equals(e.SerialNo, n, StringComparison.OrdinalIgnoreCase)))) + { + var serial = new Serial + { + ItemId = item.ItemId, + SerialNo = n, + // Location is set by MoveAsync once the stock actually lands. Callers must save + // (flush) before building StockDeltas, which need the generated SerialId. + Status = SerialStatus.InStock, + ReceivedAt = now, + CreatedAt = now, + WarrantyPeriodMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null + }; + await _serials.AddAsync(serial, ct); + result.Add(serial); + } + return result; + } + + public async Task MoveAsync( + IEnumerable serials, SerialStatus status, int? warehouseId, int? binId, + string sourceDocType, int sourceDocId, DateTime now, CancellationToken ct = default) + { + foreach (var serial in serials) + { + var from = serial.WarehouseId; + serial.Status = status; + serial.WarehouseId = warehouseId; + serial.BinId = binId; + serial.UpdatedAt = now; + await _movements.AddAsync(SerialMovement.Of(serial, from, sourceDocType, sourceDocId, _currentUser.AuditUserId, now), ct); + } + } +} diff --git a/Backend/ERPCore/Services/Stock/StockMutator.cs b/Backend/ERPCore/Services/Stock/StockMutator.cs index 7a1b9ae..4f4fae5 100644 --- a/Backend/ERPCore/Services/Stock/StockMutator.cs +++ b/Backend/ERPCore/Services/Stock/StockMutator.cs @@ -3,6 +3,7 @@ using ERPCore.Domain.Enums; using ERPCore.Infra.Auth; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services.Stock; @@ -41,24 +42,39 @@ public sealed class StockMutator : IStockMutator if (!balances.TryGetValue(d.ItemId, out var bal)) bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct); + var serialIds = d.SerialIds is { Count: > 0 } ? d.SerialIds : null; + if (serialIds is not null && serialIds.Count != Math.Abs(d.QtyDelta)) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {d.ItemId}: {Math.Abs(d.QtyDelta)} unit(s) need exactly that many serials, got {serialIds.Count}.", 422); + // The ledger carries a serial only when the movement is exactly one unit. + int? ledgerSerialId = serialIds is { Count: 1 } ? serialIds[0] : null; + StockLedger entry; if (d.QtyDelta < 0) { var qty = -d.QtyDelta; - var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct); + var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct, serialIds: serialIds); 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, + d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, 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); + var unitCost = d.UnitCost ?? await LastCostAsync(d.ItemId, warehouseId, ct); + if (serialIds is not null) + { + foreach (var serialId in serialIds) + await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, serialId, null, 1m, unitCost, now, ct); + } + else + { + 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, + d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor, Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct); } diff --git a/Backend/ERPCore/Services/TransferService.cs b/Backend/ERPCore/Services/TransferService.cs index a4036c8..b3424d9 100644 --- a/Backend/ERPCore/Services/TransferService.cs +++ b/Backend/ERPCore/Services/TransferService.cs @@ -37,12 +37,15 @@ public sealed class TransferService : ITransferService private readonly IBranchConfigService _branchConfig; private readonly INotificationService _notifications; private readonly IUnitOfWork _uow; + private readonly ISerialTrackingService _serialTracking; public TransferService( IRepository transfers, IRepository warehouses, IRepository items, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, - IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow) + IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow, + ISerialTrackingService serialTracking) { + _serialTracking = serialTracking; _transfers = transfers; _warehouses = warehouses; _items = items; @@ -83,7 +86,8 @@ public sealed class TransferService : ITransferService public async Task GetAsync(int transferId, CancellationToken ct = default) { - var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines) + var t = await _transfers.Query().AsNoTracking() + .Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.TransferId == transferId, ct); return t is null ? null : Map(t); } @@ -95,9 +99,42 @@ public sealed class TransferService : ITransferService await EnsureWarehouseAsync(request.SrcWarehouseId, ct); await EnsureWarehouseAsync(request.DestWarehouseId, ct); var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); + + // Resolve tracking up front: serial lines carry their serials; a batch-tracked line with + // no batch is split FEFO into one line per batch, so batch identity survives the move. + var lines = new List(); + var documentSerials = new HashSet(); 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 item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + var serials = await _serialTracking.ResolveOutboundAsync( + item, request.SrcWarehouseId, line.Qty, line.SerialIds, documentSerials, new[] { SerialStatus.InStock }, ct); + + StockTransferLine NewLine(int? batchId, decimal qty) => new() + { + ItemId = line.ItemId, + SrcBinId = line.SrcBinId, + DestBinId = line.DestBinId, + BatchId = batchId, + Qty = qty, + SerialId = serials.Count == 1 ? serials[0].SerialId : null, + Serials = serials.Select(s => new StockTransferLineSerial { SerialId = s.SerialId }).ToList() + }; + + if (item.TrackingMode == TrackingMode.Batch && line.BatchId is null) + { + var picks = await _fifo.SuggestBatchesAsync(line.ItemId, request.SrcWarehouseId, line.Qty, ct); + var picked = picks.Sum(p => p.Qty); + foreach (var pick in picks) lines.Add(NewLine(pick.BatchId, pick.Qty)); + // Whatever FEFO could not place stays unbatched — dispatch then reports the shortfall. + if (picked < line.Qty) lines.Add(NewLine(null, line.Qty - picked)); + } + else + { + lines.Add(NewLine(line.BatchId, line.Qty)); + } + } var actor = _currentUser.AuditUserId; var transfer = await _uow.ExecuteInTransactionAsync(async token => @@ -112,14 +149,7 @@ public sealed class TransferService : ITransferService 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() + Lines = lines }; await _transfers.AddAsync(entity, token); return entity; @@ -132,7 +162,8 @@ public sealed class TransferService : ITransferService public async Task DispatchAsync(int transferId, CancellationToken ct = default) { - var transfer = await _transfers.Query().Include(t => t.Lines) + var transfer = await _transfers.Query() + .Include(t => t.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(t => t.TransferId == transferId, ct) ?? throw new NotFoundException($"Transfer {transferId} was not found."); if (transfer.Status != TransferStatus.Draft) @@ -148,10 +179,28 @@ public sealed class TransferService : ITransferService { foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId)) { - var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token); + // The draft didn't reserve its serials — re-check they are still here and in stock. + var serials = line.Serials.Select(s => s.Serial!).ToList(); + var gone = serials.Where(s => s.Status != SerialStatus.InStock || s.WarehouseId != transfer.SrcWarehouseId).Select(s => s.SerialNo).ToList(); + if (gone.Count > 0) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial(s) {string.Join(", ", gone)} are no longer in stock at the source warehouse.", 409); + + var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token, + serialIds: serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null); var value = segments.Sum(s => s.Qty * s.UnitCost); line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive + // Each serial keeps its own cost and batch for its destination layer. + foreach (var lineSerial in line.Serials) + { + var segment = segments.First(s => s.SerialId == lineSerial.SerialId); + lineSerial.UnitCost = segment.UnitCost; + lineSerial.BatchId = segment.BatchId; + } + await _serialTracking.MoveAsync(serials, SerialStatus.InTransit, null, null, + DocumentTypes.Transfer, transfer.TransferId, now, token); + if (!balances.TryGetValue(line.ItemId, out var bal)) bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token); bal -= line.Qty; @@ -174,7 +223,8 @@ public sealed class TransferService : ITransferService public async Task ReceiveAsync(int transferId, ReceiveTransferRequest request, CancellationToken ct = default) { - var transfer = await _transfers.Query().Include(t => t.Lines) + var transfer = await _transfers.Query() + .Include(t => t.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(t => t.TransferId == transferId, ct) ?? throw new NotFoundException($"Transfer {transferId} was not found."); if (transfer.Status != TransferStatus.InTransit) @@ -199,9 +249,26 @@ public sealed class TransferService : ITransferService $"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 (line.Serials.Count > 0) + { + var arriving = ResolveArrivingSerials(line, input); + foreach (var lineSerial in arriving) + { + var layer = await _fifo.CreateInboundLayerAsync( + line.ItemId, transfer.DestWarehouseId, lineSerial.BatchId ?? line.BatchId, lineSerial.SerialId, null, + 1m, lineSerial.UnitCost ?? unitCost, now, token); + createdLayers.Add(layer); + lineSerial.ReceivedAt = now; + } + await _serialTracking.MoveAsync(arriving.Select(s => s.Serial!), SerialStatus.InStock, + transfer.DestWarehouseId, line.DestBinId, DocumentTypes.Transfer, transfer.TransferId, now, token); + } + else + { + 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); @@ -234,8 +301,34 @@ public sealed class TransferService : ITransferService throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422); } + /// + /// Which in-transit serials a receive line covers: the ones it names (each still outstanding + /// on the line), or — with none named — every outstanding one, which must then be the full qty. + /// + private static List ResolveArrivingSerials(StockTransferLine line, ReceiveTransferLineInput input) + { + var outstanding = line.Serials.Where(s => s.ReceivedAt == null).ToList(); + if (input.SerialIds is not { Count: > 0 }) + { + if (input.Qty != outstanding.Count) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Transfer line {line.TransferLineId} is serial-tracked; name which {input.Qty} of its {outstanding.Count} in-transit serials arrived.", 422); + return outstanding; + } + + if (input.SerialIds.Count != input.Qty || input.SerialIds.Distinct().Count() != input.SerialIds.Count) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Receiving {input.Qty} on transfer line {line.TransferLineId} needs exactly that many distinct serials.", 422); + var arriving = outstanding.Where(s => input.SerialIds.Contains(s.SerialId)).ToList(); + if (arriving.Count != input.SerialIds.Count) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Some serials are not in transit on transfer line {line.TransferLineId}.", 422); + return arriving; + } + private static TransferDto Map(StockTransfer t) => new( t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt, 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()); + l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived, + l.Serials.Select(s => new TransferLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty, s.ReceivedAt != null)).ToList())).ToList()); } diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 2557cec..10b002f 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -30,6 +30,11 @@ public static class ErrorCodes public const string ConfigDisabled = "CONFIG_DISABLED"; public const string RoleInUse = "ROLE_IN_USE"; + // Item tracking (docs/ITEM_TRACKING_PLAN.md). + public const string SerialDuplicate = "SERIAL_DUPLICATE"; + public const string SerialNotAvailable = "SERIAL_NOT_AVAILABLE"; + public const string TrackingRequired = "TRACKING_REQUIRED"; + // Auth proxy (AuthController → AuthHex, docs/11 §2.0) public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR"; public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE"; diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx index 896c151..8de035d 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -18,6 +18,7 @@ import { CreatePurchaseReturnLineInput } from "@/types/procurement" import { Grn, GrnLine } from "@/types/grn" import { ItemListItem } from "@/types/master-data" import { ReasonCode } from "@/types/stock" +import { useTrackingSettings } from "@/hooks/use-tracking-settings" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -39,6 +40,8 @@ const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background interface LineState { selected: boolean qty: string + /** Serial line: the received serials going back to the vendor (qty follows the count). */ + serialIds?: number[] } function NewPurchaseReturnContent() { @@ -62,6 +65,7 @@ function NewPurchaseReturnContent() { const [headerError, setHeaderError] = useState(null) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) + const { serialNumbersEnabled } = useTrackingSettings() useEffect(() => { Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")]) @@ -121,6 +125,33 @@ function NewPurchaseReturnContent() { setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } })) } + function toggleSerial(grnLineId: number, serialId: number) { + setLineState((prev) => { + const current = prev[grnLineId]?.serialIds ?? [] + const next = current.includes(serialId) ? current.filter((id) => id !== serialId) : [...current, serialId] + return { ...prev, [grnLineId]: { selected: next.length > 0, qty: next.length > 0 ? String(next.length) : "", serialIds: next } } + }) + } + + /** A serial line lists its received serials to tick — the server refuses any no longer in stock. */ + function renderSerials(line: GrnLine) { + // Serial numbers switched off in Product Configuration: the line goes back as plain stock. + if (serialNumbersEnabled === false) return null + const serials = (line.serials ?? []).filter((s) => s.serialId !== null) + if (serials.length === 0) return null + const picked = lineState[line.grnLineId]?.serialIds ?? [] + return ( +
+ {serials.map((s) => ( + + ))} +
+ ) + } + async function handleSubmit() { setHeaderError(null) setSubmitError(null) @@ -143,6 +174,8 @@ function NewPurchaseReturnContent() { const nextErrors: Record> = {} for (const line of selectedLines) { const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty }) + if (serialNumbersEnabled !== false && (line.serials ?? []).some((s) => s.serialId !== null) && (lineState[line.grnLineId].serialIds?.length ?? 0) !== Number(lineState[line.grnLineId].qty)) + errors.qty = "Tick one serial per unit going back" if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors } setLineErrors(nextErrors) @@ -155,6 +188,7 @@ function NewPurchaseReturnContent() { grnLineId: l.grnLineId, itemId: l.itemId, qty: Number(lineState[l.grnLineId].qty), + serialIds: lineState[l.grnLineId].serialIds?.length ? lineState[l.grnLineId].serialIds : null, })) setSubmitting(true) @@ -249,6 +283,7 @@ function NewPurchaseReturnContent() { {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {renderSerials(line)}
@@ -309,7 +344,10 @@ function NewPurchaseReturnContent() { aria-label={`Return ${item?.sku ?? `item #${line.itemId}`}`} /> - {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {renderSerials(line)} + {line.qty} diff --git a/Frontend/erp-system/app/dashboard/products/settings/page.tsx b/Frontend/erp-system/app/dashboard/products/settings/page.tsx index f1323fc..47290ae 100644 --- a/Frontend/erp-system/app/dashboard/products/settings/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/settings/page.tsx @@ -11,6 +11,7 @@ import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data" import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import { toast } from "@/components/ui/toast" +import { invalidateTrackingSettings } from "@/hooks/use-tracking-settings" /** * Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate. @@ -61,6 +62,8 @@ export default function ProductSettingsPage() { ) setConfig(res.data) setEtag(res.etag) + // Screens read the serial switch through a cached hook — make the next read fresh. + invalidateTrackingSettings() toast.success("Configuration saved", message) return true } catch (err) { @@ -152,7 +155,7 @@ export default function ProductSettingsPage() { toggle("serialNumbersEnabled", v)} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 3ca58b1..6fc438a 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -13,7 +13,7 @@ import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { ConfirmGrnResponse, Grn } from "@/types/grn" +import { ConfirmGrnResponse, Grn, GrnLine } from "@/types/grn" import { Bin, ItemListItem, Uom } from "@/types/master-data" import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices" @@ -27,6 +27,34 @@ import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badg import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" +/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */ +function LineTrackingInfo({ line }: { line: GrnLine }) { + const serials = line.serials ?? [] + if (!line.batchNo && serials.length === 0) return null + return ( +
+ {line.batchNo && ( +

+ Batch {line.batchNo} + {line.batchExpiryDate && <> · expires {line.batchExpiryDate}} +

+ )} + {serials.length > 0 && ( +
+ + {serials.length} serial number{serials.length === 1 ? "" : "s"} + +
    + {serials.map((s) => ( +
  • {s.serialNo}
  • + ))} +
+
+ )} +
+ ) +} + export default function GrnDetailPage() { const params = useParams<{ id: string }>() const router = useRouter() @@ -235,6 +263,7 @@ export default function GrnDetailPage() { {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured

)} +
@@ -357,6 +386,7 @@ export default function GrnDetailPage() { {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured

)} + {baseUomLabel(items, uoms, line.itemId)} {binFor(line.binId)} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index 21dcb10..724e46c 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react" +import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2, Wand2 } from "lucide-react" import { ScanInput } from "@/components/scanning/ScanInput" import { ApiError } from "@/lib/api-client" @@ -15,6 +15,8 @@ import { branchesHrmApi } from "@/lib/api/hrm-masters" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" import { grnConfigApi } from "@/lib/api/grn-config" +import { useTrackingSettings } from "@/hooks/use-tracking-settings" +import { trackingApi } from "@/lib/api/tracking" import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { validateLine, grnHeaderSchema } from "@/lib/validations/grn" @@ -40,12 +42,25 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip type Mode = "po" | "direct" type LineTab = "lines" | "warranty" -/** Whole units a line's qty represents — one warranty number is captured per unit. */ +/** Whole units a line's qty represents — one warranty/serial number is captured per unit. */ function unitCount(qty: string): number { const n = Math.floor(Number(qty)) return Number.isFinite(n) && n > 0 ? n : 0 } +/** + * Items that need one number per received unit: serial-tracked items (their serial numbers — + * which double as the warranty identity, docs/ITEM_TRACKING_PLAN.md D1) and other warranty items. + */ +function capturesUnitNumbers(item: ItemListItem | null | undefined): boolean { + return item?.trackingMode === "Serial" || item?.warranty === "Warranty" +} + +/** "serial number" for a serial-tracked item, else "warranty number". */ +function unitNumberLabel(item: ItemListItem | null | undefined): string { + return item?.trackingMode === "Serial" ? "serial number" : "warranty number" +} + /** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */ function WarrantyHintBubble() { const [visible, setVisible] = useState(true) @@ -83,10 +98,15 @@ interface DraftLine { discountPct: string vatPct: string holdStatus: HoldStatus - /** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */ + /** + * One entry per received unit, index-aligned; only meaningful when the item captures unit + * numbers — sent as `serialNumbers` for a serial-tracked item, else as `warrantyNumbers`. + */ warrantyNumbers: string[] - /** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo }` on submit. */ + /** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo, expiryDate }` on submit. */ batchNo: string + /** yyyy-mm-dd, optional — drives FEFO picking and expiry blocking. */ + batchExpiry: string } /** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */ @@ -155,10 +175,12 @@ function buildWarrantyRange(from: string, to: string): string[] | null { * with a generated number — existing (manually edited) values are left untouched. */ function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] { - if (item?.warranty !== "Warranty") return l.warrantyNumbers + if (!capturesUnitNumbers(item)) return l.warrantyNumbers const units = unitCount(l.qty) const next = l.warrantyNumbers.slice(0, units) while (next.length < units) next.push("") + // Serial numbers are the manufacturer's — never invent them; leave the slots for scanning. + if (item?.trackingMode === "Serial") return next for (let i = 0; i < units; i++) { if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber() } @@ -179,12 +201,14 @@ function emptyLine(): DraftLine { holdStatus: "Available", warrantyNumbers: [], batchNo: "", + batchExpiry: "", } } export default function NewGrnPage() { const router = useRouter() const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth() + const { serialNumbersEnabled } = useTrackingSettings() const [mode, setMode] = useState("po") @@ -339,6 +363,7 @@ export default function NewGrnPage() { holdStatus: "Available", warrantyNumbers: [], batchNo: "", + batchExpiry: "", } return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) } }) @@ -440,8 +465,16 @@ export default function NewGrnPage() { setLines((prev) => prev.filter((l) => l.key !== key)) } + /** + * The item as this GRN must treat it: with serial numbers switched off in Product + * Configuration a Serial item is received as untracked stock (a warranty item then takes + * warranty numbers again), so every serial rule below follows from this one place. + */ function itemFor(itemId: number | null) { - return items?.find((i) => i.itemId === itemId) ?? null + const item = items?.find((i) => i.itemId === itemId) ?? null + return item && item.trackingMode === "Serial" && serialNumbersEnabled === false + ? { ...item, trackingMode: "None" as const } + : item } const itemOptions = useMemo( @@ -530,11 +563,68 @@ export default function NewGrnPage() { // From/To are left exactly as typed — not cleared — so a typo is easy to fix in place. } - // Lines whose item is warranty-tracked and carry at least one unit — these are the rows - // the "Warranty numbers" tab needs to capture, one input per unit. + // Lines whose item is serial- or warranty-tracked and carry at least one unit — these are + // the rows the "Serial / warranty numbers" tab needs to capture, one input per unit. const warrantyLines = lines .map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) })) - .filter((w) => w.item?.warranty === "Warranty" && w.units > 0) + .filter((w) => capturesUnitNumbers(w.item) && w.units > 0) + + // Bulk paste for a line's serial/warranty numbers — one per line (or comma-separated), filling + // units from the first; extra pasted values beyond the unit count are reported, not dropped silently. + const [pasteInputs, setPasteInputs] = useState>({}) + function applyPastedNumbers(lineKey: string, units: number) { + const values = (pasteInputs[lineKey] ?? "").split(/[\r\n,;\t]+/).map((s) => s.trim()).filter((s) => s.length > 0) + if (values.length === 0) return + setLines((prev) => + prev.map((l) => { + if (l.key !== lineKey) return l + const next = l.warrantyNumbers.slice(0, units) + while (next.length < units) next.push("") + values.slice(0, units).forEach((v, i) => (next[i] = v)) + return { ...l, warrantyNumbers: next } + }) + ) + setPasteInputs((prev) => ({ ...prev, [lineKey]: "" })) + if (values.length > units) + toast.error("Too many numbers", `${values.length} pasted for ${units} unit(s) — the extra ${values.length - units} were not used.`) + } + + // Auto-generate serial numbers: the server continues the highest number already used under the + // prefix (received + draft GRNs); numbers already typed on this form are skipped too. Only empty + // unit slots are filled — scanned/typed serials are never overwritten. + const [genPrefixes, setGenPrefixes] = useState>({}) + const [generating, setGenerating] = useState(null) + async function autoGenerateSerials(lineKey: string, itemId: number, units: number, prefix: string) { + const line = lines.find((l) => l.key === lineKey) + if (!line) return + const blanks = Array.from({ length: units }, (_, i) => i).filter((i) => !(line.warrantyNumbers[i] ?? "").trim()) + if (blanks.length === 0) { + toast.error("Nothing to generate", "Every unit already has a serial number — clear some first.") + return + } + const inForm = new Set( + lines.filter((l) => l.itemId === itemId).flatMap((l) => l.warrantyNumbers.map((s) => s.trim().toLowerCase())).filter((s) => s) + ) + setGenerating(lineKey) + try { + const fetched = await trackingApi.nextSerialNumbers(itemId, prefix, Math.min(1000, blanks.length + inForm.size)) + const fresh = fetched.filter((n) => !inForm.has(n.toLowerCase())).slice(0, blanks.length) + setLines((prev) => + prev.map((l) => { + if (l.key !== lineKey) return l + const next = l.warrantyNumbers.slice(0, units) + while (next.length < units) next.push("") + blanks.forEach((slot, i) => { if (fresh[i]) next[slot] = fresh[i] }) + return { ...l, warrantyNumbers: next } + }) + ) + toast.success("Serial numbers generated", `${fresh.length} filled, starting ${fresh[0] ?? "—"}.`) + } catch (err) { + toast.error("Could not generate serial numbers", errorMessage(err)) + } finally { + setGenerating(null) + } + } async function handleSubmit() { setSubmitError(null) @@ -576,6 +666,8 @@ export default function NewGrnPage() { discountPct: allowDiscounts ? line.discountPct : "0", vatPct: allowVat ? line.vatPct : "0", warranty: item?.warranty ?? null, + trackingMode: item?.trackingMode ?? null, + batchNo: line.batchNo, warrantyNumbers: line.warrantyNumbers, }) if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors @@ -593,6 +685,8 @@ export default function NewGrnPage() { const payloadLines: CreateGrnLineInput[] = lines.map((l) => { const item = itemFor(l.itemId) + const unitNumbers = l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0) + const serial = item?.trackingMode === "Serial" return { poLineId: l.poLineId, itemId: l.itemId as number, @@ -602,11 +696,13 @@ export default function NewGrnPage() { discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0, vatPct: allowVat ? Number(l.vatPct) || 0 : 0, holdStatus: l.holdStatus, - warrantyNumbers: - item?.warranty === "Warranty" - ? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0) + // A serial item's serials are its warranty identity — it sends no warranty numbers. + serialNumbers: serial ? unitNumbers : null, + warrantyNumbers: !serial && item?.warranty === "Warranty" ? unitNumbers : null, + batch: + item?.trackingMode === "Batch" && l.batchNo.trim() + ? { batchNo: l.batchNo.trim(), expiryDate: l.batchExpiry || null } : null, - batch: item?.trackingMode === "Batch" && l.batchNo.trim() ? { batchNo: l.batchNo.trim() } : null, } }) @@ -861,7 +957,7 @@ export default function NewGrnPage() { className="rounded-full" onClick={() => setLineTab("warranty")} > - Warranty numbers + Serial / warranty numbers {warrantyLines.length > 0 && ( {warrantyLines.reduce((sum, w) => sum + w.units, 0)} @@ -951,14 +1047,27 @@ export default function NewGrnPage() { {item?.trackingMode === "Batch" && ( -
- - updateLine(line.key, { batchNo: e.target.value })} - placeholder="Batch no" - className="h-11 text-sm" - /> +
+
+ + updateLine(line.key, { batchNo: e.target.value })} + placeholder="Batch no" + className="h-11 text-sm" + /> + +
+
+ + updateLine(line.key, { batchExpiry: e.target.value })} + className="h-11 text-sm" + /> +
)} @@ -1050,10 +1159,10 @@ export default function NewGrnPage() {
- {item?.warranty === "Warranty" && ( + {capturesUnitNumbers(item) && ( )} {errors.warrantyNumbers && } @@ -1134,12 +1243,24 @@ export default function NewGrnPage() { {item?.trackingMode === "Batch" ? ( - updateLine(line.key, { batchNo: e.target.value })} - placeholder="Batch no" - className="h-11 text-sm" - /> +
+ updateLine(line.key, { batchNo: e.target.value })} + placeholder="Batch no" + className="h-11 text-sm" + /> + updateLine(line.key, { batchExpiry: e.target.value })} + aria-label="Batch expiry date" + title="Expiry date (optional)" + className="h-9 text-xs" + /> + +
) : ( — )} @@ -1237,7 +1358,7 @@ export default function NewGrnPage() {
- {item?.warranty === "Warranty" ? ( + {capturesUnitNumbers(item) ? (
setLineTab("warranty")} - aria-label="Add warranty numbers" + aria-label={`Add ${unitNumberLabel(item)}s`} /> } > - Add warranty numbers + Add {unitNumberLabel(item)}s {errors.warrantyNumbers && ( @@ -1311,8 +1432,8 @@ export default function NewGrnPage() { {warrantyLines.length === 0 ? (

- No warranty-tracked items on this GRN yet — add a line for an item marked - “Warranty” to capture its numbers here. + No serial- or warranty-tracked items on this GRN yet — add a line for such an + item to capture one number per unit here.

) : (
@@ -1320,11 +1441,65 @@ export default function NewGrnPage() { const errors = lineErrors[line.key] ?? {} const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" } const addError = warrantyAddErrors[line.key] + const serial = item?.trackingMode === "Serial" + const label = unitNumberLabel(item) + const entered = line.warrantyNumbers.filter((s) => s.trim().length > 0).length return (
-
+
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {serial ? "Serial" : "Warranty"} + {serial && item?.warranty === "Warranty" && ( + Warranty starts at sale + )} + + {entered} of {units} entered +
+ {serial && ( +
+
+ +