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()); } }