serial numbers
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO
|
||||
/// SQL: the sales-line check (<see cref="SalesDomainService.ValidateLineTrackingAsync"/>) and the
|
||||
/// shared stock-document rules (<see cref="SerialTrackingService"/>).
|
||||
/// </summary>
|
||||
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<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet<int>()));
|
||||
|
||||
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<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet<int>()));
|
||||
|
||||
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<int>());
|
||||
|
||||
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<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet<int>()));
|
||||
|
||||
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<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet<int>()));
|
||||
|
||||
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<int>();
|
||||
|
||||
await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials);
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
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<int>());
|
||||
|
||||
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<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
|
||||
|
||||
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<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet<int>()));
|
||||
|
||||
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<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet<int>()));
|
||||
|
||||
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<DomainException>(() => tracking.ResolveOutboundAsync(
|
||||
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), 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<int>(), 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<DomainException>(() => tracking.ResolveInboundAsync(
|
||||
item, Wh1, 1, new[] { "SN-1" }, new HashSet<string>()));
|
||||
|
||||
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<DomainException>(() => tracking.ResolveInboundAsync(
|
||||
item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet<string>()));
|
||||
|
||||
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<string>());
|
||||
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<DomainException>(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
// --- fixtures ---------------------------------------------------------------------------
|
||||
|
||||
private static async Task<ErpDbContext> 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<ErpDbContext>()
|
||||
.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<Customer>(db), new Repository<Warehouse>(db), new Repository<User>(db),
|
||||
new Repository<Item>(db), new Repository<TaxRate>(db), null!, new Repository<SalesConfig>(db),
|
||||
new Repository<Batch>(db), new Repository<Serial>(db), new Repository<StockLayer>(db),
|
||||
CreateSerialTracking(db));
|
||||
|
||||
private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new(
|
||||
new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
||||
new Repository<ProductConfig>(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<int>());
|
||||
|
||||
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<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
|
||||
var adjustment = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet<string>()));
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user