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