serial numbers

This commit is contained in:
2026-10-07 14:00:32 +05:30
committed by ImanThiyanga
parent 3abcfa6e6f
commit a788349138
89 changed files with 15320 additions and 216 deletions
+366
View File
@@ -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());
}
}
@@ -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;
/// <summary>Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9).</summary>
[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<SerialSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SerialSummaryDto>>> List(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct));
/// <summary>Exact lookup by serial number or barcode, across items.</summary>
[HttpGet("lookup")]
[ProducesResponseType(typeof(IReadOnlyList<SerialDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<SerialDetailDto>>> 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<ActionResult<SerialDetailDto>> Get(int serialId, CancellationToken ct)
{
var dto = await _tracking.GetSerialAsync(serialId, ct);
return dto is null ? NotFound() : Ok(dto);
}
}
+14 -1
View File
@@ -15,13 +15,26 @@ public sealed class StockController : ApiControllerBase
{ {
private readonly IStockService _stock; private readonly IStockService _stock;
private readonly IReorderService _reorder; private readonly IReorderService _reorder;
private readonly IItemTrackingService _tracking;
public StockController(IStockService stock, IReorderService reorder) public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking)
{ {
_stock = stock; _stock = stock;
_reorder = reorder; _reorder = reorder;
_tracking = tracking;
} }
/// <summary>
/// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry
/// first. <c>expiringWithinDays</c> keeps batches expiring within that window, expired included.
/// </summary>
[HttpGet("batches")]
[ProducesResponseType(typeof(PagedResponse<BatchStockDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BatchStockDto>>> 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")] [HttpGet("on-hand")]
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
@@ -0,0 +1,64 @@
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// 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.
/// </summary>
[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;
}
/// <summary>
/// Which tracking features are switched on (Product Configuration) — readable by every
/// screen, unlike <c>/product-config</c> which needs the Products permission.
/// </summary>
[HttpGet("settings")]
[ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<TrackingSettingsDto>> Settings(CancellationToken ct)
=> Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct)));
/// <summary>
/// Auto-generate: the next <c>count</c> free serial numbers for an item under <c>prefix</c>
/// (continues the highest number already used). Nothing is reserved — the GRN still checks
/// uniqueness when it is saved and confirmed.
/// </summary>
[HttpGet("serials/next-numbers")]
[ProducesResponseType(typeof(IReadOnlyList<string>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<string>>> NextSerialNumbers(
[FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default)
=> Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct));
/// <summary>Issuable serials of an item at a warehouse (search by number/barcode with <c>q</c>).</summary>
[HttpGet("serials/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableSerialDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableSerialDto>>> 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));
/// <summary>Issuable stock per batch with expiry, soonest expiry first.</summary>
[HttpGet("batches/available")]
[ProducesResponseType(typeof(IReadOnlyList<AvailableBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<AvailableBatchDto>>> AvailableBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct));
/// <summary>The FEFO pick posting would make for <c>qty</c> when no batch is chosen.</summary>
[HttpGet("batches/suggest")]
[ProducesResponseType(typeof(IReadOnlyList<BatchSuggestionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<BatchSuggestionDto>>> SuggestBatches(
[FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct)
=> Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct));
}
@@ -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;
/// <summary>
/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by
/// posting a sale, never directly — this controller only reads.
/// </summary>
[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<WarrantyRegistrationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WarrantyRegistrationDto>>> 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));
}
@@ -65,4 +65,12 @@ public class GrnLine
/// Empty for a non-warranty item. /// Empty for a non-warranty item.
/// </summary> /// </summary>
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>(); public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
/// <summary>
/// One row per received unit when <see cref="Item.TrackingMode"/> is
/// <see cref="TrackingMode.Serial"/> — count must equal <see cref="Qty"/>. For a serial item
/// under warranty the serial is the warranty identity, so it carries no
/// <see cref="WarrantyNumbers"/>. Empty otherwise.
/// </summary>
public ICollection<GrnLineSerial> Serials { get; set; } = new List<GrnLineSerial>();
} }
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A serial number captured on a Draft GRN line for a serial-tracked item
/// (<see cref="Item.TrackingMode"/> = <see cref="Enums.TrackingMode.Serial"/>). The line must
/// carry exactly <see cref="GrnLine.Qty"/> of these. The draft holds the numbers only — the
/// <see cref="Serial"/> 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).
/// </summary>
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;
/// <summary>Set on confirm — the <see cref="Serial"/> created for this number.</summary>
public int? SerialId { get; set; }
public Serial? Serial { get; set; }
}
@@ -19,6 +19,13 @@ public class SalesInvoiceLine
public int WarehouseId { get; set; } public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; } public Warehouse? Warehouse { get; set; }
/// <summary>Pins the line to one batch; null lets posting pick batches FEFO.</summary>
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
/// <summary>Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty).</summary>
public ICollection<SalesInvoiceLineSerial> Serials { get; set; } = new List<SalesInvoiceLineSerial>();
public decimal UnitPrice { get; set; } public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; } public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty; public string PriceSource { get; set; } = string.Empty;
@@ -0,0 +1,35 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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 (<see cref="SourceDocType"/> is a
/// <see cref="Domain.DocumentTypes"/> value) — same convention as <see cref="StockLedger"/> —
/// 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).
/// </summary>
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; }
/// <summary>Quantity of this allocation already taken back by sales returns.</summary>
public decimal QtyReturned { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,29 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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).
/// </summary>
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; }
}
/// <summary>Sales-slip counterpart of <see cref="SalesInvoiceLineSerial"/>.</summary>
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; }
}
@@ -2,7 +2,9 @@ namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// Sales-return line referencing the original sales invoice line for traceability. /// Sales-return line referencing the original sales invoice line for traceability.
/// <see cref="Qty"/> is in base UOM. /// <see cref="Qty"/> is in base UOM. <see cref="BatchId"/> is the batch the goods are restocked
/// into — the batch they were sold from (docs/ITEM_TRACKING_PLAN.md Phase 6); <see cref="Serials"/>
/// lists the returned units of a serial-tracked item.
/// </summary> /// </summary>
public class SalesReturnLine public class SalesReturnLine
{ {
@@ -18,4 +20,21 @@ public class SalesReturnLine
public Item? Item { get; set; } public Item? Item { get; set; }
public decimal Qty { get; set; } public decimal Qty { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public ICollection<SalesReturnLineSerial> Serials { get; set; } = new List<SalesReturnLineSerial>();
}
/// <summary>One serial taken back on a <see cref="SalesReturnLine"/>.</summary>
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; }
} }
@@ -22,9 +22,16 @@ public class SalesSlipLine
public int? BatchId { get; set; } public int? BatchId { get; set; }
public Batch? Batch { get; set; } public Batch? Batch { get; set; }
/// <summary>
/// Legacy single-serial pin, from before a line could carry several. Kept for old drafts and
/// still set when exactly one serial is chosen; <see cref="Serials"/> is authoritative.
/// </summary>
public int? SerialId { get; set; } public int? SerialId { get; set; }
public Serial? Serial { get; set; } public Serial? Serial { get; set; }
/// <summary>Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty).</summary>
public ICollection<SalesSlipLineSerial> Serials { get; set; } = new List<SalesSlipLineSerial>();
public decimal UnitPrice { get; set; } public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; } public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty; public string PriceSource { get; set; } = string.Empty;
+32 -2
View File
@@ -1,8 +1,13 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities; namespace ERPCore.Domain.Entities;
/// <summary> /// <summary>
/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04). /// 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 (<see cref="StockLayer.SerialId"/>), so issuing a serial consumes that
/// layer. <see cref="WarehouseId"/>/<see cref="BinId"/> are its current location and are
/// cleared once it leaves stock. Model: docs/10 Part C.4, docs/ITEM_TRACKING_PLAN.md Phase 1.
/// </summary> /// </summary>
public class Serial public class Serial
{ {
@@ -12,8 +17,33 @@ public class Serial
public Item? Item { get; set; } public Item? Item { get; set; }
public string SerialNo { get; set; } = string.Empty; public string SerialNo { get; set; } = string.Empty;
public string Status { get; set; } = "InStock"; public SerialStatus Status { get; set; } = SerialStatus.InStock;
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary> /// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
public string? Barcode { get; set; } public string? Barcode { get; set; }
/// <summary>Current warehouse while in stock; null once sold/returned to vendor/scrapped.</summary>
public int? WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
/// <summary>The GRN line that received this unit (provenance); null for count/adjustment finds.</summary>
public int? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public DateTime? ReceivedAt { get; set; }
/// <summary>
/// Warranty coverage snapshotted from <see cref="Item.WarrantyPeriodMonths"/> at receipt
/// (same rule as <see cref="GrnLineWarrantyNumber.WarrantyPeriodMonths"/>); null for a
/// non-warranty item. The clock does not start here — it starts at sale
/// (<see cref="WarrantyRegistration"/>).
/// </summary>
public int? WarrantyPeriodMonths { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
} }
@@ -0,0 +1,46 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Append-only history of one serial: a row each time a document changes its status or
/// location (receipt, sale, return, transfer, adjustment, count). <see cref="Status"/> and
/// <see cref="WarehouseId"/> are the serial's state <em>after</em> the movement. The source
/// document is referenced polymorphically, as on <see cref="StockLedger"/>. 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).
/// </summary>
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; }
/// <summary>
/// Records <paramref name="serial"/>'s current state — call after updating it. Linked by
/// navigation, so it works for a serial inserted in the same save.
/// </summary>
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
};
}
@@ -18,6 +18,7 @@ public class StockAdjustmentLine
public int? BinId { get; set; } public int? BinId { get; set; }
public int? BatchId { get; set; } public int? BatchId { get; set; }
public int? SerialId { get; set; } public int? SerialId { get; set; }
public Serial? Serial { get; set; }
public decimal QtyDelta { get; set; } public decimal QtyDelta { get; set; }
} }
@@ -32,4 +32,27 @@ public class StockTransferLine
/// <summary>Quantity already received at the destination (partial-receive support).</summary> /// <summary>Quantity already received at the destination (partial-receive support).</summary>
public decimal QtyReceived { get; set; } public decimal QtyReceived { get; set; }
/// <summary>The units moved, for a serial-tracked item (one per unit of <see cref="Qty"/>).</summary>
public ICollection<StockTransferLineSerial> Serials { get; set; } = new List<StockTransferLineSerial>();
}
/// <summary>
/// One serial on a transfer line. <see cref="UnitCost"/>/<see cref="BatchId"/> are captured from
/// its own source layer at dispatch, so the destination layer is recreated exactly;
/// <see cref="ReceivedAt"/> is set when it arrives (partial receive names which serials).
/// </summary>
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; }
} }
@@ -0,0 +1,42 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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 (<see cref="SerialId"/> set,
/// <see cref="Qty"/> = 1); for a warranty item that is not serial-tracked there is one row per
/// sales line covering its <see cref="Qty"/>. <see cref="PeriodMonths"/> is the coverage
/// snapshotted at receipt (<see cref="Serial.WarrantyPeriodMonths"/>), else the item's period.
/// </summary>
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; }
}
@@ -0,0 +1,25 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle state of one serial-tracked unit (FR-WH-04). Stored as a string — the values
/// <c>InStock</c>/<c>Sold</c> written before this enum existed stay valid as-is.
/// Only <see cref="InStock"/> is issuable.
/// </summary>
public enum SerialStatus
{
InStock,
Reserved,
/// <summary>Dispatched on a stock transfer, not yet received — in no warehouse.</summary>
InTransit,
Sold,
ReturnedByCustomer,
ReturnedToVendor,
Damaged,
Scrapped,
/// <summary>
/// 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.
/// </summary>
IssuedUntracked
}
@@ -0,0 +1,10 @@
namespace ERPCore.Domain.Enums;
/// <summary>State of a <see cref="Entities.WarrantyRegistration"/>. Stored as a string.</summary>
public enum WarrantyStatus
{
Active,
/// <summary>The unit came back on a sales return — the warranty no longer applies.</summary>
Voided,
Claimed
}
+12 -2
View File
@@ -7,12 +7,16 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths); public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths);
/// <summary>A serial captured on a GRN line; <see cref="SerialId"/> is set once the GRN is confirmed.</summary>
public sealed record GrnLineSerialDto(string SerialNo, int? SerialId);
public sealed record GrnLineDto( public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int? BinId, int GrnLineId, int? PoLineId, int ItemId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice, decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers); HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers,
string? BatchNo, DateOnly? BatchExpiryDate, IReadOnlyList<GrnLineSerialDto> Serials);
public sealed record GrnDto( public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, 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; } public BatchInput? Batch { get; set; }
/// <summary> /// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>), when the item is /// Required, one per unit (count must equal <see cref="Qty"/>), when the item is
/// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>). Ignored otherwise. /// warranty-tracked (<c>Item.Warranty == Warranty.Warranty</c>) and not serial-tracked —
/// a serial item's serial is its warranty identity. Ignored otherwise.
/// </summary> /// </summary>
public List<string>? WarrantyNumbers { get; set; } public List<string>? WarrantyNumbers { get; set; }
/// <summary>
/// Required, one per unit (count must equal <see cref="Qty"/>, so Qty must be whole), when the
/// item is serial-tracked (<c>Item.TrackingMode == Serial</c>). Rejected for any other item.
/// </summary>
public List<string>? SerialNumbers { get; set; }
} }
public sealed class CreateGrnRequest public sealed class CreateGrnRequest
@@ -25,6 +25,12 @@ public sealed class CreatePurchaseReturnLineInput
public int? GrnLineId { get; set; } public int? GrnLineId { get; set; }
[Required] public int ItemId { get; set; } [Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// 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
/// <c>ReturnedToVendor</c>. Rejected for any other item.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreatePurchaseReturnRequest public sealed class CreatePurchaseReturnRequest
+12 -1
View File
@@ -7,7 +7,11 @@ public sealed record SalesInvoiceLineDto(
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, 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<SalesLineSerialDto> Serials);
/// <summary>A serial chosen on (or sold by) a sales line.</summary>
public sealed record SalesLineSerialDto(int SerialId, string SerialNo);
public sealed record SalesInvoiceTotalsDto( public sealed record SalesInvoiceTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, 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; } [Range(0, 100)] public decimal? TaxPct { get; set; }
public bool IsFreeIssue { get; set; } public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; } public int? ParentLineId { get; set; }
/// <summary>Pins the line to one batch (must belong to the item, not expired). Omit to let posting pick FEFO.</summary>
public int? BatchId { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreateSalesInvoiceRequest public sealed class CreateSalesInvoiceRequest
+28 -2
View File
@@ -5,7 +5,17 @@ namespace ERPCore.Dtos.Sales;
// Responses ----------------------------------------------------------------- // 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<SalesLineSerialDto> Serials);
/// <summary>
/// What one posted sales invoice line issued, net of returns — the batches/serials a return
/// against it can take back.
/// </summary>
public sealed record SalesLineAllocationDto(
int? BatchId, string? BatchNo, DateOnly? ExpiryDate, int? SerialId, string? SerialNo,
decimal Qty, decimal QtyReturned);
public sealed record SalesReturnDto( public sealed record SalesReturnDto(
int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, 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 <c>Qty</c> /// whatever has already been returned against it. The invoice line's own <c>Qty</c>
/// is never mutated by a return, so this is computed on read from return history. /// is never mutated by a return, so this is computed on read from return history.
/// </summary> /// </summary>
public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty); public sealed record SalesInvoiceLineRemainingDto(
int SalesInvoiceLineId, decimal RemainingQty, IReadOnlyList<SalesLineAllocationDto> Allocations);
// Requests -------------------------------------------------------------------- // Requests --------------------------------------------------------------------
@@ -32,6 +43,16 @@ public sealed class CreateSalesReturnLineInput
public int? SalesInvoiceLineId { get; set; } public int? SalesInvoiceLineId { get; set; }
[Required] public int ItemId { get; set; } [Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// 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.
/// </summary>
public int? BatchId { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreateSalesReturnRequest public sealed class CreateSalesReturnRequest
@@ -40,5 +61,10 @@ public sealed class CreateSalesReturnRequest
[Required] public int WarehouseId { get; set; } [Required] public int WarehouseId { get; set; }
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary> /// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
public int? ReasonCodeId { get; set; } public int? ReasonCodeId { get; set; }
/// <summary>
/// Returned serials become <c>Damaged</c> (not resaleable) instead of <c>InStock</c>. Omit to
/// derive it from the reason: <c>DEF</c> (Defective) and <c>QREJ</c> (Quality Reject) mean damaged.
/// </summary>
public bool? MarkDamaged { get; set; }
[Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new(); [Required, MinLength(1)] public List<CreateSalesReturnLineInput> Lines { get; set; } = new();
} }
+7 -2
View File
@@ -8,7 +8,7 @@ public sealed record SalesSlipLineDto(
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, 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, int? SerialId); int? BatchId, int? SerialId, IReadOnlyList<SalesLineSerialDto> Serials);
public sealed record SalesSlipTotalsDto( public sealed record SalesSlipTotalsDto(
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
@@ -62,8 +62,13 @@ public sealed class CreateSalesSlipLineRequest
public int? ParentLineId { get; set; } public int? ParentLineId { get; set; }
/// <summary>Pins the line to a specific batch (must belong to <see cref="ItemId"/>), consumed via FIFO.</summary> /// <summary>Pins the line to a specific batch (must belong to <see cref="ItemId"/>), consumed via FIFO.</summary>
public int? BatchId { get; set; } public int? BatchId { get; set; }
/// <summary>Pins the line to a specific serial (must belong to <see cref="ItemId"/>); marked Sold on posting.</summary> /// <summary>Legacy single-serial pin — treated as <c>SerialIds = [SerialId]</c> when <see cref="SerialIds"/> is empty.</summary>
public int? SerialId { get; set; } public int? SerialId { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreateSalesSlipRequest public sealed class CreateSalesSlipRequest
+11 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.5) ------------------------------------------------------ // 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( public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status, 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; } public int? BatchId { get; set; }
/// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary> /// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary>
public decimal QtyDelta { get; set; } public decimal QtyDelta { get; set; }
/// <summary>
/// Negative delta on a serial-tracked item: the serials written off (InStock or Damaged at
/// the warehouse, one per unit) — they become <c>Scrapped</c>.
/// </summary>
public List<int>? SerialIds { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<string>? SerialNos { get; set; }
} }
public sealed class CreateAdjustmentRequest public sealed class CreateAdjustmentRequest
@@ -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).
/// <summary>Product Configuration tracking switches, as every screen needs them.</summary>
public sealed record TrackingSettingsDto(bool SerialNumbersEnabled);
/// <summary>An issuable serial, for sale/transfer/return pickers.</summary>
public sealed record AvailableSerialDto(int SerialId, string SerialNo, int? BatchId, string? BatchNo, SerialStatus Status);
/// <summary>Issuable stock of one batch at a warehouse (BatchId null = unbatched stock).</summary>
public sealed record AvailableBatchDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry);
/// <summary>A FEFO pick: <see cref="Qty"/> of <see cref="BatchId"/>.</summary>
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<SerialMovementDto> History, IReadOnlyList<WarrantyRegistrationDto> 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);
/// <summary>Stock on hand per batch (with expiry), for the batch / expiry report.</summary>
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);
+13 -1
View File
@@ -6,7 +6,11 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.4) ------------------------------------------------------ // Responses (docs/11 §5.4) ------------------------------------------------------
public sealed record TransferLineDto( 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<TransferLineSerialDto> Serials);
/// <summary>A serial on a transfer line; <see cref="Received"/> once it has arrived.</summary>
public sealed record TransferLineSerialDto(int SerialId, string SerialNo, bool Received);
public sealed record TransferDto( public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId, int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
@@ -34,8 +38,11 @@ public sealed class CreateTransferLineInput
[Required] public int ItemId { get; set; } [Required] public int ItemId { get; set; }
public int? SrcBinId { get; set; } public int? SrcBinId { get; set; }
public int? DestBinId { get; set; } public int? DestBinId { get; set; }
/// <summary>Batch to move. Omitted for a batch-tracked item, the line is split FEFO across batches.</summary>
public int? BatchId { get; set; } public int? BatchId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>Serials to move — required for a serial-tracked item, one per unit, each InStock at the source.</summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class CreateTransferRequest public sealed class CreateTransferRequest
@@ -52,6 +59,11 @@ public sealed class ReceiveTransferLineInput
{ {
[Required] public int TransferLineId { get; set; } [Required] public int TransferLineId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// For a serial line: which in-transit serials arrived (count = Qty). May be omitted when
/// receiving everything still outstanding on the line.
/// </summary>
public List<int>? SerialIds { get; set; }
} }
public sealed class ReceiveTransferRequest public sealed class ReceiveTransferRequest
@@ -34,7 +34,9 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
builder.HasKey(s => s.SerialId); builder.HasKey(s => s.SerialId);
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100); 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<string>().IsRequired().HasMaxLength(20);
builder.Property(s => s.Barcode).HasMaxLength(100); builder.Property(s => s.Barcode).HasMaxLength(100);
builder.HasIndex(s => s.Barcode).IsUnique(); builder.HasIndex(s => s.Barcode).IsUnique();
@@ -43,7 +45,12 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
.WithMany() .WithMany()
.HasForeignKey(s => s.ItemId) .HasForeignKey(s => s.ItemId)
.OnDelete(DeleteBehavior.Restrict); .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.SerialNo }).IsUnique();
builder.HasIndex(s => new { s.ItemId, s.Status });
builder.HasIndex(s => new { s.WarehouseId, s.ItemId, s.Status });
} }
} }
@@ -74,3 +74,23 @@ public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguratio
builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique(); builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique();
} }
} }
public sealed class GrnLineSerialConfiguration : IEntityTypeConfiguration<GrnLineSerial>
{
public void Configure(EntityTypeBuilder<GrnLineSerial> 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();
}
}
@@ -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<SalesInvoiceLineSerial>
{
public void Configure(EntityTypeBuilder<SalesInvoiceLineSerial> 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<SalesSlipLineSerial>
{
public void Configure(EntityTypeBuilder<SalesSlipLineSerial> 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<SalesLineAllocation>
{
public void Configure(EntityTypeBuilder<SalesLineAllocation> 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<StockTransferLineSerial>
{
public void Configure(EntityTypeBuilder<StockTransferLineSerial> 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<Batch>().WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.TransferLineId, x.SerialId }).IsUnique();
}
}
public sealed class SerialMovementConfiguration : IEntityTypeConfiguration<SerialMovement>
{
public void Configure(EntityTypeBuilder<SerialMovement> builder)
{
builder.ToTable("serial_movements");
builder.HasKey(x => x.SerialMovementId);
builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50);
builder.Property(x => x.Status).HasConversion<string>().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<WarrantyRegistration>
{
public void Configure(EntityTypeBuilder<WarrantyRegistration> 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<string>().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 });
}
}
@@ -111,6 +111,11 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration<Sal
.HasForeignKey(x => x.WarehouseId) .HasForeignKey(x => x.WarehouseId)
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Batch)
.WithMany()
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.PriceSource).HasMaxLength(50); builder.Property(x => x.PriceSource).HasMaxLength(50);
} }
} }
@@ -37,5 +37,20 @@ public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration<Sale
builder.HasOne(l => l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade); builder.HasOne(l => 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.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.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<SalesReturnLineSerial>
{
public void Configure(EntityTypeBuilder<SalesReturnLineSerial> 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();
} }
} }
@@ -40,6 +40,6 @@ public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration<
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); builder.HasOne<Bin>().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); builder.HasOne<Batch>().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Serial>().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict);
} }
} }
@@ -93,6 +93,14 @@ public class ErpDbContext : DbContext
// --- Batch / Serial (docs/10 Part C.4) --- // --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>(); public DbSet<Batch> Batches => Set<Batch>();
public DbSet<Serial> Serials => Set<Serial>(); public DbSet<Serial> Serials => Set<Serial>();
public DbSet<GrnLineSerial> GrnLineSerials => Set<GrnLineSerial>();
public DbSet<SalesInvoiceLineSerial> SalesInvoiceLineSerials => Set<SalesInvoiceLineSerial>();
public DbSet<SalesSlipLineSerial> SalesSlipLineSerials => Set<SalesSlipLineSerial>();
public DbSet<SalesLineAllocation> SalesLineAllocations => Set<SalesLineAllocation>();
public DbSet<WarrantyRegistration> WarrantyRegistrations => Set<WarrantyRegistration>();
public DbSet<SalesReturnLineSerial> SalesReturnLineSerials => Set<SalesReturnLineSerial>();
public DbSet<SerialMovement> SerialMovements => Set<SerialMovement>();
public DbSet<StockTransferLineSerial> StockTransferLineSerials => Set<StockTransferLineSerial>();
// --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) --- // --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) ---
public DbSet<StockLayer> StockLayers => Set<StockLayer>(); public DbSet<StockLayer> StockLayers => Set<StockLayer>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,635 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddItemTrackingSerialsWarranty : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BinId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "serials",
type: "datetime(6)",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<int>(
name: "GrnLineId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ReceivedAt",
table: "serials",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<uint>(
name: "RowVersion",
table: "serials",
type: "int unsigned",
nullable: false,
defaultValue: 0u);
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "serials",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WarehouseId",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "WarrantyPeriodMonths",
table: "serials",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BatchId",
table: "sales_return_lines",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BatchId",
table: "sales_invoice_lines",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "grn_line_serials",
columns: table => new
{
GrnLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
GrnLineId = table.Column<int>(type: "int", nullable: false),
SerialNo = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"),
SerialId = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_grn_line_serials", x => x.GrnLineSerialId);
table.ForeignKey(
name: "FK_grn_line_serials_grn_lines_GrnLineId",
column: x => x.GrnLineId,
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_grn_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_invoice_line_serials",
columns: table => new
{
SalesInvoiceLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SalesInvoiceLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_invoice_line_serials", x => x.SalesInvoiceLineSerialId);
table.ForeignKey(
name: "FK_sales_invoice_line_serials_sales_invoice_lines_SalesInvoiceL~",
column: x => x.SalesInvoiceLineId,
principalTable: "sales_invoice_lines",
principalColumn: "SalesInvoiceLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_invoice_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_line_allocations",
columns: table => new
{
SalesLineAllocationId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
SourceLineId = table.Column<int>(type: "int", nullable: false),
ItemId = table.Column<int>(type: "int", nullable: false),
WarehouseId = table.Column<int>(type: "int", nullable: false),
BatchId = table.Column<int>(type: "int", nullable: true),
SerialId = table.Column<int>(type: "int", nullable: true),
Qty = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
UnitCost = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false),
QtyReturned = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_line_allocations", x => x.SalesLineAllocationId);
table.ForeignKey(
name: "FK_sales_line_allocations_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_sales_line_allocations_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_return_line_serials",
columns: table => new
{
SalesReturnLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ReturnLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_return_line_serials", x => x.SalesReturnLineSerialId);
table.ForeignKey(
name: "FK_sales_return_line_serials_sales_return_lines_ReturnLineId",
column: x => x.ReturnLineId,
principalTable: "sales_return_lines",
principalColumn: "ReturnLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_return_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "sales_slip_line_serials",
columns: table => new
{
SalesSlipLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SalesSlipLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sales_slip_line_serials", x => x.SalesSlipLineSerialId);
table.ForeignKey(
name: "FK_sales_slip_line_serials_sales_slip_lines_SalesSlipLineId",
column: x => x.SalesSlipLineId,
principalTable: "sales_slip_lines",
principalColumn: "SalesSlipLineId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_sales_slip_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "serial_movements",
columns: table => new
{
SerialMovementId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
SerialId = table.Column<int>(type: "int", nullable: false),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
FromWarehouseId = table.Column<int>(type: "int", nullable: true),
WarehouseId = table.Column<int>(type: "int", nullable: true),
UserId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_serial_movements", x => x.SerialMovementId);
table.ForeignKey(
name: "FK_serial_movements_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "stock_transfer_line_serials",
columns: table => new
{
StockTransferLineSerialId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
TransferLineId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: false),
BatchId = table.Column<int>(type: "int", nullable: true),
UnitCost = table.Column<decimal>(type: "decimal(18,6)", precision: 18, scale: 6, nullable: true),
ReceivedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_stock_transfer_line_serials", x => x.StockTransferLineSerialId);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_batches_BatchId",
column: x => x.BatchId,
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_stock_transfer_line_serials_stock_transfer_lines_TransferLin~",
column: x => x.TransferLineId,
principalTable: "stock_transfer_lines",
principalColumn: "TransferLineId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateTable(
name: "warranty_registrations",
columns: table => new
{
WarrantyRegistrationId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ItemId = table.Column<int>(type: "int", nullable: false),
SerialId = table.Column<int>(type: "int", nullable: true),
CustomerId = table.Column<int>(type: "int", nullable: false),
SourceDocType = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"),
SourceDocId = table.Column<int>(type: "int", nullable: false),
SourceLineId = table.Column<int>(type: "int", nullable: false),
Qty = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
StartDate = table.Column<DateOnly>(type: "date", nullable: false),
PeriodMonths = table.Column<int>(type: "int", nullable: false),
EndDate = table.Column<DateOnly>(type: "date", nullable: false),
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_warranty_registrations", x => x.WarrantyRegistrationId);
table.ForeignKey(
name: "FK_warranty_registrations_customers_CustomerId",
column: x => x.CustomerId,
principalTable: "customers",
principalColumn: "CustomerId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_warranty_registrations_items_ItemId",
column: x => x.ItemId,
principalTable: "items",
principalColumn: "ItemId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_warranty_registrations_serials_SerialId",
column: x => x.SerialId,
principalTable: "serials",
principalColumn: "SerialId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_serials_BinId",
table: "serials",
column: "BinId");
migrationBuilder.CreateIndex(
name: "IX_serials_GrnLineId",
table: "serials",
column: "GrnLineId");
migrationBuilder.CreateIndex(
name: "IX_serials_ItemId_Status",
table: "serials",
columns: new[] { "ItemId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_serials_WarehouseId_ItemId_Status",
table: "serials",
columns: new[] { "WarehouseId", "ItemId", "Status" });
migrationBuilder.CreateIndex(
name: "IX_sales_return_lines_BatchId",
table: "sales_return_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_lines_BatchId",
table: "sales_invoice_lines",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_grn_line_serials_GrnLineId_SerialNo",
table: "grn_line_serials",
columns: new[] { "GrnLineId", "SerialNo" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_grn_line_serials_SerialId",
table: "grn_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_line_serials_SalesInvoiceLineId_SerialId",
table: "sales_invoice_line_serials",
columns: new[] { "SalesInvoiceLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_invoice_line_serials_SerialId",
table: "sales_invoice_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_BatchId",
table: "sales_line_allocations",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_SerialId",
table: "sales_line_allocations",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_line_allocations_SourceDocType_SourceDocId_SourceLineId",
table: "sales_line_allocations",
columns: new[] { "SourceDocType", "SourceDocId", "SourceLineId" });
migrationBuilder.CreateIndex(
name: "IX_sales_return_line_serials_ReturnLineId_SerialId",
table: "sales_return_line_serials",
columns: new[] { "ReturnLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_return_line_serials_SerialId",
table: "sales_return_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_sales_slip_line_serials_SalesSlipLineId_SerialId",
table: "sales_slip_line_serials",
columns: new[] { "SalesSlipLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sales_slip_line_serials_SerialId",
table: "sales_slip_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_serial_movements_SerialId_SerialMovementId",
table: "serial_movements",
columns: new[] { "SerialId", "SerialMovementId" });
migrationBuilder.CreateIndex(
name: "IX_serial_movements_SourceDocType_SourceDocId",
table: "serial_movements",
columns: new[] { "SourceDocType", "SourceDocId" });
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_BatchId",
table: "stock_transfer_line_serials",
column: "BatchId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_SerialId",
table: "stock_transfer_line_serials",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_stock_transfer_line_serials_TransferLineId_SerialId",
table: "stock_transfer_line_serials",
columns: new[] { "TransferLineId", "SerialId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_CustomerId",
table: "warranty_registrations",
column: "CustomerId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_ItemId",
table: "warranty_registrations",
column: "ItemId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_SerialId",
table: "warranty_registrations",
column: "SerialId");
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_SourceDocType_SourceDocId",
table: "warranty_registrations",
columns: new[] { "SourceDocType", "SourceDocId" });
migrationBuilder.CreateIndex(
name: "IX_warranty_registrations_Status_EndDate",
table: "warranty_registrations",
columns: new[] { "Status", "EndDate" });
migrationBuilder.AddForeignKey(
name: "FK_sales_invoice_lines_batches_BatchId",
table: "sales_invoice_lines",
column: "BatchId",
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_sales_return_lines_batches_BatchId",
table: "sales_return_lines",
column: "BatchId",
principalTable: "batches",
principalColumn: "BatchId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_bins_BinId",
table: "serials",
column: "BinId",
principalTable: "bins",
principalColumn: "BinId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_grn_lines_GrnLineId",
table: "serials",
column: "GrnLineId",
principalTable: "grn_lines",
principalColumn: "GrnLineId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_serials_warehouses_WarehouseId",
table: "serials",
column: "WarehouseId",
principalTable: "warehouses",
principalColumn: "WarehouseId",
onDelete: ReferentialAction.Restrict);
// Backfill serial rows that predate this migration: a real CreatedAt, a live
// concurrency token (0 is never live — see ErpDbContext.BumpRowVersions), and the
// warehouse of an in-stock serial's own open layer, if it has one.
migrationBuilder.Sql("""
UPDATE `serials` SET `CreatedAt` = UTC_TIMESTAMP(6) WHERE `CreatedAt` < '1900-01-01';
UPDATE `serials` SET `RowVersion` = 1 WHERE `RowVersion` = 0;
UPDATE `serials` s
JOIN `stock_layers` l ON l.`SerialId` = s.`SerialId` AND l.`QtyRemaining` > 0
SET s.`WarehouseId` = l.`WarehouseId`
WHERE s.`Status` = 'InStock' AND s.`WarehouseId` IS NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_sales_invoice_lines_batches_BatchId",
table: "sales_invoice_lines");
migrationBuilder.DropForeignKey(
name: "FK_sales_return_lines_batches_BatchId",
table: "sales_return_lines");
migrationBuilder.DropForeignKey(
name: "FK_serials_bins_BinId",
table: "serials");
migrationBuilder.DropForeignKey(
name: "FK_serials_grn_lines_GrnLineId",
table: "serials");
migrationBuilder.DropForeignKey(
name: "FK_serials_warehouses_WarehouseId",
table: "serials");
migrationBuilder.DropTable(
name: "grn_line_serials");
migrationBuilder.DropTable(
name: "sales_invoice_line_serials");
migrationBuilder.DropTable(
name: "sales_line_allocations");
migrationBuilder.DropTable(
name: "sales_return_line_serials");
migrationBuilder.DropTable(
name: "sales_slip_line_serials");
migrationBuilder.DropTable(
name: "serial_movements");
migrationBuilder.DropTable(
name: "stock_transfer_line_serials");
migrationBuilder.DropTable(
name: "warranty_registrations");
migrationBuilder.DropIndex(
name: "IX_serials_BinId",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_GrnLineId",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_ItemId_Status",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_serials_WarehouseId_ItemId_Status",
table: "serials");
migrationBuilder.DropIndex(
name: "IX_sales_return_lines_BatchId",
table: "sales_return_lines");
migrationBuilder.DropIndex(
name: "IX_sales_invoice_lines_BatchId",
table: "sales_invoice_lines");
migrationBuilder.DropColumn(
name: "BinId",
table: "serials");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "GrnLineId",
table: "serials");
migrationBuilder.DropColumn(
name: "ReceivedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "RowVersion",
table: "serials");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "serials");
migrationBuilder.DropColumn(
name: "WarehouseId",
table: "serials");
migrationBuilder.DropColumn(
name: "WarrantyPeriodMonths",
table: "serials");
migrationBuilder.DropColumn(
name: "BatchId",
table: "sales_return_lines");
migrationBuilder.DropColumn(
name: "BatchId",
table: "sales_invoice_lines");
}
}
}
@@ -1693,6 +1693,35 @@ namespace ERPCore.Migrations
b.ToTable("grn_lines", (string)null); b.ToTable("grn_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b =>
{
b.Property<int>("GrnLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("GrnLineSerialId"));
b.Property<int>("GrnLineId")
.HasColumnType("int");
b.Property<int?>("SerialId")
.HasColumnType("int");
b.Property<string>("SerialNo")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.HasKey("GrnLineSerialId");
b.HasIndex("SerialId");
b.HasIndex("GrnLineId", "SerialNo")
.IsUnique();
b.ToTable("grn_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
{ {
b.Property<int>("GrnLineWarrantyNumberId") b.Property<int>("GrnLineWarrantyNumberId")
@@ -4740,6 +4769,9 @@ namespace ERPCore.Migrations
.HasPrecision(18, 4) .HasPrecision(18, 4)
.HasColumnType("decimal(18,4)"); .HasColumnType("decimal(18,4)");
b.Property<int?>("BatchId")
.HasColumnType("int");
b.Property<string>("Description") b.Property<string>("Description")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@@ -4810,6 +4842,8 @@ namespace ERPCore.Migrations
b.HasKey("SalesInvoiceLineId"); b.HasKey("SalesInvoiceLineId");
b.HasIndex("BatchId");
b.HasIndex("ItemId"); b.HasIndex("ItemId");
b.HasIndex("SalesInvoiceId"); b.HasIndex("SalesInvoiceId");
@@ -4819,6 +4853,30 @@ namespace ERPCore.Migrations
b.ToTable("sales_invoice_lines", (string)null); b.ToTable("sales_invoice_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b =>
{
b.Property<int>("SalesInvoiceLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SalesInvoiceLineSerialId"));
b.Property<int>("SalesInvoiceLineId")
.HasColumnType("int");
b.Property<int>("SerialId")
.HasColumnType("int");
b.HasKey("SalesInvoiceLineSerialId");
b.HasIndex("SerialId");
b.HasIndex("SalesInvoiceLineId", "SerialId")
.IsUnique();
b.ToTable("sales_invoice_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
{ {
b.Property<int>("SalesInvoicePaymentId") b.Property<int>("SalesInvoicePaymentId")
@@ -4868,6 +4926,63 @@ namespace ERPCore.Migrations
b.ToTable("sales_invoice_payments", (string)null); b.ToTable("sales_invoice_payments", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b =>
{
b.Property<int>("SalesLineAllocationId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SalesLineAllocationId"));
b.Property<int?>("BatchId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<decimal>("QtyReturned")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<int?>("SerialId")
.HasColumnType("int");
b.Property<int>("SourceDocId")
.HasColumnType("int");
b.Property<string>("SourceDocType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int>("SourceLineId")
.HasColumnType("int");
b.Property<decimal>("UnitCost")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.Property<int>("WarehouseId")
.HasColumnType("int");
b.HasKey("SalesLineAllocationId");
b.HasIndex("BatchId");
b.HasIndex("SerialId");
b.HasIndex("SourceDocType", "SourceDocId", "SourceLineId");
b.ToTable("sales_line_allocations", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{ {
b.Property<int>("ReturnId") b.Property<int>("ReturnId")
@@ -4932,6 +5047,9 @@ namespace ERPCore.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("ReturnLineId")); MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("ReturnLineId"));
b.Property<int?>("BatchId")
.HasColumnType("int");
b.Property<int>("ItemId") b.Property<int>("ItemId")
.HasColumnType("int"); .HasColumnType("int");
@@ -4947,6 +5065,8 @@ namespace ERPCore.Migrations
b.HasKey("ReturnLineId"); b.HasKey("ReturnLineId");
b.HasIndex("BatchId");
b.HasIndex("ItemId"); b.HasIndex("ItemId");
b.HasIndex("ReturnId"); b.HasIndex("ReturnId");
@@ -4956,6 +5076,30 @@ namespace ERPCore.Migrations
b.ToTable("sales_return_lines", (string)null); b.ToTable("sales_return_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b =>
{
b.Property<int>("SalesReturnLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SalesReturnLineSerialId"));
b.Property<int>("ReturnLineId")
.HasColumnType("int");
b.Property<int>("SerialId")
.HasColumnType("int");
b.HasKey("SalesReturnLineSerialId");
b.HasIndex("SerialId");
b.HasIndex("ReturnLineId", "SerialId")
.IsUnique();
b.ToTable("sales_return_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{ {
b.Property<int>("SalesSlipId") b.Property<int>("SalesSlipId")
@@ -5151,6 +5295,30 @@ namespace ERPCore.Migrations
b.ToTable("sales_slip_lines", (string)null); b.ToTable("sales_slip_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b =>
{
b.Property<int>("SalesSlipLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SalesSlipLineSerialId"));
b.Property<int>("SalesSlipLineId")
.HasColumnType("int");
b.Property<int>("SerialId")
.HasColumnType("int");
b.HasKey("SalesSlipLineSerialId");
b.HasIndex("SerialId");
b.HasIndex("SalesSlipLineId", "SerialId")
.IsUnique();
b.ToTable("sales_slip_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{ {
b.Property<int>("SerialId") b.Property<int>("SerialId")
@@ -5163,9 +5331,25 @@ namespace ERPCore.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("varchar(100)"); .HasColumnType("varchar(100)");
b.Property<int?>("BinId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("GrnLineId")
.HasColumnType("int");
b.Property<int>("ItemId") b.Property<int>("ItemId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<DateTime?>("ReceivedAt")
.HasColumnType("datetime(6)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<string>("SerialNo") b.Property<string>("SerialNo")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -5176,17 +5360,79 @@ namespace ERPCore.Migrations
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("varchar(20)"); .HasColumnType("varchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("WarehouseId")
.HasColumnType("int");
b.Property<int?>("WarrantyPeriodMonths")
.HasColumnType("int");
b.HasKey("SerialId"); b.HasKey("SerialId");
b.HasIndex("Barcode") b.HasIndex("Barcode")
.IsUnique(); .IsUnique();
b.HasIndex("BinId");
b.HasIndex("GrnLineId");
b.HasIndex("ItemId", "SerialNo") b.HasIndex("ItemId", "SerialNo")
.IsUnique(); .IsUnique();
b.HasIndex("ItemId", "Status");
b.HasIndex("WarehouseId", "ItemId", "Status");
b.ToTable("serials", (string)null); b.ToTable("serials", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b =>
{
b.Property<int>("SerialMovementId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("SerialMovementId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("FromWarehouseId")
.HasColumnType("int");
b.Property<int>("SerialId")
.HasColumnType("int");
b.Property<int>("SourceDocId")
.HasColumnType("int");
b.Property<string>("SourceDocType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int?>("WarehouseId")
.HasColumnType("int");
b.HasKey("SerialMovementId");
b.HasIndex("SerialId", "SerialMovementId");
b.HasIndex("SourceDocType", "SourceDocId");
b.ToTable("serial_movements", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b =>
{ {
b.Property<int>("EdgeId") b.Property<int>("EdgeId")
@@ -5744,6 +5990,42 @@ namespace ERPCore.Migrations
b.ToTable("stock_transfer_lines", (string)null); b.ToTable("stock_transfer_lines", (string)null);
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b =>
{
b.Property<int>("StockTransferLineSerialId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("StockTransferLineSerialId"));
b.Property<int?>("BatchId")
.HasColumnType("int");
b.Property<DateTime?>("ReceivedAt")
.HasColumnType("datetime(6)");
b.Property<int>("SerialId")
.HasColumnType("int");
b.Property<int>("TransferLineId")
.HasColumnType("int");
b.Property<decimal?>("UnitCost")
.HasPrecision(18, 6)
.HasColumnType("decimal(18,6)");
b.HasKey("StockTransferLineSerialId");
b.HasIndex("BatchId");
b.HasIndex("SerialId");
b.HasIndex("TransferLineId", "SerialId")
.IsUnique();
b.ToTable("stock_transfer_line_serials", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
{ {
b.Property<int>("SubCategoryId") b.Property<int>("SubCategoryId")
@@ -6787,6 +7069,77 @@ namespace ERPCore.Migrations
}); });
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b =>
{
b.Property<int>("WarrantyRegistrationId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("WarrantyRegistrationId"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("CustomerId")
.HasColumnType("int");
b.Property<DateOnly>("EndDate")
.HasColumnType("date");
b.Property<int>("ItemId")
.HasColumnType("int");
b.Property<int>("PeriodMonths")
.HasColumnType("int");
b.Property<decimal>("Qty")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<int?>("SerialId")
.HasColumnType("int");
b.Property<int>("SourceDocId")
.HasColumnType("int");
b.Property<string>("SourceDocType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<int>("SourceLineId")
.HasColumnType("int");
b.Property<DateOnly>("StartDate")
.HasColumnType("date");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("WarrantyRegistrationId");
b.HasIndex("CustomerId");
b.HasIndex("ItemId");
b.HasIndex("SerialId");
b.HasIndex("SourceDocType", "SourceDocId");
b.HasIndex("Status", "EndDate");
b.ToTable("warranty_registrations", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b => modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b =>
{ {
b.Property<int>("WorkShiftId") b.Property<int>("WorkShiftId")
@@ -7306,6 +7659,24 @@ namespace ERPCore.Migrations
b.Navigation("PoLine"); b.Navigation("PoLine");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
.WithMany("Serials")
.HasForeignKey("GrnLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("GrnLine");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
@@ -8143,6 +8514,11 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Item", "Item") b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany() .WithMany()
.HasForeignKey("ItemId") .HasForeignKey("ItemId")
@@ -8161,6 +8537,8 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Batch");
b.Navigation("Item"); b.Navigation("Item");
b.Navigation("SalesInvoice"); b.Navigation("SalesInvoice");
@@ -8168,6 +8546,25 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse"); b.Navigation("Warehouse");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
.WithMany("Serials")
.HasForeignKey("SalesInvoiceLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SalesInvoiceLine");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -8187,6 +8584,23 @@ namespace ERPCore.Migrations
b.Navigation("SalesInvoice"); b.Navigation("SalesInvoice");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b =>
{
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Batch");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "Creator") b.HasOne("ERPCore.Domain.Entities.User", "Creator")
@@ -8224,6 +8638,11 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Item", "Item") b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany() .WithMany()
.HasForeignKey("ItemId") .HasForeignKey("ItemId")
@@ -8241,6 +8660,8 @@ namespace ERPCore.Migrations
.HasForeignKey("SalesInvoiceLineId") .HasForeignKey("SalesInvoiceLineId")
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
b.Navigation("Batch");
b.Navigation("Item"); b.Navigation("Item");
b.Navigation("Return"); b.Navigation("Return");
@@ -8248,6 +8669,25 @@ namespace ERPCore.Migrations
b.Navigation("SalesInvoiceLine"); b.Navigation("SalesInvoiceLine");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.SalesReturnLine", "ReturnLine")
.WithMany("Serials")
.HasForeignKey("ReturnLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ReturnLine");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
@@ -8323,15 +8763,66 @@ namespace ERPCore.Migrations
b.Navigation("Warehouse"); b.Navigation("Warehouse");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.SalesSlipLine", "SalesSlipLine")
.WithMany("Serials")
.HasForeignKey("SalesSlipLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("SalesSlipLine");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Bin", "Bin")
.WithMany()
.HasForeignKey("BinId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
.WithMany()
.HasForeignKey("GrnLineId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Item", "Item") b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany() .WithMany()
.HasForeignKey("ItemId") .HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
.WithMany()
.HasForeignKey("WarehouseId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Bin");
b.Navigation("GrnLine");
b.Navigation("Item"); b.Navigation("Item");
b.Navigation("Warehouse");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b =>
{
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Serial");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b =>
@@ -8470,7 +8961,7 @@ namespace ERPCore.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", null) b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany() .WithMany()
.HasForeignKey("SerialId") .HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
@@ -8478,6 +8969,8 @@ namespace ERPCore.Migrations
b.Navigation("Adjustment"); b.Navigation("Adjustment");
b.Navigation("Item"); b.Navigation("Item");
b.Navigation("Serial");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
@@ -8695,6 +9188,30 @@ namespace ERPCore.Migrations
b.Navigation("Transfer"); b.Navigation("Transfer");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b =>
{
b.HasOne("ERPCore.Domain.Entities.Batch", null)
.WithMany()
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.StockTransferLine", "TransferLine")
.WithMany("Serials")
.HasForeignKey("TransferLineId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Serial");
b.Navigation("TransferLine");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
{ {
b.HasOne("ERPCore.Domain.Entities.Category", "Category") b.HasOne("ERPCore.Domain.Entities.Category", "Category")
@@ -8809,6 +9326,32 @@ namespace ERPCore.Migrations
b.Navigation("Branch"); b.Navigation("Branch");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b =>
{
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
.WithMany()
.HasForeignKey("ItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
.WithMany()
.HasForeignKey("SerialId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Customer");
b.Navigation("Item");
b.Navigation("Serial");
});
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
@@ -8841,6 +9384,8 @@ namespace ERPCore.Migrations
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
{ {
b.Navigation("Serials");
b.Navigation("WarrantyNumbers"); b.Navigation("WarrantyNumbers");
}); });
@@ -8944,16 +9489,31 @@ namespace ERPCore.Migrations
b.Navigation("Payments"); b.Navigation("Payments");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
{
b.Navigation("Serials");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
{
b.Navigation("Serials");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b =>
{
b.Navigation("Serials");
});
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
{ {
b.Navigation("Lines"); b.Navigation("Lines");
@@ -8969,6 +9529,11 @@ namespace ERPCore.Migrations
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b =>
{
b.Navigation("Serials");
});
modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b =>
{ {
b.Navigation("Messages"); b.Navigation("Messages");
+2
View File
@@ -166,6 +166,8 @@ builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
// Stock transactions + reference data (docs/11 §5–6) // Stock transactions + reference data (docs/11 §5–6)
builder.Services.AddScoped<IStockMutator, StockMutator>(); builder.Services.AddScoped<IStockMutator, StockMutator>();
builder.Services.AddScoped<ISerialTrackingService, SerialTrackingService>();
builder.Services.AddScoped<IItemTrackingService, ItemTrackingService>();
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>(); builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
builder.Services.AddScoped<IAdjustmentService, AdjustmentService>(); builder.Services.AddScoped<IAdjustmentService, AdjustmentService>();
builder.Services.AddScoped<ITransferService, TransferService>(); builder.Services.AddScoped<ITransferService, TransferService>();
+29 -8
View File
@@ -36,6 +36,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly ISerialTrackingService _serialTracking;
private readonly IGeneralLedgerService _gl; private readonly IGeneralLedgerService _gl;
private readonly IGlAccountConfigService _glAccounts; private readonly IGlAccountConfigService _glAccounts;
@@ -45,6 +46,7 @@ public sealed class AdjustmentService : IAdjustmentService
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
IGeneralLedgerService gl, IGlAccountConfigService glAccounts) IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_serialTracking = serialTracking;
_adjustments = adjustments; _adjustments = adjustments;
_warehouses = warehouses; _warehouses = warehouses;
_items = items; _items = items;
@@ -155,17 +157,24 @@ public sealed class AdjustmentService : IAdjustmentService
if (reason.Context != ReasonContext.Adjustment) if (reason.Context != ReasonContext.Adjustment)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422); throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
// Serials per line: outbound ones are validated as on hand; inbound ones are created (or
// re-found) in the context now and get their ids on the flush below.
var serialsByLine = new List<IReadOnlyList<Serial>>();
var documentSerials = new HashSet<int>();
var documentSerialNos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in request.Lines) foreach (var line in request.Lines)
{ {
if (line.QtyDelta == 0) if (line.QtyDelta == 0)
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422); throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422);
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
serialsByLine.Add(line.QtyDelta < 0
? await _serialTracking.ResolveOutboundAsync(item, request.WarehouseId, -line.QtyDelta, line.SerialIds,
documentSerials, new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct)
: await _serialTracking.ResolveInboundAsync(item, request.WarehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct));
} }
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var deltas = request.Lines
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList();
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token => var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
{ {
@@ -179,19 +188,31 @@ public sealed class AdjustmentService : IAdjustmentService
Status = AdjustmentStatus.Posted, Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId, CreatedBy = _currentUser.AuditUserId,
CreatedAt = now, CreatedAt = now,
Lines = request.Lines.Select(l => new StockAdjustmentLine Lines = request.Lines.Select((l, i) => new StockAdjustmentLine
{ {
ItemId = l.ItemId, ItemId = l.ItemId,
BinId = l.BinId, BinId = l.BinId,
BatchId = l.BatchId, BatchId = l.BatchId,
QtyDelta = l.QtyDelta QtyDelta = l.QtyDelta,
Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null
}).ToList() }).ToList()
}; };
await _adjustments.AddAsync(entity, token); await _adjustments.AddAsync(entity, token);
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId await _uow.SaveChangesAsync(token); // flush so AdjustmentId (and new SerialIds) are valid
var deltas = request.Lines.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
serialsByLine[i].Select(s => s.SerialId).ToList())).ToList();
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token); var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
for (var i = 0; i < request.Lines.Count; i++)
{
var line = request.Lines[i];
await _serialTracking.MoveAsync(serialsByLine[i],
line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock,
line.QtyDelta < 0 ? null : request.WarehouseId, line.QtyDelta < 0 ? null : line.BinId,
DocumentTypes.Adjustment, entity.AdjustmentId, now, token);
}
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token); entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
if (entity.GlJournalNo is not null) entity.GlPostedAt = now; if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
@@ -207,6 +228,6 @@ public sealed class AdjustmentService : IAdjustmentService
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt, a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.GlJournalNo, a.GlPostedAt, a.GlJournalNo, a.GlPostedAt,
a.Lines.OrderBy(l => l.AdjLineId) a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(), .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId)).ToList(),
ledgerRefs); ledgerRefs);
} }
+38 -5
View File
@@ -50,6 +50,7 @@ public sealed class CountService : ICountService
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches, IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
IRepository<Serial> serials, IGlAccountConfigService glAccounts) IRepository<Serial> serials, IGlAccountConfigService glAccounts)
{ {
_serialTracking = serialTracking;
_counts = counts; _counts = counts;
_adjustments = adjustments; _adjustments = adjustments;
_warehouses = warehouses; _warehouses = warehouses;
@@ -171,17 +172,32 @@ public sealed class CountService : ICountService
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty }); lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty });
} }
// Stock that reached the item without a batch (e.g. an adjustment) is counted on its own line.
var unbatched = await _stockLayers.Query().AsNoTracking()
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == null)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (unbatched != 0)
lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = unbatched });
} }
else if (item.TrackingMode == TrackingMode.Serial) else if (await _serialTracking.IsSerialTrackedAsync(item, ct)) // off: counted as one aggregate line
{ {
// Serials that should be at this warehouse (one line each, counted 0 or 1), plus
// one aggregate line for any pre-tracking serial-less stock still here (D6).
var serials = await _serials.Query().AsNoTracking() var serials = await _serials.Query().AsNoTracking()
.Where(s => s.ItemId == id && s.Status == "InStock").ToListAsync(ct); .Where(s => s.ItemId == id && s.WarehouseId == request.WarehouseId
&& (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged))
.OrderBy(s => s.SerialNo).ToListAsync(ct);
foreach (var serial in serials) foreach (var serial in serials)
{ {
var open = await _stockLayers.Query().AsNoTracking().AnyAsync( var open = await _stockLayers.Query().AsNoTracking().AnyAsync(
l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct); l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct);
lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 }); lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 });
} }
var untracked = await _stockLayers.Query().AsNoTracking()
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == null)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (untracked != 0 || serials.Count == 0)
lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked });
} }
else else
{ {
@@ -225,6 +241,8 @@ public sealed class CountService : ICountService
{ {
var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId) var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId)
?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422);
if (line.SerialId is not null && input.CountedQty is not (0m or 1m))
throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422);
line.CountedQty = input.CountedQty; line.CountedQty = input.CountedQty;
line.Variance = input.CountedQty - line.SystemQty; line.Variance = input.CountedQty - line.SystemQty;
} }
@@ -246,9 +264,11 @@ public sealed class CountService : ICountService
if (count.Status != CountStatus.Counted) if (count.Status != CountStatus.Counted)
throw new ConflictException($"Count {countId} has no entered counts to post."); throw new ConflictException($"Count {countId} has no entered counts to post.");
var deltas = count.Lines // Batch and serial lines adjust exactly their own lot/unit.
.Where(l => l.Variance.HasValue && l.Variance.Value != 0) var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList();
.Select(l => new StockDelta(l.ItemId, l.BinId, null, l.Variance!.Value)) var deltas = varianceLines
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.Variance!.Value,
l.SerialId is int serialId ? new List<int> { serialId } : null))
.ToList(); .ToList();
// No variances → just close the count, no adjustment. // No variances → just close the count, no adjustment.
@@ -280,6 +300,8 @@ public sealed class CountService : ICountService
{ {
ItemId = d.ItemId, ItemId = d.ItemId,
BinId = d.BinId, BinId = d.BinId,
BatchId = d.BatchId,
SerialId = d.SerialIds?.FirstOrDefault(),
QtyDelta = d.QtyDelta QtyDelta = d.QtyDelta
}).ToList() }).ToList()
}; };
@@ -288,6 +310,17 @@ public sealed class CountService : ICountService
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token); var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
// A serial counted missing is written off (Scrapped); one found keeps/regains InStock here.
foreach (var line in varianceLines.Where(l => l.SerialId is not null))
{
var serial = await _serials.Query().FirstAsync(s => s.SerialId == line.SerialId, token);
var found = line.Variance > 0;
await _serialTracking.MoveAsync(new[] { serial },
found ? SerialStatus.InStock : SerialStatus.Scrapped,
found ? count.WarehouseId : null, found ? line.BinId : null,
DocumentTypes.Count, count.CountId, now, token);
}
adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token); adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now; if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now;
+121 -10
View File
@@ -34,6 +34,9 @@ public sealed class GrnService : IGrnService
private readonly IRepository<Bin> _bins; private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors; private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches; private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IRepository<SerialMovement> _serialMovements;
private readonly ISerialTrackingService _serialTracking;
private readonly IRepository<StockLayer> _layers; private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger; private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<TaxRate> _taxRates; private readonly IRepository<TaxRate> _taxRates;
@@ -50,7 +53,7 @@ public sealed class GrnService : IGrnService
public GrnService( public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines, IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, IRepository<Serial> serials, IRepository<SerialMovement> serialMovements, ISerialTrackingService serialTracking,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates, IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts) IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts)
@@ -63,6 +66,9 @@ public sealed class GrnService : IGrnService
_bins = bins; _bins = bins;
_vendors = vendors; _vendors = vendors;
_batches = batches; _batches = batches;
_serials = serials;
_serialMovements = serialMovements;
_serialTracking = serialTracking;
_layers = layers; _layers = layers;
_ledger = ledger; _ledger = ledger;
_taxRates = taxRates; _taxRates = taxRates;
@@ -110,6 +116,8 @@ public sealed class GrnService : IGrnService
{ {
var grn = await _grns.Query().AsNoTracking() var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers) .Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
.Include(g => g.Lines).ThenInclude(l => l.Serials)
.Include(g => g.Lines).ThenInclude(l => l.Batch)
.Include(g => g.PurchaseInvoice) .Include(g => g.PurchaseInvoice)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct); .FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn); return grn is null ? null : Map(grn);
@@ -155,6 +163,7 @@ public sealed class GrnService : IGrnService
var lines = new List<GrnLine>(); var lines = new List<GrnLine>();
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>(); var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
var requestSerials = new HashSet<(int ItemId, string SerialNo)>();
foreach (var input in request.Lines) foreach (var input in request.Lines)
{ {
@@ -203,7 +212,9 @@ public sealed class GrnService : IGrnService
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero); var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct); var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty); var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty,
serialTracked: await _serialTracking.IsSerialTrackedAsync(item, ct));
var serials = await ResolveSerialNumbersAsync(item, input.SerialNumbers, input.Qty, requestSerials, ct);
lines.Add(new GrnLine lines.Add(new GrnLine
{ {
@@ -221,7 +232,8 @@ public sealed class GrnService : IGrnService
ReceivedValue = receivedValue, ReceivedValue = receivedValue,
LineTotal = receivedValue + vatAmount, LineTotal = receivedValue + vatAmount,
HoldStatus = input.HoldStatus, HoldStatus = input.HoldStatus,
WarrantyNumbers = warrantyNumbers WarrantyNumbers = warrantyNumbers,
Serials = serials
}); });
} }
@@ -252,7 +264,7 @@ public sealed class GrnService : IGrnService
public async Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default) public async Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default)
{ {
var grn = await _grns.Query() var grn = await _grns.Query()
.Include(g => g.Lines) .Include(g => g.Lines).ThenInclude(l => l.Serials)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct) .FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found."); ?? throw new NotFoundException($"GRN {grnId} was not found.");
@@ -278,10 +290,21 @@ public sealed class GrnService : IGrnService
var qtyBase = line.Qty; var qtyBase = line.Qty;
var unitCostBase = line.NetUnitCost; var unitCostBase = line.NetUnitCost;
var layer = await _fifo.CreateInboundLayerAsync( if (line.Serials.Count > 0)
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, {
qtyBase, unitCostBase, now, token); // Serial-tracked: one Serial and one qty-1 layer per unit, so a sale can later
createdLayers.Add(layer); // consume exactly the serial it names. The ledger stays one row per line
// (below): every ledger row also posts a GL entry, and per-unit rows would mean
// one GL call per serial — serial history is read from the document tables.
createdLayers.AddRange(await ReceiveSerialsAsync(grn, line, unitCostBase, now, token));
}
else
{
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
qtyBase, unitCostBase, now, token);
createdLayers.Add(layer);
}
var key = (line.ItemId, grn.WarehouseId); var key = (line.ItemId, grn.WarehouseId);
if (!runningBalance.TryGetValue(key, out var bal)) if (!runningBalance.TryGetValue(key, out var bal))
@@ -411,12 +434,16 @@ public sealed class GrnService : IGrnService
/// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>, /// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
/// which the item must have been given at creation (<see cref="ItemService"/> enforces that). /// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
/// </summary> /// </summary>
private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty) private static List<GrnLineWarrantyNumber> ResolveWarrantyNumbers(Item item, List<string>? numbers, decimal qty, bool serialTracked)
{ {
if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>(); if (item.Warranty != Warranty.Warranty) return new List<GrnLineWarrantyNumber>();
if (item.WarrantyPeriodMonths is null) if (item.WarrantyPeriodMonths is null)
throw new DomainException( throw new DomainException(
ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422); ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422);
// A serial item's serial number is its warranty identity (docs/ITEM_TRACKING_PLAN.md D1);
// its coverage is snapshotted onto each Serial at confirm instead. With serial numbers
// switched off the item has no serials, so it takes warranty numbers like any other.
if (serialTracked) return new List<GrnLineWarrantyNumber>();
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
if (trimmed.Count != qty) if (trimmed.Count != qty)
@@ -429,6 +456,88 @@ public sealed class GrnService : IGrnService
return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList(); return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList();
} }
/// <summary>
/// A serial-tracked item requires exactly one serial number per received unit — so the
/// quantity must be whole. Numbers must be unique within the request and must not already
/// exist for the item. Any other item must not carry serial numbers.
/// </summary>
private async Task<List<GrnLineSerial>> ResolveSerialNumbersAsync(
Item item, List<string>? numbers, decimal qty, HashSet<(int, string)> requestSerials, CancellationToken ct)
{
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
if (!await _serialTracking.IsSerialTrackedAsync(item, ct))
{
_serialTracking.RejectSerialsWhileDisabled(item, trimmed.Count);
if (trimmed.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serial numbers are not accepted.", 422);
return new List<GrnLineSerial>();
}
if (qty != decimal.Truncate(qty))
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is serial-tracked; the quantity must be a whole number.", 422);
if (trimmed.Count != qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; provide exactly {qty} serial number(s), got {trimmed.Count}.", 422);
if (trimmed.Any(n => n.Length > 100))
throw new DomainException(ErrorCodes.Validation, "A serial number cannot be longer than 100 characters.", 422);
// Case-insensitive, matching the utf8mb4_unicode_ci unique index on serials (ItemId, SerialNo).
foreach (var n in trimmed)
if (!requestSerials.Add((item.ItemId, n.ToUpperInvariant())))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number {n} for item {item.Sku} is entered more than once.", 422);
await EnsureSerialsFreeAsync(item.ItemId, item.Sku, trimmed, ct);
return trimmed.Select(n => new GrnLineSerial { SerialNo = n }).ToList();
}
private async Task EnsureSerialsFreeAsync(int itemId, string sku, List<string> numbers, CancellationToken ct)
{
var taken = await _serials.Query().AsNoTracking()
.Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo))
.Select(s => s.SerialNo).Take(5).ToListAsync(ct);
if (taken.Count > 0)
throw new DomainException(ErrorCodes.SerialDuplicate,
$"Serial number(s) already exist for item {sku}: {string.Join(", ", taken)}.", 409);
}
/// <summary>
/// Confirm-time half of serial capture: re-checks the numbers are still free (another GRN
/// may have confirmed them since this draft was created — the unique index is the final
/// guard), then creates each <see cref="Serial"/> with its own qty-1 FIFO layer.
/// </summary>
private async Task<List<StockLayer>> ReceiveSerialsAsync(Grn grn, GrnLine line, decimal unitCost, DateTime now, CancellationToken ct)
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct);
await EnsureSerialsFreeAsync(item.ItemId, item.Sku, line.Serials.Select(s => s.SerialNo).ToList(), ct);
var warrantyMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null;
var layers = new List<StockLayer>();
foreach (var captured in line.Serials.OrderBy(s => s.GrnLineSerialId))
{
var serial = new Serial
{
ItemId = line.ItemId,
SerialNo = captured.SerialNo,
Status = SerialStatus.InStock,
WarehouseId = grn.WarehouseId,
BinId = line.BinId,
GrnLineId = line.GrnLineId,
ReceivedAt = now,
CreatedAt = now,
WarrantyPeriodMonths = warrantyMonths
};
await _serials.AddAsync(serial, ct);
captured.Serial = serial; // FK fixed up on SaveChanges
await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.Grn, grn.GrnId, _currentUser.AuditUserId, now), ct);
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, 1m, unitCost, now, ct);
layer.Serial = serial;
layers.Add(layer);
}
return layers;
}
private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct) private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct)
{ {
if (poId is null) return; if (poId is null) return;
@@ -472,5 +581,7 @@ public sealed class GrnService : IGrnService
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero), l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
l.HoldStatus, l.BatchId, l.HoldStatus, l.BatchId,
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList()); l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList(),
l.Batch?.BatchNo, l.Batch?.ExpiryDate,
l.Serials.OrderBy(s => s.GrnLineSerialId).Select(s => new GrnLineSerialDto(s.SerialNo, s.SerialId)).ToList())).ToList());
} }
@@ -31,9 +31,26 @@ public interface IFifoCostingService
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the /// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost. /// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
/// </param> /// </param>
/// <param name="serialIds">
/// Serial-tracked issue: consume exactly these serials' qty-1 layers (count must equal
/// <paramref name="qtyBase"/>), or throw <c>SERIAL_NOT_AVAILABLE</c>. When null, serial layers
/// are never touched — serial stock only leaves by naming its serial — so only untracked
/// (legacy, pre-serial-capture) layers are issued. Shortfall is never allowed with serials.
/// </param>
/// <remarks>
/// Issue order is FEFO then FIFO: layers whose batch expires soonest go first, then oldest
/// receipt. Items without batches therefore keep plain FIFO.
/// </remarks>
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync( Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default, int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false); bool allowShortfall = false, IReadOnlyCollection<int>? serialIds = null);
/// <summary>
/// Read-only (no locks) FEFO preview of how <paramref name="qtyBase"/> would be issued across
/// batches — for UI suggestions. Expired, on-hold and serial layers are excluded.
/// </summary>
Task<IReadOnlyList<BatchAllocation>> SuggestBatchesAsync(
int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default);
/// <summary> /// <summary>
/// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>. /// Append an immutable ledger entry. Value defaults to <c>round(qtyBase × unitCost, 4)</c>.
@@ -60,4 +77,7 @@ public interface IFifoCostingService
} }
/// <summary>A quantity consumed from one FIFO layer at that layer's unit cost.</summary> /// <summary>A quantity consumed from one FIFO layer at that layer's unit cost.</summary>
public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost); public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost, int? BatchId = null, int? SerialId = null);
/// <summary>A suggested batch pick: <see cref="Qty"/> of <see cref="BatchId"/> (null = unbatched stock).</summary>
public sealed record BatchAllocation(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty);
@@ -0,0 +1,46 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Read side of item tracking: pickers, serial and warranty registers, batch stock.</summary>
public interface IItemTrackingService
{
/// <summary>
/// The next <paramref name="count"/> free serial numbers for an item under
/// <paramref name="prefix"/>: continues after the highest number already used with that
/// prefix (received serials and numbers on draft GRNs), zero-padded to at least 4 digits —
/// e.g. prefix "PH-" after PH-0041 gives PH-0042, PH-0043, …
/// </summary>
Task<IReadOnlyList<string>> NextSerialNumbersAsync(int itemId, string prefix, int count, CancellationToken ct = default);
/// <summary>In-stock serials of an item at a warehouse, optionally filtered by number.</summary>
Task<IReadOnlyList<AvailableSerialDto>> GetAvailableSerialsAsync(int itemId, int warehouseId, string? q, int take, CancellationToken ct = default);
/// <summary>Issuable (not expired, not on hold) stock per batch, soonest expiry first.</summary>
Task<IReadOnlyList<AvailableBatchDto>> GetAvailableBatchesAsync(int itemId, int warehouseId, CancellationToken ct = default);
/// <summary>How posting would pick <paramref name="qty"/> FEFO across batches.</summary>
Task<IReadOnlyList<BatchSuggestionDto>> SuggestBatchesAsync(int itemId, int warehouseId, decimal qty, CancellationToken ct = default);
Task<PagedResponse<SerialSummaryDto>> ListSerialsAsync(
PageQuery query, int? itemId, int? warehouseId, SerialStatus? status, CancellationToken ct = default);
Task<SerialDetailDto?> GetSerialAsync(int serialId, CancellationToken ct = default);
/// <summary>Exact serial-number lookup (all items) — e.g. a warranty desk scanning a unit.</summary>
Task<IReadOnlyList<SerialDetailDto>> FindSerialAsync(string serialNo, CancellationToken ct = default);
/// <summary>
/// Warranty register. <paramref name="expiringWithinDays"/> keeps active warranties ending within
/// that many days; <paramref name="expired"/> true/false keeps only expired / unexpired ones.
/// </summary>
Task<PagedResponse<WarrantyRegistrationDto>> ListWarrantiesAsync(
PageQuery query, int? customerId, int? itemId, string? serialNo, WarrantyStatus? status,
int? expiringWithinDays, bool? expired, CancellationToken ct = default);
/// <summary>Batch stock on hand (positive), optionally only batches expiring within N days (expired included).</summary>
Task<PagedResponse<BatchStockDto>> ListBatchStockAsync(
PageQuery query, int? itemId, int? warehouseId, int? expiringWithinDays, CancellationToken ct = default);
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums; using ERPCore.Domain.Enums;
using ERPCore.Dtos.Sales; using ERPCore.Dtos.Sales;
@@ -63,6 +64,18 @@ public interface ISalesDomainService
bool isFreeIssue); bool isFreeIssue);
Task<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default); Task<bool> IsStockedItemAsync(int itemId, CancellationToken ct = default);
/// <summary>
/// Batch/serial rules for one sales line (docs/ITEM_TRACKING_PLAN.md Phase 4). A serial-tracked
/// item needs one serial per unit of <paramref name="totalQty"/>, each InStock at
/// <paramref name="warehouseId"/> and not already used on another line of the same document
/// (<paramref name="documentSerials"/>) — unless enough pre-tracking (serial-less) stock is
/// still on hand there (D6). A pinned batch must belong to the item and not be expired.
/// Returns the chosen serials as tracked entities, for the line's serial rows.
/// </summary>
Task<IReadOnlyList<Serial>> ValidateLineTrackingAsync(
int itemId, int warehouseId, decimal totalQty, int? batchId, IReadOnlyCollection<int>? serialIds,
ISet<int> documentSerials, CancellationToken ct = default);
} }
public sealed record SalesLineComputation( public sealed record SalesLineComputation(
@@ -0,0 +1,55 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Shared serial rules for stock documents other than GRN and sales — purchase returns,
/// transfers, adjustments and counts (docs/ITEM_TRACKING_PLAN.md Phases 6–7). Validation is
/// done before the document's transaction; <see cref="MoveAsync"/> runs inside it.
/// </summary>
public interface ISerialTrackingService
{
/// <summary>
/// Product Configuration "Serial numbers" switch (<see cref="ProductConfig.SerialNumbersEnabled"/>).
/// Off: no document asks for or accepts serials, and serial-mode items are handled as
/// untracked stock. Defaults to on when the config row is missing.
/// </summary>
Task<bool> IsEnabledAsync(CancellationToken ct = default);
/// <summary>True when <paramref name="item"/> is serial-tracked and the switch is on.</summary>
Task<bool> IsSerialTrackedAsync(Item item, CancellationToken ct = default);
/// <summary>
/// Throws <c>CONFIG_DISABLED</c> when serials were sent for a serial-mode item while the switch
/// is off — they are refused rather than silently dropped.
/// </summary>
void RejectSerialsWhileDisabled(Item item, int serialCount);
/// <summary>
/// Serials leaving <paramref name="warehouseId"/>. For a serial-tracked item: one per unit of
/// <paramref name="qty"/>, each currently at that warehouse in one of
/// <paramref name="allowedStatuses"/> and not used elsewhere on the same document
/// (<paramref name="documentSerials"/>); with none named, only pre-tracking (serial-less)
/// stock may go (D6). Any other item must name none. Returns tracked entities.
/// </summary>
Task<IReadOnlyList<Serial>> ResolveOutboundAsync(
Item item, int warehouseId, decimal qty, IReadOnlyCollection<int>? serialIds,
ISet<int> documentSerials, IReadOnlyCollection<SerialStatus> allowedStatuses, CancellationToken ct = default);
/// <summary>
/// Serials arriving by adjustment or count: each number must be new for the item (it is
/// added to the context, unsaved and without a location — save before building
/// <see cref="StockDelta"/>s, which need its id) or an existing serial
/// that is not currently in stock anywhere (it is brought back). One per unit of
/// <paramref name="qty"/>; required for a serial-tracked item, rejected otherwise.
/// </summary>
Task<IReadOnlyList<Serial>> ResolveInboundAsync(
Item item, int warehouseId, decimal qty, IReadOnlyCollection<string>? serialNos,
ISet<string> documentSerialNos, CancellationToken ct = default);
/// <summary>Sets status and location on each serial and appends its <see cref="SerialMovement"/>.</summary>
Task MoveAsync(
IEnumerable<Serial> serials, SerialStatus status, int? warehouseId, int? binId,
string sourceDocType, int sourceDocId, DateTime now, CancellationToken ct = default);
}
@@ -2,8 +2,16 @@ using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces; namespace ERPCore.Services.Interfaces;
/// <summary>A signed base-UOM change to an item's stock at one warehouse.</summary> /// <summary>
public sealed record StockDelta(int ItemId, int? BinId, int? BatchId, decimal QtyDelta); /// A signed base-UOM change to an item's stock at one warehouse.
/// <see cref="SerialIds"/> (serial-tracked items): an outbound delta consumes exactly those
/// serials' layers; an inbound delta creates one qty-1 layer per serial. Either way the count
/// must equal |<see cref="QtyDelta"/>|, and the caller owns the serials' status/location.
/// <see cref="UnitCost"/> overrides the inbound cost (default: last cost at the warehouse).
/// </summary>
public sealed record StockDelta(
int ItemId, int? BinId, int? BatchId, decimal QtyDelta,
IReadOnlyList<int>? SerialIds = null, decimal? UnitCost = null);
/// <summary> /// <summary>
/// Shared poster for stock-affecting documents (adjustments, count variances, /// Shared poster for stock-affecting documents (adjustments, count variances,
+12 -7
View File
@@ -298,7 +298,7 @@ public sealed class ItemService : IItemService
await ValidateReferencesAsync( await ValidateReferencesAsync(
request.CategoryId, request.SubCategoryId, request.BrandId, request.CategoryId, request.SubCategoryId, request.BrandId,
request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct); request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct);
ValidateFeatureConfiguration(request, config); ValidateFeatureConfiguration(request, config, item.TrackingMode);
// The base UOM is the sole meaning of every quantity recorded against this item — // The base UOM is the sole meaning of every quantity recorded against this item —
// stock layers, ledger rows and document lines are all plain counts of it. Once any // stock layers, ledger rows and document lines are all plain counts of it. Once any
@@ -490,15 +490,20 @@ public sealed class ItemService : IItemService
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config); request.TrackingMode, request.Warranty, config);
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config) /// <summary>
/// An item keeps the tracking mode it already has even after that feature is switched off —
/// otherwise it could not be edited at all; only a change <em>to</em> a disabled mode is refused.
/// </summary>
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode)
=> ValidateFeatureConfiguration( => ValidateFeatureConfiguration(
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
request.TrackingMode, request.Warranty, config); request.TrackingMode, request.Warranty, config, currentTrackingMode);
private static void ValidateFeatureConfiguration( private static void ValidateFeatureConfiguration(
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice, decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config) TrackingMode trackingMode, Warranty warranty, ProductConfigDto config, TrackingMode? currentTrackingMode = null)
{ {
var trackingUnchanged = trackingMode == currentTrackingMode;
if (!config.SellingPricesEnabled && if (!config.SellingPricesEnabled &&
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null)) (sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
throw new DomainException( throw new DomainException(
@@ -506,13 +511,13 @@ public sealed class ItemService : IItemService
"Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.", "Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.",
422); 422);
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial) if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial && !trackingUnchanged)
throw new DomainException( throw new DomainException(
ErrorCodes.ConfigDisabled, ErrorCodes.ConfigDisabled,
"Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.", "Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.",
422); 422);
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch) if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch && !trackingUnchanged)
throw new DomainException( throw new DomainException(
ErrorCodes.ConfigDisabled, ErrorCodes.ConfigDisabled,
"Batch lists are disabled in the product configuration; trackingMode cannot be Batch.", "Batch lists are disabled in the product configuration; trackingMode cannot be Batch.",
@@ -620,7 +625,7 @@ public sealed class ItemService : IItemService
private static ItemScanResultDto ToScanResult(Serial s) => new( private static ItemScanResultDto ToScanResult(Serial s) => new(
s.Item!.ItemId, s.Item.Sku, s.Item.Name, s.Item.TrackingMode, s.Item.BaseUomId, "Serial", s.Item!.ItemId, s.Item.Sku, s.Item.Name, s.Item.TrackingMode, s.Item.BaseUomId, "Serial",
null, null, null, s.SerialId, s.SerialNo, s.Status); null, null, null, s.SerialId, s.SerialNo, s.Status.ToString());
private static ItemDetailDto ToDetail(Item i) => new( private static ItemDetailDto ToDetail(Item i) => new(
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
@@ -38,6 +38,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
private readonly IRepository<StockLedger> _ledger; private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator; private readonly IStockMutator _mutator;
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly IRepository<Serial> _serials;
private readonly ISerialTrackingService _serialTracking;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
@@ -49,9 +51,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses, IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines, IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo, IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
IRepository<Serial> serials, ISerialTrackingService serialTracking,
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
_serials = serials;
_serialTracking = serialTracking;
_returns = returns; _returns = returns;
_vendors = vendors; _vendors = vendors;
_warehouses = warehouses; _warehouses = warehouses;
@@ -130,11 +135,24 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
// layer(s) below, not the generic mutator — track which by GrnLineId so the two // layer(s) below, not the generic mutator — track which by GrnLineId so the two
// paths don't double-handle the same line. // paths don't double-handle the same line.
var rejectedGrnLineIds = new HashSet<int>(); var rejectedGrnLineIds = new HashSet<int>();
var documentSerials = new HashSet<int>();
var serialsByLine = new Dictionary<CreatePurchaseReturnLineInput, IReadOnlyList<Serial>>();
foreach (var line in request.Lines) foreach (var line in request.Lines)
{ {
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
// Rejected-line serials are checked against that line's own layers below; anything
// else must name serials actually on hand (D6: or leave only pre-tracking stock).
var isRejectedLine = line.GrnLineId is not null && await _grnLines.Query().AnyAsync(g => g.GrnLineId == line.GrnLineId && g.HoldStatus == HoldStatus.Rejected, ct);
if (!isRejectedLine || line.SerialIds is { Count: > 0 })
serialsByLine[line] = await _serialTracking.ResolveOutboundAsync(
item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials,
new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct);
if (isRejectedLine && serialsByLine.TryGetValue(line, out var named) && named.Any(s => s.GrnLineId != line.GrnLineId))
throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials were not received on GRN line {line.GrnLineId}.", 422);
if (line.GrnLineId is not null) if (line.GrnLineId is not null)
{ {
var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct) var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct)
@@ -158,7 +176,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
var actor = _currentUser.AuditUserId; var actor = _currentUser.AuditUserId;
var deltas = request.Lines var deltas = request.Lines
.Where(l => l.GrnLineId is null || !rejectedGrnLineIds.Contains(l.GrnLineId.Value)) .Where(l => l.GrnLineId is null || !rejectedGrnLineIds.Contains(l.GrnLineId.Value))
.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList(); .Select(l => new StockDelta(l.ItemId, null, null, -l.Qty,
serialsByLine.TryGetValue(l, out var s) ? s.Select(x => x.SerialId).ToList() : null)).ToList();
var rejectedLines = request.Lines.Where(l => l.GrnLineId is not null && rejectedGrnLineIds.Contains(l.GrnLineId.Value)).ToList(); var rejectedLines = request.Lines.Where(l => l.GrnLineId is not null && rejectedGrnLineIds.Contains(l.GrnLineId.Value)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token => var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
@@ -196,10 +215,31 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId) .OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.ToListAsync(token); .ToListAsync(token);
// A serial line's layers are one per unit: named serials take exactly theirs;
// with none named, the whole remaining line may go back (every serial on it).
var named = serialsByLine.TryGetValue(line, out var lineSerials) ? lineSerials : Array.Empty<Serial>();
if (named.Count > 0)
{
var ids = named.Select(s => s.SerialId).ToHashSet();
layers = layers.Where(l => l.SerialId != null && ids.Contains(l.SerialId.Value)).ToList();
if (layers.Count != named.Count)
throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials are no longer on GRN line {line.GrnLineId}'s rejected stock.", 409);
}
else if (layers.Any(l => l.SerialId != null) && await _serialTracking.IsEnabledAsync(token))
{
if (line.Qty != layers.Sum(l => l.QtyRemaining))
throw new DomainException(ErrorCodes.TrackingRequired,
$"GRN line {line.GrnLineId} is serial-tracked; select which {line.Qty} serial(s) go back.", 422);
named = await _serials.Query()
.Where(s => layers.Select(l => l.SerialId).Contains(s.SerialId)).ToListAsync(token);
serialsByLine[line] = named;
}
if (!balances.TryGetValue(line.ItemId, out var bal)) if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, request.WarehouseId, token); bal = await _fifo.GetOnHandAsync(line.ItemId, request.WarehouseId, token);
var remaining = line.Qty; var remaining = line.Qty;
var takenSerialIds = new List<int>();
foreach (var layer in layers) foreach (var layer in layers)
{ {
if (remaining <= 0) break; if (remaining <= 0) break;
@@ -207,6 +247,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
layer.QtyRemaining -= take; layer.QtyRemaining -= take;
remaining -= take; remaining -= take;
bal -= take; bal -= take;
if (layer.SerialId is int takenSerial && layer.QtyRemaining == 0) takenSerialIds.Add(takenSerial);
var entry = await _fifo.PostLedgerAsync( var entry = await _fifo.PostLedgerAsync(
layer.ItemId, layer.WarehouseId, null, layer.BatchId, null, actor, layer.ItemId, layer.WarehouseId, null, layer.BatchId, null, actor,
@@ -214,8 +255,17 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
refs.Add(entry); refs.Add(entry);
} }
balances[line.ItemId] = bal; balances[line.ItemId] = bal;
// Serial numbers switched off: serial units went back oldest-first without being
// named — still record them as returned so the register doesn't show them in stock.
if (named.Count == 0 && takenSerialIds.Count > 0)
serialsByLine[line] = await _serials.Query().Where(s => takenSerialIds.Contains(s.SerialId)).ToListAsync(token);
} }
foreach (var serials in serialsByLine.Values)
await _serialTracking.MoveAsync(serials, SerialStatus.ReturnedToVendor, null, null,
DocumentTypes.PurchaseReturn, ret.ReturnId, now, token);
var totalValue = refs.Sum(r => r.Value); var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0) if (totalValue > 0)
{ {
@@ -20,7 +20,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService
public async Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default) public async Task<SalesInvoice> LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default)
{ {
var invoice = await _invoices.Query().Include(x => x.Lines) var invoice = await _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.RowVersion != expectedRowVersion) if (invoice.RowVersion != expectedRowVersion)
@@ -32,7 +32,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService
public async Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default) public async Task<SalesSlip> LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default)
{ {
var slip = await _slips.Query().Include(x => x.Lines) var slip = await _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.RowVersion != expectedRowVersion) if (slip.RowVersion != expectedRowVersion)
+73 -1
View File
@@ -16,6 +16,10 @@ public sealed class SalesDomainService : ISalesDomainService
private readonly IRepository<TaxRate> _taxRates; private readonly IRepository<TaxRate> _taxRates;
private readonly ISalesPricingService _pricing; private readonly ISalesPricingService _pricing;
private readonly IRepository<SalesConfig> _salesConfig; private readonly IRepository<SalesConfig> _salesConfig;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IRepository<StockLayer> _layers;
private readonly ISerialTrackingService _serialTracking;
public SalesDomainService( public SalesDomainService(
IRepository<Customer> customers, IRepository<Customer> customers,
@@ -24,8 +28,16 @@ public sealed class SalesDomainService : ISalesDomainService
IRepository<Item> items, IRepository<Item> items,
IRepository<TaxRate> taxRates, IRepository<TaxRate> taxRates,
ISalesPricingService pricing, ISalesPricingService pricing,
IRepository<SalesConfig> salesConfig) IRepository<SalesConfig> salesConfig,
IRepository<Batch> batches,
IRepository<Serial> serials,
IRepository<StockLayer> layers,
ISerialTrackingService serialTracking)
{ {
_serialTracking = serialTracking;
_batches = batches;
_serials = serials;
_layers = layers;
_customers = customers; _customers = customers;
_warehouses = warehouses; _warehouses = warehouses;
_users = users; _users = users;
@@ -154,6 +166,66 @@ public sealed class SalesDomainService : ISalesDomainService
.Select(x => x.StockNature == StockNature.Stocked) .Select(x => x.StockNature == StockNature.Stocked)
.FirstAsync(ct); .FirstAsync(ct);
public async Task<IReadOnlyList<Serial>> ValidateLineTrackingAsync(
int itemId, int warehouseId, decimal totalQty, int? batchId, IReadOnlyCollection<int>? serialIds,
ISet<int> documentSerials, CancellationToken ct = default)
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == itemId, ct);
var ids = serialIds?.ToList() ?? new List<int>();
if (batchId is not null)
{
var batch = await _batches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BatchId == batchId && b.ItemId == itemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Batch {batchId} does not belong to item {item.Sku}.", 422);
if (batch.ExpiryDate is DateOnly expiry && expiry < DateOnly.FromDateTime(DateTime.UtcNow))
throw new DomainException(ErrorCodes.ExpiredBatchBlocked, $"Batch {batch.BatchNo} expired on {expiry:yyyy-MM-dd} and cannot be sold.", 422);
}
// Serial numbers switched off in Product Configuration: a serial-mode item sells like
// untracked stock (no serials asked, none accepted).
if (!await _serialTracking.IsSerialTrackedAsync(item, ct))
{
_serialTracking.RejectSerialsWhileDisabled(item, ids.Count);
if (ids.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422);
return Array.Empty<Serial>();
}
if (ids.Count == 0)
{
// D6: stock received before serial capture has no serials — it can still be sold
// without naming any, but only while enough of it is left at this warehouse.
if (item.StockNature != StockNature.Stocked) return Array.Empty<Serial>();
var untracked = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.SerialId == null && l.QtyRemaining > 0)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (untracked < totalQty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; select {totalQty} serial number(s).", 422);
return Array.Empty<Serial>();
}
if (ids.Count != totalQty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; select exactly {totalQty} serial number(s), got {ids.Count}.", 422);
foreach (var id in ids)
if (!documentSerials.Add(id))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is selected more than once on this document.", 422);
// Tracked on purpose: the caller hangs these off the new line's serial rows as navigations.
var serials = await _serials.Query().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct);
foreach (var id in ids)
{
var serial = serials.FirstOrDefault(s => s.SerialId == id);
if (serial is null || serial.ItemId != itemId)
throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422);
if (serial.Status != SerialStatus.InStock || serial.WarehouseId != warehouseId)
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial {serial.SerialNo} is {serial.Status}{(serial.Status == SerialStatus.InStock ? " at another warehouse" : string.Empty)} and cannot be sold from warehouse {warehouseId}.", 422);
}
return serials;
}
private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount)
{ {
var computed = mode == SalesDiscountMode.Amount var computed = mode == SalesDiscountMode.Amount
@@ -81,7 +81,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default) public async Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default)
{ {
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
return invoice is null ? null : new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion); return invoice is null ? null : new ETagged<SalesInvoiceDto>(_mapping.MapInvoice(invoice), invoice.RowVersion);
} }
@@ -155,14 +155,14 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
public async Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default) public async Task<SalesInvoiceDto> PostAsync(int salesInvoiceId, CancellationToken ct = default)
{ {
await _posting.PostInvoiceAsync(salesInvoiceId, ct); await _posting.PostInvoiceAsync(salesInvoiceId, ct);
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); .FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
return _mapping.MapInvoice(invoice); return _mapping.MapInvoice(invoice);
} }
public async Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default) public async Task<SalesInvoiceDto> CancelAsync(int salesInvoiceId, CancellationToken ct = default)
{ {
var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) var invoice = await _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct)
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
if (invoice.Status != SalesInvoiceStatus.Draft) if (invoice.Status != SalesInvoiceStatus.Draft)
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled."); throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled.");
@@ -188,10 +188,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422); throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct)) if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422); throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
var documentSerials = new HashSet<int>();
foreach (var r in requests) foreach (var r in requests)
{ {
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, r.SerialIds, documentSerials, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice; var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource; var priceSource = resolved.PriceSource;
@@ -217,7 +219,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
TaxPct = taxPct, TaxPct = taxPct,
TaxAmount = calc.TaxAmount, TaxAmount = calc.TaxAmount,
IsFreeIssue = r.IsFreeIssue, IsFreeIssue = r.IsFreeIssue,
ParentLineId = r.ParentLineId ParentLineId = r.ParentLineId,
BatchId = r.BatchId,
Serials = serials.Select(s => new SalesInvoiceLineSerial { SerialId = s.SerialId, Serial = s }).ToList()
}); });
} }
return lines; return lines;
@@ -38,7 +38,8 @@ public sealed class SalesMappingService : ISalesMappingService
invoice.Lines.Select(l => new SalesInvoiceLineDto( invoice.Lines.Select(l => new SalesInvoiceLineDto(
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId,
l.BatchId, l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList());
public SalesSlipDto MapSlip(SalesSlip slip) public SalesSlipDto MapSlip(SalesSlip slip)
=> new( => new(
@@ -49,5 +50,6 @@ public sealed class SalesMappingService : ISalesMappingService
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId,
l.BatchId, l.SerialId)).ToList()); l.BatchId, l.SerialId,
l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList());
} }
+148 -17
View File
@@ -21,6 +21,10 @@ public sealed class SalesPostingService : ISalesPostingService
private readonly IRepository<BundleSale> _bundles; private readonly IRepository<BundleSale> _bundles;
private readonly IRepository<Item> _items; private readonly IRepository<Item> _items;
private readonly IRepository<Serial> _serials; private readonly IRepository<Serial> _serials;
private readonly IRepository<SalesLineAllocation> _allocations;
private readonly IRepository<WarrantyRegistration> _warranties;
private readonly IRepository<SerialMovement> _serialMovements;
private readonly ISerialTrackingService _serialTracking;
private readonly IFifoCostingService _fifo; private readonly IFifoCostingService _fifo;
private readonly ISalesDomainService _sales; private readonly ISalesDomainService _sales;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
@@ -34,6 +38,10 @@ public sealed class SalesPostingService : ISalesPostingService
IRepository<BundleSale> bundles, IRepository<BundleSale> bundles,
IRepository<Item> items, IRepository<Item> items,
IRepository<Serial> serials, IRepository<Serial> serials,
IRepository<SalesLineAllocation> allocations,
IRepository<WarrantyRegistration> warranties,
IRepository<SerialMovement> serialMovements,
ISerialTrackingService serialTracking,
IFifoCostingService fifo, IFifoCostingService fifo,
ISalesDomainService sales, ISalesDomainService sales,
ICurrentUser currentUser, ICurrentUser currentUser,
@@ -46,6 +54,10 @@ public sealed class SalesPostingService : ISalesPostingService
_bundles = bundles; _bundles = bundles;
_items = items; _items = items;
_serials = serials; _serials = serials;
_allocations = allocations;
_warranties = warranties;
_serialMovements = serialMovements;
_serialTracking = serialTracking;
_fifo = fifo; _fifo = fifo;
_sales = sales; _sales = sales;
_currentUser = currentUser; _currentUser = currentUser;
@@ -150,15 +162,17 @@ public sealed class SalesPostingService : ISalesPostingService
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default) public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
=> PostAsync( => PostAsync(
load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct), load: () => _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.", notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
statusSelector: x => x.Status, statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.", ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, null, null)), getLines: x => x.Lines.Select(l => new PostingLine(l.SalesInvoiceLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty,
l.BatchId, l.Serials.Select(s => s.SerialId).ToList())),
setPosted: x => x.Status = SalesInvoiceStatus.Posted, setPosted: x => x.Status = SalesInvoiceStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow, setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesInvoice, sourceDocType: DocumentTypes.SalesInvoice,
getDocId: x => x.SalesInvoiceId, getDocId: x => x.SalesInvoiceId,
getCustomerId: x => x.CustomerId,
ct: ct, ct: ct,
afterConsumption: PostInvoiceRevenueAsync); afterConsumption: PostInvoiceRevenueAsync);
@@ -208,15 +222,19 @@ public sealed class SalesPostingService : ISalesPostingService
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default) public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
=> PostAsync( => PostAsync(
load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct), load: () => _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
notFoundMessage: $"Sales slip {salesSlipId} was not found.", notFoundMessage: $"Sales slip {salesSlipId} was not found.",
statusSelector: x => x.Status, statusSelector: x => x.Status,
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.", ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId, l.SerialId)), // An old draft may still carry only the legacy single SerialId.
getLines: x => x.Lines.Select(l => new PostingLine(l.SalesSlipLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId,
l.Serials.Count > 0 ? l.Serials.Select(s => s.SerialId).ToList()
: l.SerialId is int single ? new List<int> { single } : new List<int>())),
setPosted: x => x.Status = SalesSlipStatus.Posted, setPosted: x => x.Status = SalesSlipStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow, setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.SalesSlip, sourceDocType: DocumentTypes.SalesSlip,
getDocId: x => x.SalesSlipId, getDocId: x => x.SalesSlipId,
getCustomerId: x => x.CustomerId,
ct: ct); ct: ct);
public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default) public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default)
@@ -225,11 +243,12 @@ public sealed class SalesPostingService : ISalesPostingService
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.", notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
statusSelector: x => x.Status, statusSelector: x => x.Status,
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.", ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, null)), getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.BundleSaleLineId, l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, new List<int>())),
setPosted: x => x.Status = BundleSaleStatus.Posted, setPosted: x => x.Status = BundleSaleStatus.Posted,
setUpdated: x => x.UpdatedAt = DateTime.UtcNow, setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
sourceDocType: DocumentTypes.BundleSale, sourceDocType: DocumentTypes.BundleSale,
getDocId: x => x.BundleSaleId, getDocId: x => x.BundleSaleId,
getCustomerId: x => x.CustomerId,
ct: ct); ct: ct);
private async Task PostAsync<T>( private async Task PostAsync<T>(
@@ -242,6 +261,7 @@ public sealed class SalesPostingService : ISalesPostingService
Action<T> setUpdated, Action<T> setUpdated,
string sourceDocType, string sourceDocType,
Func<T, int> getDocId, Func<T, int> getDocId,
Func<T, int> getCustomerId,
CancellationToken ct, CancellationToken ct,
/// <summary>Run inside the same transaction, after consumption and status flip, with the /// <summary>Run inside the same transaction, after consumption and status flip, with the
/// total COGS this call just consumed — the invoice-only revenue-recognition hook. /// total COGS this call just consumed — the invoice-only revenue-recognition hook.
@@ -262,27 +282,65 @@ public sealed class SalesPostingService : ISalesPostingService
await _uow.ExecuteInTransactionAsync(async token => await _uow.ExecuteInTransactionAsync(async token =>
{ {
var totalCogs = 0m; var totalCogs = 0m;
var now = DateTime.UtcNow;
var docId = getDocId(doc);
foreach (var line in getLines(doc)) foreach (var line in getLines(doc))
{ {
if (line.Qty <= 0) continue; if (line.Qty <= 0) continue;
if (!await _sales.IsStockedItemAsync(line.ItemId, token)) var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
if (item.StockNature != StockNature.Stocked)
continue; continue;
var serials = await LoadSerialsForPostingAsync(item, line, token);
// Line quantities are already a count of the item's base UOM — sales documents // Line quantities are already a count of the item's base UOM — sales documents
// carry no unit of their own — so this is the quantity FIFO consumes verbatim. // carry no unit of their own — so this is the quantity FIFO consumes verbatim.
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall); // Serial lines consume exactly their serials' layers and can never go short; batch
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); // lines with no pinned batch are picked FEFO (docs/ITEM_TRACKING_PLAN.md Phase 3).
totalCogs += cost * line.Qty; var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token,
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId, // With serial numbers switched off, a serial-mode item is plain stock and may go short.
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token); allowShortfall && serials.Count == 0 && !await _serialTracking.IsSerialTrackedAsync(item, token),
serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null);
totalCogs += consumed.Sum(x => x.Qty * x.UnitCost);
// ConsumeAsync has no serial parameter — closing the loop on a serial-pinned // One ledger row per batch actually issued (usually one) — a FEFO pick can span
// line means flipping the serial's own status once its stock is actually gone. // several, and each ledger row must carry the batch it moved.
if (line.SerialId.HasValue) foreach (var group in consumed.GroupBy(x => x.BatchId))
{ {
var serial = await _serials.Query().FirstOrDefaultAsync(s => s.SerialId == line.SerialId, token); var qty = group.Sum(x => x.Qty);
if (serial is not null) serial.Status = "Sold"; var cost = qty == 0 ? 0m : group.Sum(x => x.Qty * x.UnitCost) / qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, group.Key,
serials.Count == 1 ? serials[0].SerialId : null, _currentUser.AuditUserId,
Direction.Out, qty, cost, 0m, sourceDocType, docId, now, token);
} }
foreach (var segment in consumed)
{
await _allocations.AddAsync(new SalesLineAllocation
{
SourceDocType = sourceDocType,
SourceDocId = docId,
SourceLineId = line.LineId,
ItemId = line.ItemId,
WarehouseId = line.WarehouseId,
BatchId = segment.BatchId,
SerialId = segment.SerialId,
Qty = segment.Qty,
UnitCost = segment.UnitCost,
CreatedAt = now
}, token);
}
foreach (var serial in serials)
{
serial.Status = SerialStatus.Sold;
serial.WarehouseId = null;
serial.BinId = null;
serial.UpdatedAt = now;
await _serialMovements.AddAsync(SerialMovement.Of(serial, line.WarehouseId, sourceDocType, docId, _currentUser.AuditUserId, now), token);
}
await RegisterWarrantyAsync(item, line, serials, getCustomerId(doc), sourceDocType, docId, now, token);
} }
setPosted(doc); setPosted(doc);
@@ -292,5 +350,78 @@ public sealed class SalesPostingService : ISalesPostingService
}, ct); }, ct);
} }
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty, int? BatchId, int? SerialId); /// <summary>
/// Re-checks a line's chosen serials at post time — the draft did not reserve them, so another
/// document may have sold one since — and returns them tracked for the status flip. A
/// serial-tracked line with none chosen may only post from pre-tracking stock (D6); the
/// consume call enforces that by never touching serial layers without serial ids. A bundle
/// line has nowhere to choose serials, so a serial item in a bundle surfaces the same way.
/// </summary>
private async Task<IReadOnlyList<Serial>> LoadSerialsForPostingAsync(Item item, PostingLine line, CancellationToken ct)
{
if (line.SerialIds.Count == 0)
{
// Same D6 rule the draft was checked against (enough serial-less stock left here),
// re-run because that stock may have been issued since.
if (item.TrackingMode == TrackingMode.Serial)
await _sales.ValidateLineTrackingAsync(item.ItemId, line.WarehouseId, line.Qty, null, null, new HashSet<int>(), ct);
return Array.Empty<Serial>();
}
if (line.SerialIds.Count != line.Qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} needs exactly {line.Qty} serial number(s); {line.SerialIds.Count} are selected.", 422);
var serials = await _serials.Query().Where(s => line.SerialIds.Contains(s.SerialId)).ToListAsync(ct);
var unavailable = serials.Where(s => s.ItemId != item.ItemId || s.Status != SerialStatus.InStock || s.WarehouseId != line.WarehouseId)
.Select(s => s.SerialNo).ToList();
if (unavailable.Count > 0 || serials.Count != line.SerialIds.Count)
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial(s) {string.Join(", ", unavailable)} of item {item.Sku} are no longer in stock at this warehouse.", 409);
return serials;
}
/// <summary>
/// Starts the customer's warranty at posting (docs/ITEM_TRACKING_PLAN.md Phase 5): one
/// registration per serial, or one per line for a warranty item that is not serial-tracked.
/// Coverage is the period snapshotted on the serial at receipt, else the item's own.
/// </summary>
private async Task RegisterWarrantyAsync(
Item item, PostingLine line, IReadOnlyList<Serial> serials, int customerId,
string sourceDocType, int docId, DateTime now, CancellationToken ct)
{
if (item.Warranty != Warranty.Warranty) return;
var start = DateOnly.FromDateTime(now);
WarrantyRegistration Build(int? serialId, decimal qty, int months) => new()
{
ItemId = item.ItemId,
SerialId = serialId,
CustomerId = customerId,
SourceDocType = sourceDocType,
SourceDocId = docId,
SourceLineId = line.LineId,
Qty = qty,
StartDate = start,
PeriodMonths = months,
EndDate = start.AddMonths(months),
Status = WarrantyStatus.Active,
CreatedAt = now
};
if (serials.Count > 0)
{
foreach (var serial in serials)
if ((serial.WarrantyPeriodMonths ?? item.WarrantyPeriodMonths) is int months)
await _warranties.AddAsync(Build(serial.SerialId, 1m, months), ct);
return;
}
if (item.WarrantyPeriodMonths is int itemMonths)
await _warranties.AddAsync(Build(null, line.Qty, itemMonths), ct);
}
private sealed record PostingLine(
int LineId, int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty,
int? BatchId, IReadOnlyList<int> SerialIds);
} }
+222 -12
View File
@@ -17,15 +17,24 @@ namespace ERPCore.Services;
/// <summary> /// <summary>
/// Sales-return service. Auto-posts with a mandatory Return reason code and /// Sales-return service. Auto-posts with a mandatory Return reason code and
/// generates an inbound stock movement via the shared <see cref="IStockMutator"/> /// generates an inbound stock movement via the shared <see cref="IStockMutator"/>
/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW /// (positive delta — creates an inbound FIFO layer). Single UoW
/// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction /// transaction, mirroring <see cref="PurchaseReturnService"/> with the direction
/// reversed. Also posts a real GL journal entry reversing Inventory/COGS for the /// reversed. Also posts a real GL journal entry reversing Inventory/COGS for the
/// stock movement's value (see <see cref="SalesReturn.GlJournalNo"/> for why revenue/tax /// stock movement's value (see <see cref="SalesReturn.GlJournalNo"/> for why revenue/tax
/// aren't part of it) — reuses the Sales Day End accounts since that's the module /// aren't part of it) — reuses the Sales Day End accounts since that's the module
/// whose COGS this reverses. /// whose COGS this reverses.
/// <para>
/// Item tracking (docs/ITEM_TRACKING_PLAN.md Phase 6): a return against an invoice line restocks
/// into the batch(es)/serials that line actually issued (<see cref="SalesLineAllocation"/>), at
/// the cost they were issued at; returned serials go back to InStock — or Damaged — at the return
/// warehouse, and their warranty is voided.
/// </para>
/// </summary> /// </summary>
public sealed class SalesReturnService : ISalesReturnService public sealed class SalesReturnService : ISalesReturnService
{ {
// Return reasons whose goods are not resaleable (see DataSeeder.StandardReasonCodes).
private static readonly HashSet<string> DamagedReasonCodes = new(StringComparer.OrdinalIgnoreCase) { "DEF", "QREJ" };
private readonly IRepository<SalesReturn> _returns; private readonly IRepository<SalesReturn> _returns;
private readonly IRepository<Customer> _customers; private readonly IRepository<Customer> _customers;
private readonly IRepository<Warehouse> _warehouses; private readonly IRepository<Warehouse> _warehouses;
@@ -34,6 +43,12 @@ public sealed class SalesReturnService : ISalesReturnService
private readonly IRepository<SalesInvoiceLine> _salesInvoiceLines; private readonly IRepository<SalesInvoiceLine> _salesInvoiceLines;
private readonly IRepository<SalesReturnLine> _returnLines; private readonly IRepository<SalesReturnLine> _returnLines;
private readonly IRepository<StockLedger> _ledger; private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<Batch> _batches;
private readonly IRepository<Serial> _serials;
private readonly IRepository<SalesLineAllocation> _allocations;
private readonly IRepository<WarrantyRegistration> _warranties;
private readonly IRepository<SerialMovement> _serialMovements;
private readonly ISerialTrackingService _serialTracking;
private readonly IStockMutator _mutator; private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers; private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser; private readonly ICurrentUser _currentUser;
@@ -44,7 +59,9 @@ public sealed class SalesReturnService : ISalesReturnService
public SalesReturnService( public SalesReturnService(
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses, IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines, IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<SalesInvoiceLine> salesInvoiceLines,
IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger, IStockMutator mutator, IRepository<SalesReturnLine> returnLines, IRepository<StockLedger> ledger,
IRepository<Batch> batches, IRepository<Serial> serials, IRepository<SalesLineAllocation> allocations,
IRepository<WarrantyRegistration> warranties, IRepository<SerialMovement> serialMovements, ISerialTrackingService serialTracking, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow,
IGeneralLedgerService gl, IGlAccountConfigService glAccounts) IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{ {
@@ -56,6 +73,12 @@ public sealed class SalesReturnService : ISalesReturnService
_salesInvoiceLines = salesInvoiceLines; _salesInvoiceLines = salesInvoiceLines;
_returnLines = returnLines; _returnLines = returnLines;
_ledger = ledger; _ledger = ledger;
_batches = batches;
_serials = serials;
_allocations = allocations;
_warranties = warranties;
_serialMovements = serialMovements;
_serialTracking = serialTracking;
_mutator = mutator; _mutator = mutator;
_numbers = numbers; _numbers = numbers;
_currentUser = currentUser; _currentUser = currentUser;
@@ -91,7 +114,7 @@ public sealed class SalesReturnService : ISalesReturnService
public async Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default) public async Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
{ {
var ret = await _returns.Query().AsNoTracking() var ret = await _returns.Query().AsNoTracking()
.Include(r => r.Lines) .Include(r => r.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct); .FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
if (ret is null) return null; if (ret is null) return null;
@@ -119,15 +142,19 @@ public sealed class SalesReturnService : ISalesReturnService
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Return) if (reason.Context != ReasonContext.Return)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422); throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
var damaged = request.MarkDamaged ?? DamagedReasonCodes.Contains(reason.Code);
// Original sold qty is never mutated — "remaining returnable" is computed from // Original sold qty is never mutated — "remaining returnable" is computed from
// return history instead, so the invoice keeps recording what was actually sold. // return history instead, so the invoice keeps recording what was actually sold.
var pendingByInvoiceLine = new Dictionary<int, decimal>(); var pendingByInvoiceLine = new Dictionary<int, decimal>();
var requestSerials = new HashSet<int>();
var resolved = new List<ResolvedReturnLine>();
foreach (var line in request.Lines) foreach (var line in request.Lines)
{ {
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
var allocations = new List<SalesLineAllocation>();
if (line.SalesInvoiceLineId is not null) if (line.SalesInvoiceLineId is not null)
{ {
var invoiceLineId = line.SalesInvoiceLineId.Value; var invoiceLineId = line.SalesInvoiceLineId.Value;
@@ -145,11 +172,20 @@ public sealed class SalesReturnService : ISalesReturnService
if (line.Qty > remaining) if (line.Qty > remaining)
throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422); throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422);
pendingByInvoiceLine[invoiceLineId] = pending + line.Qty; pendingByInvoiceLine[invoiceLineId] = pending + line.Qty;
// Tracked: QtyReturned is updated below, inside the transaction.
allocations = await _allocations.Query()
.Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceLineId == invoiceLineId)
.OrderBy(a => a.SalesLineAllocationId)
.ToListAsync(ct);
} }
resolved.Add(await ResolveTrackingAsync(item, line, allocations, requestSerials, ct));
} }
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, l.Qty)).ToList(); var deltas = resolved.Select(r => new StockDelta(
r.Input.ItemId, null, r.BatchId, r.Input.Qty, r.Serials.Select(s => s.SerialId).ToList(), r.UnitCost)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token => var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{ {
@@ -163,11 +199,13 @@ public sealed class SalesReturnService : ISalesReturnService
Status = ReturnStatus.Posted, Status = ReturnStatus.Posted,
CreatedBy = _currentUser.AuditUserId, CreatedBy = _currentUser.AuditUserId,
CreatedAt = now, CreatedAt = now,
Lines = request.Lines.Select(l => new SalesReturnLine Lines = resolved.Select(r => new SalesReturnLine
{ {
SalesInvoiceLineId = l.SalesInvoiceLineId, SalesInvoiceLineId = r.Input.SalesInvoiceLineId,
ItemId = l.ItemId, ItemId = r.Input.ItemId,
Qty = l.Qty Qty = r.Input.Qty,
BatchId = r.BatchId,
Serials = r.Serials.Select(s => new SalesReturnLineSerial { SerialId = s.SerialId, Serial = s }).ToList()
}).ToList() }).ToList()
}; };
await _returns.AddAsync(ret, token); await _returns.AddAsync(ret, token);
@@ -175,6 +213,13 @@ public sealed class SalesReturnService : ISalesReturnService
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token); var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token);
foreach (var r in resolved)
{
await ApplyReturnEffectsAsync(r, request.WarehouseId, damaged, now, token);
foreach (var serial in r.Serials)
await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.SalesReturn, ret.ReturnId, _currentUser.AuditUserId, now), token);
}
var totalValue = refs.Sum(r => r.Value); var totalValue = refs.Sum(r => r.Value);
if (totalValue > 0) if (totalValue > 0)
{ {
@@ -218,17 +263,182 @@ public sealed class SalesReturnService : ISalesReturnService
.Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) }) .Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) })
.ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct); .ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct);
var allocations = await _allocations.Query().AsNoTracking()
.Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceDocId == salesInvoiceId)
.Select(a => new
{
a.SourceLineId,
Dto = new SalesLineAllocationDto(
a.BatchId, a.Batch != null ? a.Batch.BatchNo : null, a.Batch != null ? a.Batch.ExpiryDate : null,
a.SerialId, a.Serial != null ? a.Serial.SerialNo : null, a.Qty, a.QtyReturned)
})
.ToListAsync(ct);
return lines return lines
.Select(l => new SalesInvoiceLineRemainingDto( .Select(l => new SalesInvoiceLineRemainingDto(
l.SalesInvoiceLineId, l.SalesInvoiceLineId,
l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m))) l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m),
allocations.Where(a => a.SourceLineId == l.SalesInvoiceLineId).Select(a => a.Dto).ToList()))
.ToList(); .ToList();
} }
/// <summary>
/// Works out which batch/serials one return line restocks, and at what cost. Against an
/// invoice line, both must come from what that line issued; free-standing, a serial item
/// must name serials that are Sold and a batch item must name its batch. Legacy invoice lines
/// posted before allocations existed (no allocation rows) return untracked, as before.
/// </summary>
private async Task<ResolvedReturnLine> ResolveTrackingAsync(
Item item, CreateSalesReturnLineInput line, List<SalesLineAllocation> allocations,
HashSet<int> requestSerials, CancellationToken ct)
{
var serialIds = line.SerialIds?.ToList() ?? new List<int>();
var batchId = line.BatchId;
var serials = new List<Serial>();
var hasAllocations = allocations.Count > 0;
// Serial numbers switched off: a serial-mode item comes back as untracked stock.
var serialTracked = await _serialTracking.IsSerialTrackedAsync(item, ct);
if (!serialTracked) _serialTracking.RejectSerialsWhileDisabled(item, serialIds.Count);
if (!serialTracked && serialIds.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422);
if (serialTracked)
{
var lineSoldSerials = hasAllocations && allocations.Any(a => a.SerialId != null);
if (serialIds.Count == 0)
{
if (line.SalesInvoiceLineId is null || lineSoldSerials)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; select the {line.Qty} returned serial number(s).", 422);
}
else
{
if (serialIds.Count != line.Qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku}: returning {line.Qty} unit(s) needs exactly that many serials, got {serialIds.Count}.", 422);
foreach (var id in serialIds)
if (!requestSerials.Add(id))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is returned more than once on this document.", 422);
serials = await _serials.Query().Where(s => serialIds.Contains(s.SerialId)).ToListAsync(ct);
foreach (var id in serialIds)
{
var serial = serials.FirstOrDefault(s => s.SerialId == id);
if (serial is null || serial.ItemId != item.ItemId)
throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422);
if (serial.Status != SerialStatus.Sold)
throw new DomainException(ErrorCodes.SerialNotAvailable, $"Serial {serial.SerialNo} is {serial.Status}, not Sold — it cannot be returned.", 422);
if (line.SalesInvoiceLineId is not null
&& !allocations.Any(a => a.SerialId == id && a.QtyReturned < a.Qty))
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial {serial.SerialNo} was not sold on sales invoice line {line.SalesInvoiceLineId} (or is already returned).", 422);
}
// A serial goes back into the batch it was sold from.
batchId ??= allocations.FirstOrDefault(a => a.SerialId == serialIds[0])?.BatchId;
}
}
else if (hasAllocations)
{
var issuedBatches = allocations.Where(a => a.QtyReturned < a.Qty).Select(a => a.BatchId).Distinct().ToList();
if (batchId is null)
{
if (issuedBatches.Count > 1)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Sales invoice line {line.SalesInvoiceLineId} issued several batches of item {item.Sku}; choose the batch being returned.", 422);
batchId = issuedBatches.FirstOrDefault();
}
else if (!issuedBatches.Contains(batchId))
{
throw new DomainException(ErrorCodes.Validation,
$"Batch {batchId} was not issued on sales invoice line {line.SalesInvoiceLineId}.", 422);
}
}
else if (item.TrackingMode == TrackingMode.Batch && batchId is null && line.SalesInvoiceLineId is null)
{
throw new DomainException(ErrorCodes.TrackingRequired, $"Item {item.Sku} is batch-tracked; choose the batch being returned.", 422);
}
if (batchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == batchId && b.ItemId == item.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Batch {batchId} does not belong to item {item.Sku}.", 422);
// Restock at what the goods were issued at, so the COGS reversal mirrors the sale.
var costed = allocations.Where(a => serials.Count > 0
? a.SerialId != null && serialIds.Contains(a.SerialId.Value)
: a.BatchId == batchId)
.ToList();
decimal? unitCost = costed.Sum(a => a.Qty) > 0
? Math.Round(costed.Sum(a => a.Qty * a.UnitCost) / costed.Sum(a => a.Qty), 6, MidpointRounding.AwayFromZero)
: null;
return new ResolvedReturnLine(line, item, batchId, serials, allocations, unitCost);
}
/// <summary>
/// After the stock is back: serials return to stock (or Damaged) at the return warehouse, the
/// issuing allocations record what came back, and the matching warranty is voided — per
/// serial, or reduced by the returned quantity for a non-serial warranty line.
/// </summary>
private async Task ApplyReturnEffectsAsync(ResolvedReturnLine r, int warehouseId, bool damaged, DateTime now, CancellationToken ct)
{
foreach (var serial in r.Serials)
{
serial.Status = damaged ? SerialStatus.Damaged : SerialStatus.InStock;
serial.WarehouseId = warehouseId;
serial.BinId = null;
serial.UpdatedAt = now;
}
if (r.Serials.Count > 0)
{
var ids = r.Serials.Select(s => s.SerialId).ToHashSet();
foreach (var a in r.Allocations.Where(a => a.SerialId != null && ids.Contains(a.SerialId.Value)))
a.QtyReturned = a.Qty;
var registrations = await _warranties.Query()
.Where(w => w.SerialId != null && ids.Contains(w.SerialId.Value) && w.Status == WarrantyStatus.Active)
.ToListAsync(ct);
foreach (var w in registrations)
{
w.Status = WarrantyStatus.Voided;
w.UpdatedAt = now;
}
return;
}
var left = r.Input.Qty;
foreach (var a in r.Allocations.Where(a => a.BatchId == r.BatchId && a.QtyReturned < a.Qty))
{
if (left <= 0) break;
var take = Math.Min(left, a.Qty - a.QtyReturned);
a.QtyReturned += take;
left -= take;
}
if (r.Item.Warranty == Warranty.Warranty && r.Input.SalesInvoiceLineId is int invoiceLineId)
{
var registration = await _warranties.Query()
.FirstOrDefaultAsync(w => w.SourceDocType == DocumentTypes.SalesInvoice && w.SourceLineId == invoiceLineId
&& w.SerialId == null && w.Status == WarrantyStatus.Active, ct);
if (registration is not null)
{
registration.Qty = Math.Max(0m, registration.Qty - r.Input.Qty);
if (registration.Qty == 0m) registration.Status = WarrantyStatus.Voided;
registration.UpdatedAt = now;
}
}
}
private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList<int> ledgerRefs) => new( private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList<int> ledgerRefs) => new(
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt, r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
r.GlJournalNo, r.GlPostedAt, r.GlJournalNo, r.GlPostedAt,
r.Lines.OrderBy(l => l.ReturnLineId) r.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(), .Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty, l.BatchId,
l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList()))
.ToList(),
ledgerRefs); ledgerRefs);
private sealed record ResolvedReturnLine(
CreateSalesReturnLineInput Input, Item Item, int? BatchId, List<Serial> Serials,
List<SalesLineAllocation> Allocations, decimal? UnitCost);
} }
+12 -11
View File
@@ -79,7 +79,7 @@ public sealed class SalesSlipService : ISalesSlipService
public async Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default) public async Task<ETagged<SalesSlipDto>?> GetAsync(int salesSlipId, CancellationToken ct = default)
{ {
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
return slip is null ? null : new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion); return slip is null ? null : new ETagged<SalesSlipDto>(_mapping.MapSlip(slip), slip.RowVersion);
} }
@@ -104,7 +104,7 @@ public sealed class SalesSlipService : ISalesSlipService
public async Task<ETagged<FreeIssueDto>?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default) public async Task<ETagged<FreeIssueDto>?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default)
{ {
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion); return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
} }
@@ -180,7 +180,7 @@ public sealed class SalesSlipService : ISalesSlipService
public async Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default) public async Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default)
{ {
await _posting.PostSlipAsync(salesSlipId, ct); await _posting.PostSlipAsync(salesSlipId, ct);
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstAsync(x => x.SalesSlipId == salesSlipId, ct); .FirstAsync(x => x.SalesSlipId == salesSlipId, ct);
await _notifications.RaiseAsync("sale.posted", "Sale Posted", $"Sales slip {slip.SlipNo} was posted.", nameof(SalesSlip), slip.SalesSlipId, ct); await _notifications.RaiseAsync("sale.posted", "Sale Posted", $"Sales slip {slip.SlipNo} was posted.", nameof(SalesSlip), slip.SalesSlipId, ct);
return _mapping.MapSlip(slip); return _mapping.MapSlip(slip);
@@ -188,7 +188,7 @@ public sealed class SalesSlipService : ISalesSlipService
public async Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default) public async Task<SalesSlipDto> CancelAsync(int salesSlipId, CancellationToken ct = default)
{ {
var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) var slip = await _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct)
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
if (slip.Status != SalesSlipStatus.Draft) if (slip.Status != SalesSlipStatus.Draft)
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled."); throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled.");
@@ -201,10 +201,15 @@ public sealed class SalesSlipService : ISalesSlipService
private async Task<List<SalesSlipLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesSlipLineRequest> requests, CancellationToken ct) private async Task<List<SalesSlipLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
{ {
var lines = new List<SalesSlipLine>(); var lines = new List<SalesSlipLine>();
var documentSerials = new HashSet<int>();
foreach (var r in requests) foreach (var r in requests)
{ {
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
// The legacy single SerialId is a one-element SerialIds.
var serialIds = r.SerialIds is { Count: > 0 } ? r.SerialIds
: r.SerialId is int single ? new List<int> { single } : null;
var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, serialIds, documentSerials, ct);
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
var unitPrice = resolved.UnitPrice; var unitPrice = resolved.UnitPrice;
var priceSource = resolved.PriceSource; var priceSource = resolved.PriceSource;
@@ -212,11 +217,6 @@ public sealed class SalesSlipService : ISalesSlipService
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, taxPct, r.IsFreeIssue); var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, taxPct, r.IsFreeIssue);
if (r.BatchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == r.BatchId && b.ItemId == r.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Batch {r.BatchId} does not belong to item {r.ItemId}.", 422);
if (r.SerialId is not null && !await _serials.Query().AnyAsync(s => s.SerialId == r.SerialId && s.ItemId == r.ItemId, ct))
throw new DomainException(ErrorCodes.Validation, $"Serial {r.SerialId} does not belong to item {r.ItemId}.", 422);
lines.Add(new SalesSlipLine lines.Add(new SalesSlipLine
{ {
ItemId = r.ItemId, ItemId = r.ItemId,
@@ -225,7 +225,8 @@ public sealed class SalesSlipService : ISalesSlipService
FreeQty = r.FreeQty, FreeQty = r.FreeQty,
WarehouseId = r.WarehouseId, WarehouseId = r.WarehouseId,
BatchId = r.BatchId, BatchId = r.BatchId,
SerialId = r.SerialId, SerialId = serials.Count == 1 ? serials[0].SerialId : null,
Serials = serials.Select(s => new SalesSlipLineSerial { SerialId = s.SerialId, Serial = s }).ToList(),
UnitPrice = unitPrice, UnitPrice = unitPrice,
BaseCost = unitPrice, BaseCost = unitPrice,
PriceSource = priceSource, PriceSource = priceSource,
@@ -319,7 +320,7 @@ public sealed class SalesSlipService : ISalesSlipService
x.CreatedAt, x.CreatedAt,
x.UpdatedAt, x.UpdatedAt,
summary, summary,
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, l.BatchId, l.SerialId)).ToList()); _mapping.MapSlip(x).Lines);
} }
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x); private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
@@ -63,11 +63,28 @@ public sealed class FifoCostingService : IFifoCostingService
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync( public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default, int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default,
bool allowShortfall = false) bool allowShortfall = false, IReadOnlyCollection<int>? serialIds = null)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var today = DateOnly.FromDateTime(now); var today = DateOnly.FromDateTime(now);
if (serialIds is not null)
{
if (serialIds.Distinct().Count() != serialIds.Count || serialIds.Count != qtyBase)
throw new DomainException(ErrorCodes.Validation,
$"Issuing {qtyBase} of item {itemId} requires exactly {qtyBase} distinct serial(s), got {serialIds.Count}.", 422);
allowShortfall = false; // a serial cannot be sold before it exists
}
// Serial layers are held back from an issue that names no serials — but only while the
// item is serial-tracked and Product Configuration has serial numbers switched on. With
// the switch off (or the item moved off Serial), they are ordinary stock: issued
// oldest-first, and their serial records closed out below.
var holdSerialLayers = serialIds is null
&& await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId && i.TrackingMode == TrackingMode.Serial, ct)
&& (await _db.ProductConfig.AsNoTracking().Where(c => c.ConfigId == ProductConfig.SingletonId)
.Select(c => (bool?)c.SerialNumbersEnabled).FirstOrDefaultAsync(ct) ?? true);
if (batchId is not null) if (batchId is not null)
{ {
var expiry = await _db.Batches.AsNoTracking() var expiry = await _db.Batches.AsNoTracking()
@@ -107,6 +124,18 @@ public sealed class FifoCostingService : IFifoCostingService
""") """)
.ToListAsync(ct); .ToListAsync(ct);
// A serial item holds one layer per unit, so don't lock them all: keep only the named
// serials' layers (or, with none named, the untracked ones) plus shortfall layers.
if (candidateIds.Count > 0 && (serialIds is not null || holdSerialLayers))
{
var ids = candidateIds;
candidateIds = await _db.StockLayers.AsNoTracking()
.Where(l => ids.Contains(l.LayerId) && (l.QtyRemaining < 0 || (serialIds == null
? l.SerialId == null
: l.SerialId != null && serialIds.Contains(l.SerialId.Value))))
.Select(l => l.LayerId).ToListAsync(ct);
}
// `IN ()` is a syntax error, so short-circuit to the normal insufficient-stock path. // `IN ()` is a syntax error, so short-circuit to the normal insufficient-stock path.
List<StockLayer> locked; List<StockLayer> locked;
if (candidateIds.Count == 0) if (candidateIds.Count == 0)
@@ -167,11 +196,13 @@ public sealed class FifoCostingService : IFifoCostingService
// Stock sold before it arrived is owed first: net shortfall layers off against issuable // Stock sold before it arrived is owed first: net shortfall layers off against issuable
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The // stock (oldest-first) before anything new is issued, so it can't be sold twice. The
// sale already posted its ledger Out, so settling writes no ledger entry of its own. // sale already posted its ledger Out, so settling writes no ledger entry of its own.
// Serial layers are excluded from settlement: netting a shortfall against one would empty
// a serial's layer while the serial itself still reads InStock.
var owed = locked.Where(l => l.QtyRemaining < 0).ToList(); var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
var open = locked.Where(l => l.QtyRemaining > 0).ToList(); var open = locked.Where(l => l.QtyRemaining > 0).ToList();
foreach (var shortfall in owed) foreach (var shortfall in owed)
{ {
foreach (var layer in open) foreach (var layer in open.Where(l => l.SerialId == null))
{ {
if (shortfall.QtyRemaining >= 0) break; if (shortfall.QtyRemaining >= 0) break;
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining); var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
@@ -182,7 +213,38 @@ public sealed class FifoCostingService : IFifoCostingService
// Only now apply the caller's batch filter: settlement above is deliberately // Only now apply the caller's batch filter: settlement above is deliberately
// batch-agnostic (a shortfall layer has no batch), but the issue itself is not. // batch-agnostic (a shortfall layer has no batch), but the issue itself is not.
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList(); // Serial filter: named serials only, or — with none named — no serial layer while they
// are held back (see holdSerialLayers).
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)
&& (serialIds == null
? !holdSerialLayers || l.SerialId == null
: l.SerialId != null && serialIds.Contains(l.SerialId.Value)))
.ToList();
if (serialIds is not null)
{
var found = layers.Select(l => l.SerialId!.Value).ToHashSet();
var missing = serialIds.Where(id => !found.Contains(id)).ToList();
if (missing.Count > 0)
{
var numbers = await _db.Serials.AsNoTracking().Where(s => missing.Contains(s.SerialId))
.Select(s => s.SerialNo).ToListAsync(ct);
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial(s) {string.Join(", ", numbers.Count > 0 ? numbers : missing.Select(m => m.ToString()))} are not available to issue from warehouse {warehouseId} (not in stock here, on hold, or expired).", 409);
}
}
// FEFO then FIFO: soonest-expiring batch first; unbatched/no-expiry stock after.
var layerBatchIds = layers.Where(l => l.BatchId != null).Select(l => l.BatchId!.Value).Distinct().ToList();
if (layerBatchIds.Count > 0)
{
var expiries = await _db.Batches.AsNoTracking().Where(b => layerBatchIds.Contains(b.BatchId))
.ToDictionaryAsync(b => b.BatchId, b => b.ExpiryDate, ct);
layers = layers
.OrderBy(l => l.BatchId is int b && expiries.TryGetValue(b, out var e) && e is not null ? e.Value : DateOnly.MaxValue)
.ThenBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.ToList();
}
var available = layers.Sum(l => l.QtyRemaining); var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase && !allowShortfall) if (available < qtyBase && !allowShortfall)
@@ -190,6 +252,7 @@ public sealed class FifoCostingService : IFifoCostingService
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l => var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
&& (batchId == null || l.BatchId == batchId) && (batchId == null || l.BatchId == batchId)
&& (serialIds == null ? l.SerialId == null : serialIds.Contains(l.SerialId ?? 0))
&& l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected), ct); && l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected), ct);
if (heldExists) if (heldExists)
throw new DomainException(ErrorCodes.OnHoldNotIssuable, throw new DomainException(ErrorCodes.OnHoldNotIssuable,
@@ -207,7 +270,27 @@ public sealed class FifoCostingService : IFifoCostingService
var take = Math.Min(remaining, layer.QtyRemaining); var take = Math.Min(remaining, layer.QtyRemaining);
layer.QtyRemaining -= take; layer.QtyRemaining -= take;
remaining -= take; remaining -= take;
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost)); segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost, layer.BatchId, layer.SerialId));
}
// Serial units issued without being named (serial numbers switched off): close their
// records so the register doesn't keep showing them in stock.
if (serialIds is null)
{
var issuedSerialIds = layers.Where(l => l.SerialId != null && l.QtyRemaining == 0
&& segments.Any(s => s.LayerId == l.LayerId))
.Select(l => l.SerialId!.Value).ToList();
if (issuedSerialIds.Count > 0)
{
var issued = await _db.Serials.Where(s => issuedSerialIds.Contains(s.SerialId)).ToListAsync(ct);
foreach (var serial in issued)
{
serial.Status = SerialStatus.IssuedUntracked;
serial.WarehouseId = null;
serial.BinId = null;
serial.UpdatedAt = now;
}
}
} }
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes // Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
@@ -232,6 +315,32 @@ public sealed class FifoCostingService : IFifoCostingService
return segments; return segments;
} }
public async Task<IReadOnlyList<BatchAllocation>> SuggestBatchesAsync(
int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var open = await _db.StockLayers.AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && l.SerialId == null
&& (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available)
&& (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today))
.Select(l => new { l.BatchId, BatchNo = l.Batch != null ? l.Batch.BatchNo : null, Expiry = l.Batch != null ? l.Batch.ExpiryDate : null, l.ReceiptDate, l.LayerId, l.QtyRemaining })
.ToListAsync(ct);
var result = new List<BatchAllocation>();
var remaining = qtyBase;
foreach (var group in open
.GroupBy(l => new { l.BatchId, l.BatchNo, l.Expiry })
.OrderBy(g => g.Key.Expiry ?? DateOnly.MaxValue)
.ThenBy(g => g.Min(l => l.ReceiptDate)))
{
if (remaining <= 0) break;
var take = Math.Min(remaining, group.Sum(l => l.QtyRemaining));
result.Add(new BatchAllocation(group.Key.BatchId, group.Key.BatchNo, group.Key.Expiry, take));
remaining -= take;
}
return result;
}
// Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item). // Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item).
private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct) private async Task<decimal> LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct)
{ {
@@ -0,0 +1,263 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Persistence;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <inheritdoc cref="IItemTrackingService"/>
public sealed class ItemTrackingService : IItemTrackingService
{
private readonly ErpDbContext _db;
private readonly IFifoCostingService _fifo;
public ItemTrackingService(ErpDbContext db, IFifoCostingService fifo)
{
_db = db;
_fifo = fifo;
}
private static DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow);
public const int MaxGeneratedSerials = 1000;
public async Task<IReadOnlyList<string>> NextSerialNumbersAsync(int itemId, string prefix, int count, CancellationToken ct = default)
{
prefix = prefix.Trim();
if (count < 1 || count > MaxGeneratedSerials)
throw new DomainException(ErrorCodes.Validation, $"Generate between 1 and {MaxGeneratedSerials} serial numbers at a time.", 422);
if (prefix.Length > 80)
throw new DomainException(ErrorCodes.Validation, "The serial prefix cannot be longer than 80 characters.", 422);
if (!await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId, ct))
throw new NotFoundException($"Item {itemId} was not found.");
// Everything already taken under this prefix: received serials of the item, plus numbers
// sitting on draft GRN lines (they become serials when the GRN is confirmed).
var used = await _db.Serials.AsNoTracking()
.Where(s => s.ItemId == itemId && s.SerialNo.StartsWith(prefix))
.Select(s => s.SerialNo)
.Concat(_db.GrnLineSerials.AsNoTracking()
.Where(s => s.GrnLine!.ItemId == itemId && s.SerialId == null && s.SerialNo.StartsWith(prefix))
.Select(s => s.SerialNo))
.ToListAsync(ct);
// Highest numeric tail after the prefix ("PH-0041" → 41); width follows the widest seen.
long max = 0;
var width = 4;
foreach (var number in used)
{
var tail = number[prefix.Length..];
if (tail.Length == 0 || tail.Length > 18 || !tail.All(char.IsAsciiDigit)) continue;
max = Math.Max(max, long.Parse(tail));
width = Math.Max(width, tail.Length);
}
var taken = used.ToHashSet(StringComparer.OrdinalIgnoreCase);
var result = new List<string>(count);
for (var n = max + 1; result.Count < count; n++)
{
var candidate = prefix + n.ToString().PadLeft(width, '0');
if (candidate.Length > 100)
throw new DomainException(ErrorCodes.Validation, "Generated serial numbers would exceed 100 characters — use a shorter prefix.", 422);
if (taken.Add(candidate)) result.Add(candidate);
}
return result;
}
public async Task<IReadOnlyList<AvailableSerialDto>> GetAvailableSerialsAsync(
int itemId, int warehouseId, string? q, int take, CancellationToken ct = default)
{
var today = Today;
// Issuable = InStock here AND its own layer is open, not on hold, not in an expired batch —
// the same test posting applies, so the picker never offers a serial posting would refuse.
var query = _db.StockLayers.AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.SerialId != null && l.QtyRemaining > 0
&& l.Serial!.Status == SerialStatus.InStock && l.Serial.WarehouseId == warehouseId
&& (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available)
&& (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today));
if (!string.IsNullOrWhiteSpace(q))
{
var term = q.Trim();
query = query.Where(l => EF.Functions.Like(l.Serial!.SerialNo, $"%{term}%") || l.Serial.Barcode == term);
}
return await query
.OrderBy(l => l.Serial!.SerialNo)
.Take(Math.Clamp(take, 1, 500))
.Select(l => new AvailableSerialDto(l.SerialId!.Value, l.Serial!.SerialNo, l.BatchId,
l.Batch != null ? l.Batch.BatchNo : null, l.Serial.Status))
.ToListAsync(ct);
}
public async Task<IReadOnlyList<AvailableBatchDto>> GetAvailableBatchesAsync(int itemId, int warehouseId, CancellationToken ct = default)
{
var today = Today;
var rows = await _db.StockLayers.AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && l.SerialId == null
&& (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available)
&& (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today))
.GroupBy(l => new { l.BatchId, BatchNo = l.Batch != null ? l.Batch.BatchNo : null, Expiry = l.Batch != null ? l.Batch.ExpiryDate : null })
.Select(g => new { g.Key.BatchId, g.Key.BatchNo, g.Key.Expiry, Qty = g.Sum(l => l.QtyRemaining) })
.ToListAsync(ct);
return rows
.OrderBy(r => r.Expiry ?? DateOnly.MaxValue).ThenBy(r => r.BatchNo)
.Select(r => new AvailableBatchDto(r.BatchId, r.BatchNo, r.Expiry, r.Qty,
r.Expiry is DateOnly e ? e.DayNumber - today.DayNumber : null))
.ToList();
}
public async Task<IReadOnlyList<BatchSuggestionDto>> SuggestBatchesAsync(int itemId, int warehouseId, decimal qty, CancellationToken ct = default)
=> (await _fifo.SuggestBatchesAsync(itemId, warehouseId, qty, ct))
.Select(a => new BatchSuggestionDto(a.BatchId, a.BatchNo, a.ExpiryDate, a.Qty)).ToList();
public async Task<PagedResponse<SerialSummaryDto>> ListSerialsAsync(
PageQuery query, int? itemId, int? warehouseId, SerialStatus? status, CancellationToken ct = default)
{
var q = _db.Serials.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(s => EF.Functions.Like(s.SerialNo, $"%{term}%") || s.Barcode == term
|| EF.Functions.Like(s.Item!.Sku, $"%{term}%") || EF.Functions.Like(s.Item.Name, $"%{term}%"));
}
if (itemId is not null) q = q.Where(s => s.ItemId == itemId);
if (warehouseId is not null) q = q.Where(s => s.WarehouseId == warehouseId);
if (status is not null) q = q.Where(s => s.Status == status);
var total = await q.CountAsync(ct);
var rows = await ProjectSerials(q.OrderByDescending(s => s.SerialId).Skip(query.Skip).Take(query.PageSize)).ToListAsync(ct);
return PagedResponse<SerialSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<SerialDetailDto?> GetSerialAsync(int serialId, CancellationToken ct = default)
{
var serial = await ProjectSerials(_db.Serials.AsNoTracking().Where(s => s.SerialId == serialId)).FirstOrDefaultAsync(ct);
return serial is null ? null : await BuildDetailAsync(serial, ct);
}
public async Task<IReadOnlyList<SerialDetailDto>> FindSerialAsync(string serialNo, CancellationToken ct = default)
{
var term = serialNo.Trim();
var serials = await ProjectSerials(_db.Serials.AsNoTracking().Where(s => s.SerialNo == term || s.Barcode == term))
.Take(20).ToListAsync(ct);
var result = new List<SerialDetailDto>();
foreach (var s in serials) result.Add(await BuildDetailAsync(s, ct));
return result;
}
public async Task<PagedResponse<WarrantyRegistrationDto>> ListWarrantiesAsync(
PageQuery query, int? customerId, int? itemId, string? serialNo, WarrantyStatus? status,
int? expiringWithinDays, bool? expired, CancellationToken ct = default)
{
var today = Today;
var q = _db.WarrantyRegistrations.AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(w => (w.Serial != null && EF.Functions.Like(w.Serial.SerialNo, $"%{term}%"))
|| EF.Functions.Like(w.Item!.Sku, $"%{term}%") || EF.Functions.Like(w.Item.Name, $"%{term}%"));
}
if (customerId is not null) q = q.Where(w => w.CustomerId == customerId);
if (itemId is not null) q = q.Where(w => w.ItemId == itemId);
if (!string.IsNullOrWhiteSpace(serialNo))
{
var term = serialNo.Trim();
q = q.Where(w => w.Serial != null && (w.Serial.SerialNo == term || w.Serial.Barcode == term));
}
if (status is not null) q = q.Where(w => w.Status == status);
if (expiringWithinDays is int days)
{
var until = today.AddDays(days);
q = q.Where(w => w.Status == WarrantyStatus.Active && w.EndDate >= today && w.EndDate <= until);
}
if (expired == true) q = q.Where(w => w.EndDate < today);
if (expired == false) q = q.Where(w => w.EndDate >= today);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.EndDate).ThenBy(w => w.WarrantyRegistrationId)
.Skip(query.Skip).Take(query.PageSize)
.Select(WarrantyProjection())
.ToListAsync(ct);
return PagedResponse<WarrantyRegistrationDto>.Create(rows.Select(r => WithDays(r, today)).ToList(), query.Page, query.PageSize, total);
}
public async Task<PagedResponse<BatchStockDto>> ListBatchStockAsync(
PageQuery query, int? itemId, int? warehouseId, int? expiringWithinDays, CancellationToken ct = default)
{
var today = Today;
var layers = _db.StockLayers.AsNoTracking().Where(l => l.QtyRemaining > 0 && l.BatchId != null);
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId);
if (expiringWithinDays is int days)
{
var until = today.AddDays(days);
layers = layers.Where(l => l.Batch!.ExpiryDate != null && l.Batch.ExpiryDate <= until);
}
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
layers = layers.Where(l => EF.Functions.Like(l.Batch!.BatchNo, $"%{term}%")
|| EF.Functions.Like(l.Item!.Sku, $"%{term}%") || EF.Functions.Like(l.Item.Name, $"%{term}%"));
}
var grouped = layers.GroupBy(l => new
{
l.ItemId, Sku = l.Item!.Sku, ItemName = l.Item.Name,
l.WarehouseId, WarehouseName = l.Warehouse != null ? l.Warehouse.Name : null,
l.BatchId, BatchNo = l.Batch!.BatchNo, Expiry = l.Batch.ExpiryDate
}).Select(g => new { g.Key, Qty = g.Sum(l => l.QtyRemaining) });
var total = await grouped.CountAsync(ct);
var rows = await grouped
.OrderBy(g => g.Key.Expiry == null).ThenBy(g => g.Key.Expiry).ThenBy(g => g.Key.Sku)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
return PagedResponse<BatchStockDto>.Create(rows.Select(r => new BatchStockDto(
r.Key.ItemId, r.Key.Sku, r.Key.ItemName, r.Key.WarehouseId, r.Key.WarehouseName ?? $"Warehouse {r.Key.WarehouseId}",
r.Key.BatchId, r.Key.BatchNo, r.Key.Expiry, r.Qty,
r.Key.Expiry is DateOnly e ? e.DayNumber - today.DayNumber : null,
r.Key.Expiry is DateOnly x && x < today)).ToList(), query.Page, query.PageSize, total);
}
private static IQueryable<SerialSummaryDto> ProjectSerials(IQueryable<Serial> q) => q.Select(s => new SerialSummaryDto(
s.SerialId, s.ItemId, s.Item!.Sku, s.Item.Name, s.SerialNo, s.Status,
s.WarehouseId, s.Warehouse != null ? s.Warehouse.Name : null, s.BinId, s.ReceivedAt,
s.GrnLine != null ? s.GrnLine.GrnId : null, s.GrnLine != null && s.GrnLine.Grn != null ? s.GrnLine.Grn.DocNo : null,
s.WarrantyPeriodMonths));
private async Task<SerialDetailDto> BuildDetailAsync(SerialSummaryDto serial, CancellationToken ct)
{
var today = Today;
var history = await _db.SerialMovements.AsNoTracking()
.Where(m => m.SerialId == serial.SerialId)
.OrderBy(m => m.SerialMovementId)
.Select(m => new SerialMovementDto(m.SerialMovementId, m.SourceDocType, m.SourceDocId, m.Status,
m.FromWarehouseId, m.WarehouseId, m.UserId, m.CreatedAt))
.ToListAsync(ct);
var warranties = await _db.WarrantyRegistrations.AsNoTracking()
.Where(w => w.SerialId == serial.SerialId)
.OrderByDescending(w => w.WarrantyRegistrationId)
.Select(WarrantyProjection())
.ToListAsync(ct);
return new SerialDetailDto(serial, history, warranties.Select(w => WithDays(w, today)).ToList());
}
private static global::System.Linq.Expressions.Expression<Func<WarrantyRegistration, WarrantyRegistrationDto>> WarrantyProjection()
=> w => new WarrantyRegistrationDto(
w.WarrantyRegistrationId, w.ItemId, w.Item!.Sku, w.Item.Name, w.SerialId, w.Serial != null ? w.Serial.SerialNo : null,
w.CustomerId, w.Customer != null ? (w.Customer.DisplayName ?? w.Customer.Name) : string.Empty,
w.SourceDocType, w.SourceDocId, w.SourceLineId, w.Qty, w.StartDate, w.PeriodMonths, w.EndDate, w.Status,
0, false);
private static WarrantyRegistrationDto WithDays(WarrantyRegistrationDto w, DateOnly today) => w with
{
DaysRemaining = Math.Max(0, w.EndDate.DayNumber - today.DayNumber),
IsExpired = w.EndDate < today
};
}
@@ -0,0 +1,156 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <inheritdoc cref="ISerialTrackingService"/>
public sealed class SerialTrackingService : ISerialTrackingService
{
private readonly IRepository<Serial> _serials;
private readonly IRepository<SerialMovement> _movements;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<ProductConfig> _config;
private readonly ICurrentUser _currentUser;
private bool? _enabled; // per request (scoped service) — one config read
public SerialTrackingService(
IRepository<Serial> serials, IRepository<SerialMovement> movements,
IRepository<StockLayer> layers, IRepository<ProductConfig> config, ICurrentUser currentUser)
{
_serials = serials;
_movements = movements;
_layers = layers;
_config = config;
_currentUser = currentUser;
}
public async Task<bool> IsEnabledAsync(CancellationToken ct = default)
=> _enabled ??= await _config.Query().AsNoTracking()
.Where(c => c.ConfigId == ProductConfig.SingletonId)
.Select(c => (bool?)c.SerialNumbersEnabled)
.FirstOrDefaultAsync(ct) ?? true;
public async Task<bool> IsSerialTrackedAsync(Item item, CancellationToken ct = default)
=> item.TrackingMode == TrackingMode.Serial && await IsEnabledAsync(ct);
public void RejectSerialsWhileDisabled(Item item, int serialCount)
{
if (item.TrackingMode == TrackingMode.Serial && serialCount > 0)
throw new DomainException(ErrorCodes.ConfigDisabled,
$"Serial numbers are switched off in Product Configuration; item {item.Sku} is handled without serials.", 422);
}
public async Task<IReadOnlyList<Serial>> ResolveOutboundAsync(
Item item, int warehouseId, decimal qty, IReadOnlyCollection<int>? serialIds,
ISet<int> documentSerials, IReadOnlyCollection<SerialStatus> allowedStatuses, CancellationToken ct = default)
{
var ids = serialIds?.ToList() ?? new List<int>();
if (!await IsSerialTrackedAsync(item, ct))
{
RejectSerialsWhileDisabled(item, ids.Count);
if (ids.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422);
return Array.Empty<Serial>();
}
if (ids.Count == 0)
{
var untracked = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == item.ItemId && l.WarehouseId == warehouseId && l.SerialId == null && l.QtyRemaining > 0)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
if (untracked < qty)
throw new DomainException(ErrorCodes.TrackingRequired, $"Item {item.Sku} is serial-tracked; select {qty} serial number(s).", 422);
return Array.Empty<Serial>();
}
if (ids.Count != qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku}: {qty} unit(s) need exactly that many serials, got {ids.Count}.", 422);
foreach (var id in ids)
if (!documentSerials.Add(id))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is used more than once on this document.", 422);
var serials = await _serials.Query().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct);
foreach (var id in ids)
{
var serial = serials.FirstOrDefault(s => s.SerialId == id);
if (serial is null || serial.ItemId != item.ItemId)
throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422);
if (serial.WarehouseId != warehouseId || !allowedStatuses.Contains(serial.Status))
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial {serial.SerialNo} is {serial.Status}{(serial.WarehouseId is int w && w != warehouseId ? $" at warehouse {w}" : string.Empty)} and cannot leave warehouse {warehouseId}.", 422);
}
return serials;
}
public async Task<IReadOnlyList<Serial>> ResolveInboundAsync(
Item item, int warehouseId, decimal qty, IReadOnlyCollection<string>? serialNos,
ISet<string> documentSerialNos, CancellationToken ct = default)
{
var numbers = (serialNos ?? Array.Empty<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
if (!await IsSerialTrackedAsync(item, ct))
{
RejectSerialsWhileDisabled(item, numbers.Count);
if (numbers.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serial numbers are not accepted.", 422);
return Array.Empty<Serial>();
}
if (qty != decimal.Truncate(qty))
throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is serial-tracked; the quantity must be a whole number.", 422);
if (numbers.Count != qty)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {item.Sku} is serial-tracked; enter exactly {qty} serial number(s), got {numbers.Count}.", 422);
if (numbers.Any(n => n.Length > 100))
throw new DomainException(ErrorCodes.Validation, "A serial number cannot be longer than 100 characters.", 422);
foreach (var n in numbers)
if (!documentSerialNos.Add($"{item.ItemId}:{n.ToUpperInvariant()}"))
throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number {n} is entered more than once.", 422);
var existing = await _serials.Query().Where(s => s.ItemId == item.ItemId && numbers.Contains(s.SerialNo)).ToListAsync(ct);
var inStock = existing.Where(s => s.WarehouseId != null).Select(s => s.SerialNo).ToList();
if (inStock.Count > 0)
throw new DomainException(ErrorCodes.SerialDuplicate,
$"Serial number(s) {string.Join(", ", inStock)} of item {item.Sku} are already in stock.", 409);
var now = DateTime.UtcNow;
var result = new List<Serial>(existing);
foreach (var n in numbers.Where(n => !existing.Any(e => string.Equals(e.SerialNo, n, StringComparison.OrdinalIgnoreCase))))
{
var serial = new Serial
{
ItemId = item.ItemId,
SerialNo = n,
// Location is set by MoveAsync once the stock actually lands. Callers must save
// (flush) before building StockDeltas, which need the generated SerialId.
Status = SerialStatus.InStock,
ReceivedAt = now,
CreatedAt = now,
WarrantyPeriodMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null
};
await _serials.AddAsync(serial, ct);
result.Add(serial);
}
return result;
}
public async Task MoveAsync(
IEnumerable<Serial> serials, SerialStatus status, int? warehouseId, int? binId,
string sourceDocType, int sourceDocId, DateTime now, CancellationToken ct = default)
{
foreach (var serial in serials)
{
var from = serial.WarehouseId;
serial.Status = status;
serial.WarehouseId = warehouseId;
serial.BinId = binId;
serial.UpdatedAt = now;
await _movements.AddAsync(SerialMovement.Of(serial, from, sourceDocType, sourceDocId, _currentUser.AuditUserId, now), ct);
}
}
}
+21 -5
View File
@@ -3,6 +3,7 @@ using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock; namespace ERPCore.Services.Stock;
@@ -41,24 +42,39 @@ public sealed class StockMutator : IStockMutator
if (!balances.TryGetValue(d.ItemId, out var bal)) if (!balances.TryGetValue(d.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct); bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct);
var serialIds = d.SerialIds is { Count: > 0 } ? d.SerialIds : null;
if (serialIds is not null && serialIds.Count != Math.Abs(d.QtyDelta))
throw new DomainException(ErrorCodes.TrackingRequired,
$"Item {d.ItemId}: {Math.Abs(d.QtyDelta)} unit(s) need exactly that many serials, got {serialIds.Count}.", 422);
// The ledger carries a serial only when the movement is exactly one unit.
int? ledgerSerialId = serialIds is { Count: 1 } ? serialIds[0] : null;
StockLedger entry; StockLedger entry;
if (d.QtyDelta < 0) if (d.QtyDelta < 0)
{ {
var qty = -d.QtyDelta; var qty = -d.QtyDelta;
var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct); var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct, serialIds: serialIds);
var unitCost = segments.Sum(s => s.Qty * s.UnitCost) / qty; var unitCost = segments.Sum(s => s.Qty * s.UnitCost) / qty;
bal -= qty; bal -= qty;
entry = await _fifo.PostLedgerAsync( entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor, d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor,
Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct); Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct);
} }
else else
{ {
var unitCost = await LastCostAsync(d.ItemId, warehouseId, ct); var unitCost = d.UnitCost ?? await LastCostAsync(d.ItemId, warehouseId, ct);
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct); if (serialIds is not null)
{
foreach (var serialId in serialIds)
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, serialId, null, 1m, unitCost, now, ct);
}
else
{
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct);
}
bal += d.QtyDelta; bal += d.QtyDelta;
entry = await _fifo.PostLedgerAsync( entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor, d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor,
Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct); Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct);
} }
+112 -19
View File
@@ -37,12 +37,15 @@ public sealed class TransferService : ITransferService
private readonly IBranchConfigService _branchConfig; private readonly IBranchConfigService _branchConfig;
private readonly INotificationService _notifications; private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow; private readonly IUnitOfWork _uow;
private readonly ISerialTrackingService _serialTracking;
public TransferService( public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items, IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow) IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
ISerialTrackingService serialTracking)
{ {
_serialTracking = serialTracking;
_transfers = transfers; _transfers = transfers;
_warehouses = warehouses; _warehouses = warehouses;
_items = items; _items = items;
@@ -83,7 +86,8 @@ public sealed class TransferService : ITransferService
public async Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default) public async Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default)
{ {
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines) var t = await _transfers.Query().AsNoTracking()
.Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(x => x.TransferId == transferId, ct); .FirstOrDefaultAsync(x => x.TransferId == transferId, ct);
return t is null ? null : Map(t); return t is null ? null : Map(t);
} }
@@ -95,9 +99,42 @@ public sealed class TransferService : ITransferService
await EnsureWarehouseAsync(request.SrcWarehouseId, ct); await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
await EnsureWarehouseAsync(request.DestWarehouseId, ct); await EnsureWarehouseAsync(request.DestWarehouseId, ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
// Resolve tracking up front: serial lines carry their serials; a batch-tracked line with
// no batch is split FEFO into one line per batch, so batch identity survives the move.
var lines = new List<StockTransferLine>();
var documentSerials = new HashSet<int>();
foreach (var line in request.Lines) foreach (var line in request.Lines)
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) {
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
var serials = await _serialTracking.ResolveOutboundAsync(
item, request.SrcWarehouseId, line.Qty, line.SerialIds, documentSerials, new[] { SerialStatus.InStock }, ct);
StockTransferLine NewLine(int? batchId, decimal qty) => new()
{
ItemId = line.ItemId,
SrcBinId = line.SrcBinId,
DestBinId = line.DestBinId,
BatchId = batchId,
Qty = qty,
SerialId = serials.Count == 1 ? serials[0].SerialId : null,
Serials = serials.Select(s => new StockTransferLineSerial { SerialId = s.SerialId }).ToList()
};
if (item.TrackingMode == TrackingMode.Batch && line.BatchId is null)
{
var picks = await _fifo.SuggestBatchesAsync(line.ItemId, request.SrcWarehouseId, line.Qty, ct);
var picked = picks.Sum(p => p.Qty);
foreach (var pick in picks) lines.Add(NewLine(pick.BatchId, pick.Qty));
// Whatever FEFO could not place stays unbatched — dispatch then reports the shortfall.
if (picked < line.Qty) lines.Add(NewLine(null, line.Qty - picked));
}
else
{
lines.Add(NewLine(line.BatchId, line.Qty));
}
}
var actor = _currentUser.AuditUserId; var actor = _currentUser.AuditUserId;
var transfer = await _uow.ExecuteInTransactionAsync(async token => var transfer = await _uow.ExecuteInTransactionAsync(async token =>
@@ -112,14 +149,7 @@ public sealed class TransferService : ITransferService
Status = TransferStatus.Draft, Status = TransferStatus.Draft,
CreatedBy = actor, CreatedBy = actor,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new StockTransferLine Lines = lines
{
ItemId = l.ItemId,
SrcBinId = l.SrcBinId,
DestBinId = l.DestBinId,
BatchId = l.BatchId,
Qty = l.Qty
}).ToList()
}; };
await _transfers.AddAsync(entity, token); await _transfers.AddAsync(entity, token);
return entity; return entity;
@@ -132,7 +162,8 @@ public sealed class TransferService : ITransferService
public async Task<DispatchResultDto> DispatchAsync(int transferId, CancellationToken ct = default) public async Task<DispatchResultDto> DispatchAsync(int transferId, CancellationToken ct = default)
{ {
var transfer = await _transfers.Query().Include(t => t.Lines) var transfer = await _transfers.Query()
.Include(t => t.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct) .FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
?? throw new NotFoundException($"Transfer {transferId} was not found."); ?? throw new NotFoundException($"Transfer {transferId} was not found.");
if (transfer.Status != TransferStatus.Draft) if (transfer.Status != TransferStatus.Draft)
@@ -148,10 +179,28 @@ public sealed class TransferService : ITransferService
{ {
foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId)) foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId))
{ {
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token); // The draft didn't reserve its serials — re-check they are still here and in stock.
var serials = line.Serials.Select(s => s.Serial!).ToList();
var gone = serials.Where(s => s.Status != SerialStatus.InStock || s.WarehouseId != transfer.SrcWarehouseId).Select(s => s.SerialNo).ToList();
if (gone.Count > 0)
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Serial(s) {string.Join(", ", gone)} are no longer in stock at the source warehouse.", 409);
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token,
serialIds: serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null);
var value = segments.Sum(s => s.Qty * s.UnitCost); var value = segments.Sum(s => s.Qty * s.UnitCost);
line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive
// Each serial keeps its own cost and batch for its destination layer.
foreach (var lineSerial in line.Serials)
{
var segment = segments.First(s => s.SerialId == lineSerial.SerialId);
lineSerial.UnitCost = segment.UnitCost;
lineSerial.BatchId = segment.BatchId;
}
await _serialTracking.MoveAsync(serials, SerialStatus.InTransit, null, null,
DocumentTypes.Transfer, transfer.TransferId, now, token);
if (!balances.TryGetValue(line.ItemId, out var bal)) if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token); bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token);
bal -= line.Qty; bal -= line.Qty;
@@ -174,7 +223,8 @@ public sealed class TransferService : ITransferService
public async Task<ReceiveResultDto> ReceiveAsync(int transferId, ReceiveTransferRequest request, CancellationToken ct = default) public async Task<ReceiveResultDto> ReceiveAsync(int transferId, ReceiveTransferRequest request, CancellationToken ct = default)
{ {
var transfer = await _transfers.Query().Include(t => t.Lines) var transfer = await _transfers.Query()
.Include(t => t.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct) .FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
?? throw new NotFoundException($"Transfer {transferId} was not found."); ?? throw new NotFoundException($"Transfer {transferId} was not found.");
if (transfer.Status != TransferStatus.InTransit) if (transfer.Status != TransferStatus.InTransit)
@@ -199,9 +249,26 @@ public sealed class TransferService : ITransferService
$"Receiving {input.Qty} exceeds the outstanding in-transit {outstanding} on line {input.TransferLineId}.", 422); $"Receiving {input.Qty} exceeds the outstanding in-transit {outstanding} on line {input.TransferLineId}.", 422);
var unitCost = line.UnitCost ?? 0m; var unitCost = line.UnitCost ?? 0m;
var layer = await _fifo.CreateInboundLayerAsync( if (line.Serials.Count > 0)
line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token); {
createdLayers.Add(layer); var arriving = ResolveArrivingSerials(line, input);
foreach (var lineSerial in arriving)
{
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, transfer.DestWarehouseId, lineSerial.BatchId ?? line.BatchId, lineSerial.SerialId, null,
1m, lineSerial.UnitCost ?? unitCost, now, token);
createdLayers.Add(layer);
lineSerial.ReceivedAt = now;
}
await _serialTracking.MoveAsync(arriving.Select(s => s.Serial!), SerialStatus.InStock,
transfer.DestWarehouseId, line.DestBinId, DocumentTypes.Transfer, transfer.TransferId, now, token);
}
else
{
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token);
createdLayers.Add(layer);
}
if (!balances.TryGetValue(line.ItemId, out var bal)) if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.DestWarehouseId, token); bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.DestWarehouseId, token);
@@ -234,8 +301,34 @@ public sealed class TransferService : ITransferService
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422); throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
} }
/// <summary>
/// Which in-transit serials a receive line covers: the ones it names (each still outstanding
/// on the line), or — with none named — every outstanding one, which must then be the full qty.
/// </summary>
private static List<StockTransferLineSerial> ResolveArrivingSerials(StockTransferLine line, ReceiveTransferLineInput input)
{
var outstanding = line.Serials.Where(s => s.ReceivedAt == null).ToList();
if (input.SerialIds is not { Count: > 0 })
{
if (input.Qty != outstanding.Count)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Transfer line {line.TransferLineId} is serial-tracked; name which {input.Qty} of its {outstanding.Count} in-transit serials arrived.", 422);
return outstanding;
}
if (input.SerialIds.Count != input.Qty || input.SerialIds.Distinct().Count() != input.SerialIds.Count)
throw new DomainException(ErrorCodes.TrackingRequired,
$"Receiving {input.Qty} on transfer line {line.TransferLineId} needs exactly that many distinct serials.", 422);
var arriving = outstanding.Where(s => input.SerialIds.Contains(s.SerialId)).ToList();
if (arriving.Count != input.SerialIds.Count)
throw new DomainException(ErrorCodes.SerialNotAvailable,
$"Some serials are not in transit on transfer line {line.TransferLineId}.", 422);
return arriving;
}
private static TransferDto Map(StockTransfer t) => new( private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt, t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt,
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto( t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList()); l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived,
l.Serials.Select(s => new TransferLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty, s.ReceivedAt != null)).ToList())).ToList());
} }
@@ -30,6 +30,11 @@ public static class ErrorCodes
public const string ConfigDisabled = "CONFIG_DISABLED"; public const string ConfigDisabled = "CONFIG_DISABLED";
public const string RoleInUse = "ROLE_IN_USE"; public const string RoleInUse = "ROLE_IN_USE";
// Item tracking (docs/ITEM_TRACKING_PLAN.md).
public const string SerialDuplicate = "SERIAL_DUPLICATE";
public const string SerialNotAvailable = "SERIAL_NOT_AVAILABLE";
public const string TrackingRequired = "TRACKING_REQUIRED";
// Auth proxy (AuthController → AuthHex, docs/11 §2.0) // Auth proxy (AuthController → AuthHex, docs/11 §2.0)
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR"; public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE"; public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE";
@@ -18,6 +18,7 @@ import { CreatePurchaseReturnLineInput } from "@/types/procurement"
import { Grn, GrnLine } from "@/types/grn" import { Grn, GrnLine } from "@/types/grn"
import { ItemListItem } from "@/types/master-data" import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -39,6 +40,8 @@ const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background
interface LineState { interface LineState {
selected: boolean selected: boolean
qty: string qty: string
/** Serial line: the received serials going back to the vendor (qty follows the count). */
serialIds?: number[]
} }
function NewPurchaseReturnContent() { function NewPurchaseReturnContent() {
@@ -62,6 +65,7 @@ function NewPurchaseReturnContent() {
const [headerError, setHeaderError] = useState<string | null>(null) const [headerError, setHeaderError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const { serialNumbersEnabled } = useTrackingSettings()
useEffect(() => { useEffect(() => {
Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")]) Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
@@ -121,6 +125,33 @@ function NewPurchaseReturnContent() {
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } })) setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } }))
} }
function toggleSerial(grnLineId: number, serialId: number) {
setLineState((prev) => {
const current = prev[grnLineId]?.serialIds ?? []
const next = current.includes(serialId) ? current.filter((id) => id !== serialId) : [...current, serialId]
return { ...prev, [grnLineId]: { selected: next.length > 0, qty: next.length > 0 ? String(next.length) : "", serialIds: next } }
})
}
/** A serial line lists its received serials to tick — the server refuses any no longer in stock. */
function renderSerials(line: GrnLine) {
// Serial numbers switched off in Product Configuration: the line goes back as plain stock.
if (serialNumbersEnabled === false) return null
const serials = (line.serials ?? []).filter((s) => s.serialId !== null)
if (serials.length === 0) return null
const picked = lineState[line.grnLineId]?.serialIds ?? []
return (
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1">
{serials.map((s) => (
<label key={s.serialId} className="flex items-center gap-1.5 text-sm">
<Checkbox checked={picked.includes(s.serialId!)} onCheckedChange={() => toggleSerial(line.grnLineId, s.serialId!)} />
<span className="font-mono">{s.serialNo}</span>
</label>
))}
</div>
)
}
async function handleSubmit() { async function handleSubmit() {
setHeaderError(null) setHeaderError(null)
setSubmitError(null) setSubmitError(null)
@@ -143,6 +174,8 @@ function NewPurchaseReturnContent() {
const nextErrors: Record<number, Record<string, string>> = {} const nextErrors: Record<number, Record<string, string>> = {}
for (const line of selectedLines) { for (const line of selectedLines) {
const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty }) const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty })
if (serialNumbersEnabled !== false && (line.serials ?? []).some((s) => s.serialId !== null) && (lineState[line.grnLineId].serialIds?.length ?? 0) !== Number(lineState[line.grnLineId].qty))
errors.qty = "Tick one serial per unit going back"
if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors
} }
setLineErrors(nextErrors) setLineErrors(nextErrors)
@@ -155,6 +188,7 @@ function NewPurchaseReturnContent() {
grnLineId: l.grnLineId, grnLineId: l.grnLineId,
itemId: l.itemId, itemId: l.itemId,
qty: Number(lineState[l.grnLineId].qty), qty: Number(lineState[l.grnLineId].qty),
serialIds: lineState[l.grnLineId].serialIds?.length ? lineState[l.grnLineId].serialIds : null,
})) }))
setSubmitting(true) setSubmitting(true)
@@ -249,6 +283,7 @@ function NewPurchaseReturnContent() {
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
</span> </span>
</label> </label>
{renderSerials(line)}
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm"> <div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div> <div>
@@ -309,7 +344,10 @@ function NewPurchaseReturnContent() {
aria-label={`Return ${item?.sku ?? `item #${line.itemId}`}`} aria-label={`Return ${item?.sku ?? `item #${line.itemId}`}`}
/> />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</TableCell> <TableCell className="px-3 py-3.5">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
{renderSerials(line)}
</TableCell>
<TableCell className="px-3 py-3.5">{line.qty}</TableCell> <TableCell className="px-3 py-3.5">{line.qty}</TableCell>
<TableCell className="px-3 py-3.5"> <TableCell className="px-3 py-3.5">
<HoldStatusBadge status={line.holdStatus} /> <HoldStatusBadge status={line.holdStatus} />
@@ -11,6 +11,7 @@ import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { invalidateTrackingSettings } from "@/hooks/use-tracking-settings"
/** /**
* Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate. * Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate.
@@ -61,6 +62,8 @@ export default function ProductSettingsPage() {
) )
setConfig(res.data) setConfig(res.data)
setEtag(res.etag) setEtag(res.etag)
// Screens read the serial switch through a cached hook — make the next read fresh.
invalidateTrackingSettings()
toast.success("Configuration saved", message) toast.success("Configuration saved", message)
return true return true
} catch (err) { } catch (err) {
@@ -152,7 +155,7 @@ export default function ProductSettingsPage() {
<ToggleRow <ToggleRow
label="Serial numbers" label="Serial numbers"
description="Allows items to be created with Serial tracking." description="On: serial-tracked items need one serial per unit on GRNs, sales, returns, transfers, adjustments and counts, and warranties follow each serial. Off: no serials are asked for or accepted anywhere; serial items are handled as plain stock (oldest first) and the Serials page is hidden. Existing serial history is kept."
checked={config.serialNumbersEnabled} checked={config.serialNumbersEnabled}
busy={saving === "serialNumbersEnabled"} busy={saving === "serialNumbersEnabled"}
onChange={(v) => toggle("serialNumbersEnabled", v)} onChange={(v) => toggle("serialNumbersEnabled", v)}
@@ -13,7 +13,7 @@ import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ConfirmGrnResponse, Grn } from "@/types/grn" import { ConfirmGrnResponse, Grn, GrnLine } from "@/types/grn"
import { Bin, ItemListItem, Uom } from "@/types/master-data" import { Bin, ItemListItem, Uom } from "@/types/master-data"
import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices" import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices"
@@ -27,6 +27,34 @@ import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badg
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint" import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */
function LineTrackingInfo({ line }: { line: GrnLine }) {
const serials = line.serials ?? []
if (!line.batchNo && serials.length === 0) return null
return (
<div className="mt-1 flex flex-col gap-1 text-xs text-muted-foreground">
{line.batchNo && (
<p>
Batch <span className="font-medium text-foreground">{line.batchNo}</span>
{line.batchExpiryDate && <> · expires {line.batchExpiryDate}</>}
</p>
)}
{serials.length > 0 && (
<details>
<summary className="cursor-pointer select-none">
{serials.length} serial number{serials.length === 1 ? "" : "s"}
</summary>
<ul className="mt-1 grid max-h-48 grid-cols-2 gap-x-4 overflow-y-auto font-mono">
{serials.map((s) => (
<li key={s.serialNo}>{s.serialNo}</li>
))}
</ul>
</details>
)}
</div>
)
}
export default function GrnDetailPage() { export default function GrnDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter() const router = useRouter()
@@ -235,6 +263,7 @@ export default function GrnDetailPage() {
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p> </p>
)} )}
<LineTrackingInfo line={line} />
</div> </div>
<HoldStatusBadge status={line.holdStatus} /> <HoldStatusBadge status={line.holdStatus} />
</div> </div>
@@ -357,6 +386,7 @@ export default function GrnDetailPage() {
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
</p> </p>
)} )}
<LineTrackingInfo line={line} />
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell> <TableCell className="px-3 py-3.5">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell> <TableCell className="px-3 py-3.5">{binFor(line.binId)}</TableCell>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react" import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2, Wand2 } from "lucide-react"
import { ScanInput } from "@/components/scanning/ScanInput" import { ScanInput } from "@/components/scanning/ScanInput"
import { ApiError } from "@/lib/api-client" import { ApiError } from "@/lib/api-client"
@@ -15,6 +15,8 @@ import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms" import { uomsApi } from "@/lib/api/uoms"
import { grnConfigApi } from "@/lib/api/grn-config" import { grnConfigApi } from "@/lib/api/grn-config"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { trackingApi } from "@/lib/api/tracking"
import { baseUomLabel } from "@/lib/uom-label" import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn" import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
@@ -40,12 +42,25 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
type Mode = "po" | "direct" type Mode = "po" | "direct"
type LineTab = "lines" | "warranty" type LineTab = "lines" | "warranty"
/** Whole units a line's qty represents — one warranty number is captured per unit. */ /** Whole units a line's qty represents — one warranty/serial number is captured per unit. */
function unitCount(qty: string): number { function unitCount(qty: string): number {
const n = Math.floor(Number(qty)) const n = Math.floor(Number(qty))
return Number.isFinite(n) && n > 0 ? n : 0 return Number.isFinite(n) && n > 0 ? n : 0
} }
/**
* Items that need one number per received unit: serial-tracked items (their serial numbers —
* which double as the warranty identity, docs/ITEM_TRACKING_PLAN.md D1) and other warranty items.
*/
function capturesUnitNumbers(item: ItemListItem | null | undefined): boolean {
return item?.trackingMode === "Serial" || item?.warranty === "Warranty"
}
/** "serial number" for a serial-tracked item, else "warranty number". */
function unitNumberLabel(item: ItemListItem | null | undefined): string {
return item?.trackingMode === "Serial" ? "serial number" : "warranty number"
}
/** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */ /** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */
function WarrantyHintBubble() { function WarrantyHintBubble() {
const [visible, setVisible] = useState(true) const [visible, setVisible] = useState(true)
@@ -83,10 +98,15 @@ interface DraftLine {
discountPct: string discountPct: string
vatPct: string vatPct: string
holdStatus: HoldStatus holdStatus: HoldStatus
/** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */ /**
* One entry per received unit, index-aligned; only meaningful when the item captures unit
* numbers — sent as `serialNumbers` for a serial-tracked item, else as `warrantyNumbers`.
*/
warrantyNumbers: string[] warrantyNumbers: string[]
/** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo }` on submit. */ /** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo, expiryDate }` on submit. */
batchNo: string batchNo: string
/** yyyy-mm-dd, optional — drives FEFO picking and expiry blocking. */
batchExpiry: string
} }
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */ /** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
@@ -155,10 +175,12 @@ function buildWarrantyRange(from: string, to: string): string[] | null {
* with a generated number — existing (manually edited) values are left untouched. * with a generated number — existing (manually edited) values are left untouched.
*/ */
function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] { function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] {
if (item?.warranty !== "Warranty") return l.warrantyNumbers if (!capturesUnitNumbers(item)) return l.warrantyNumbers
const units = unitCount(l.qty) const units = unitCount(l.qty)
const next = l.warrantyNumbers.slice(0, units) const next = l.warrantyNumbers.slice(0, units)
while (next.length < units) next.push("") while (next.length < units) next.push("")
// Serial numbers are the manufacturer's — never invent them; leave the slots for scanning.
if (item?.trackingMode === "Serial") return next
for (let i = 0; i < units; i++) { for (let i = 0; i < units; i++) {
if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber() if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber()
} }
@@ -179,12 +201,14 @@ function emptyLine(): DraftLine {
holdStatus: "Available", holdStatus: "Available",
warrantyNumbers: [], warrantyNumbers: [],
batchNo: "", batchNo: "",
batchExpiry: "",
} }
} }
export default function NewGrnPage() { export default function NewGrnPage() {
const router = useRouter() const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth() const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const { serialNumbersEnabled } = useTrackingSettings()
const [mode, setMode] = useState<Mode>("po") const [mode, setMode] = useState<Mode>("po")
@@ -339,6 +363,7 @@ export default function NewGrnPage() {
holdStatus: "Available", holdStatus: "Available",
warrantyNumbers: [], warrantyNumbers: [],
batchNo: "", batchNo: "",
batchExpiry: "",
} }
return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) } return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
}) })
@@ -440,8 +465,16 @@ export default function NewGrnPage() {
setLines((prev) => prev.filter((l) => l.key !== key)) setLines((prev) => prev.filter((l) => l.key !== key))
} }
/**
* The item as this GRN must treat it: with serial numbers switched off in Product
* Configuration a Serial item is received as untracked stock (a warranty item then takes
* warranty numbers again), so every serial rule below follows from this one place.
*/
function itemFor(itemId: number | null) { function itemFor(itemId: number | null) {
return items?.find((i) => i.itemId === itemId) ?? null const item = items?.find((i) => i.itemId === itemId) ?? null
return item && item.trackingMode === "Serial" && serialNumbersEnabled === false
? { ...item, trackingMode: "None" as const }
: item
} }
const itemOptions = useMemo<SuggestOption[]>( const itemOptions = useMemo<SuggestOption[]>(
@@ -530,11 +563,68 @@ export default function NewGrnPage() {
// From/To are left exactly as typed — not cleared — so a typo is easy to fix in place. // From/To are left exactly as typed — not cleared — so a typo is easy to fix in place.
} }
// Lines whose item is warranty-tracked and carry at least one unit — these are the rows // Lines whose item is serial- or warranty-tracked and carry at least one unit — these are
// the "Warranty numbers" tab needs to capture, one input per unit. // the rows the "Serial / warranty numbers" tab needs to capture, one input per unit.
const warrantyLines = lines const warrantyLines = lines
.map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) })) .map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) }))
.filter((w) => w.item?.warranty === "Warranty" && w.units > 0) .filter((w) => capturesUnitNumbers(w.item) && w.units > 0)
// Bulk paste for a line's serial/warranty numbers — one per line (or comma-separated), filling
// units from the first; extra pasted values beyond the unit count are reported, not dropped silently.
const [pasteInputs, setPasteInputs] = useState<Record<string, string>>({})
function applyPastedNumbers(lineKey: string, units: number) {
const values = (pasteInputs[lineKey] ?? "").split(/[\r\n,;\t]+/).map((s) => s.trim()).filter((s) => s.length > 0)
if (values.length === 0) return
setLines((prev) =>
prev.map((l) => {
if (l.key !== lineKey) return l
const next = l.warrantyNumbers.slice(0, units)
while (next.length < units) next.push("")
values.slice(0, units).forEach((v, i) => (next[i] = v))
return { ...l, warrantyNumbers: next }
})
)
setPasteInputs((prev) => ({ ...prev, [lineKey]: "" }))
if (values.length > units)
toast.error("Too many numbers", `${values.length} pasted for ${units} unit(s) — the extra ${values.length - units} were not used.`)
}
// Auto-generate serial numbers: the server continues the highest number already used under the
// prefix (received + draft GRNs); numbers already typed on this form are skipped too. Only empty
// unit slots are filled — scanned/typed serials are never overwritten.
const [genPrefixes, setGenPrefixes] = useState<Record<string, string>>({})
const [generating, setGenerating] = useState<string | null>(null)
async function autoGenerateSerials(lineKey: string, itemId: number, units: number, prefix: string) {
const line = lines.find((l) => l.key === lineKey)
if (!line) return
const blanks = Array.from({ length: units }, (_, i) => i).filter((i) => !(line.warrantyNumbers[i] ?? "").trim())
if (blanks.length === 0) {
toast.error("Nothing to generate", "Every unit already has a serial number — clear some first.")
return
}
const inForm = new Set(
lines.filter((l) => l.itemId === itemId).flatMap((l) => l.warrantyNumbers.map((s) => s.trim().toLowerCase())).filter((s) => s)
)
setGenerating(lineKey)
try {
const fetched = await trackingApi.nextSerialNumbers(itemId, prefix, Math.min(1000, blanks.length + inForm.size))
const fresh = fetched.filter((n) => !inForm.has(n.toLowerCase())).slice(0, blanks.length)
setLines((prev) =>
prev.map((l) => {
if (l.key !== lineKey) return l
const next = l.warrantyNumbers.slice(0, units)
while (next.length < units) next.push("")
blanks.forEach((slot, i) => { if (fresh[i]) next[slot] = fresh[i] })
return { ...l, warrantyNumbers: next }
})
)
toast.success("Serial numbers generated", `${fresh.length} filled, starting ${fresh[0] ?? "—"}.`)
} catch (err) {
toast.error("Could not generate serial numbers", errorMessage(err))
} finally {
setGenerating(null)
}
}
async function handleSubmit() { async function handleSubmit() {
setSubmitError(null) setSubmitError(null)
@@ -576,6 +666,8 @@ export default function NewGrnPage() {
discountPct: allowDiscounts ? line.discountPct : "0", discountPct: allowDiscounts ? line.discountPct : "0",
vatPct: allowVat ? line.vatPct : "0", vatPct: allowVat ? line.vatPct : "0",
warranty: item?.warranty ?? null, warranty: item?.warranty ?? null,
trackingMode: item?.trackingMode ?? null,
batchNo: line.batchNo,
warrantyNumbers: line.warrantyNumbers, warrantyNumbers: line.warrantyNumbers,
}) })
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
@@ -593,6 +685,8 @@ export default function NewGrnPage() {
const payloadLines: CreateGrnLineInput[] = lines.map((l) => { const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
const item = itemFor(l.itemId) const item = itemFor(l.itemId)
const unitNumbers = l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
const serial = item?.trackingMode === "Serial"
return { return {
poLineId: l.poLineId, poLineId: l.poLineId,
itemId: l.itemId as number, itemId: l.itemId as number,
@@ -602,11 +696,13 @@ export default function NewGrnPage() {
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0, discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
vatPct: allowVat ? Number(l.vatPct) || 0 : 0, vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
holdStatus: l.holdStatus, holdStatus: l.holdStatus,
warrantyNumbers: // A serial item's serials are its warranty identity — it sends no warranty numbers.
item?.warranty === "Warranty" serialNumbers: serial ? unitNumbers : null,
? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0) warrantyNumbers: !serial && item?.warranty === "Warranty" ? unitNumbers : null,
batch:
item?.trackingMode === "Batch" && l.batchNo.trim()
? { batchNo: l.batchNo.trim(), expiryDate: l.batchExpiry || null }
: null, : null,
batch: item?.trackingMode === "Batch" && l.batchNo.trim() ? { batchNo: l.batchNo.trim() } : null,
} }
}) })
@@ -861,7 +957,7 @@ export default function NewGrnPage() {
className="rounded-full" className="rounded-full"
onClick={() => setLineTab("warranty")} onClick={() => setLineTab("warranty")}
> >
Warranty numbers Serial / warranty numbers
{warrantyLines.length > 0 && ( {warrantyLines.length > 0 && (
<Badge variant="outline" className="ml-1.5 h-5 px-1.5 text-xs"> <Badge variant="outline" className="ml-1.5 h-5 px-1.5 text-xs">
{warrantyLines.reduce((sum, w) => sum + w.units, 0)} {warrantyLines.reduce((sum, w) => sum + w.units, 0)}
@@ -951,14 +1047,27 @@ export default function NewGrnPage() {
</div> </div>
{item?.trackingMode === "Batch" && ( {item?.trackingMode === "Batch" && (
<div> <div className="grid grid-cols-2 gap-3">
<Label className="text-sm">Batch no</Label> <div>
<Input <Label className="text-sm">Batch no</Label>
value={line.batchNo} <Input
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })} value={line.batchNo}
placeholder="Batch no" aria-invalid={!!errors.batchNo}
className="h-11 text-sm" onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
/> placeholder="Batch no"
className="h-11 text-sm"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
<div>
<Label className="text-sm">Expiry</Label>
<Input
type="date"
value={line.batchExpiry}
onChange={(e) => updateLine(line.key, { batchExpiry: e.target.value })}
className="h-11 text-sm"
/>
</div>
</div> </div>
)} )}
@@ -1050,10 +1159,10 @@ export default function NewGrnPage() {
</Select> </Select>
</div> </div>
{item?.warranty === "Warranty" && ( {capturesUnitNumbers(item) && (
<Button type="button" variant="outline" size="sm" onClick={() => setLineTab("warranty")}> <Button type="button" variant="outline" size="sm" onClick={() => setLineTab("warranty")}>
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4" />
Add warranty numbers Add {unitNumberLabel(item)}s
</Button> </Button>
)} )}
{errors.warrantyNumbers && <FieldError errors={[{ message: errors.warrantyNumbers }]} />} {errors.warrantyNumbers && <FieldError errors={[{ message: errors.warrantyNumbers }]} />}
@@ -1134,12 +1243,24 @@ export default function NewGrnPage() {
</TableCell> </TableCell>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" ? ( {item?.trackingMode === "Batch" ? (
<Input <div className="flex flex-col gap-1.5">
value={line.batchNo} <Input
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })} value={line.batchNo}
placeholder="Batch no" aria-invalid={!!errors.batchNo}
className="h-11 text-sm" onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
/> placeholder="Batch no"
className="h-11 text-sm"
/>
<Input
type="date"
value={line.batchExpiry}
onChange={(e) => updateLine(line.key, { batchExpiry: e.target.value })}
aria-label="Batch expiry date"
title="Expiry date (optional)"
className="h-9 text-xs"
/>
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
</div>
) : ( ) : (
<span className="text-sm text-muted-foreground">—</span> <span className="text-sm text-muted-foreground">—</span>
)} )}
@@ -1237,7 +1358,7 @@ export default function NewGrnPage() {
</Select> </Select>
</TableCell> </TableCell>
<TableCell className="py-3 pr-0 pl-1 align-top"> <TableCell className="py-3 pr-0 pl-1 align-top">
{item?.warranty === "Warranty" ? ( {capturesUnitNumbers(item) ? (
<div className="relative flex flex-col gap-1"> <div className="relative flex flex-col gap-1">
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
@@ -1247,13 +1368,13 @@ export default function NewGrnPage() {
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => setLineTab("warranty")} onClick={() => setLineTab("warranty")}
aria-label="Add warranty numbers" aria-label={`Add ${unitNumberLabel(item)}s`}
/> />
} }
> >
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Add warranty numbers</TooltipContent> <TooltipContent>Add {unitNumberLabel(item)}s</TooltipContent>
</Tooltip> </Tooltip>
{errors.warrantyNumbers && ( {errors.warrantyNumbers && (
<FieldError errors={[{ message: errors.warrantyNumbers }]} /> <FieldError errors={[{ message: errors.warrantyNumbers }]} />
@@ -1311,8 +1432,8 @@ export default function NewGrnPage() {
{warrantyLines.length === 0 ? ( {warrantyLines.length === 0 ? (
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground"> <p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">
No warranty-tracked items on this GRN yet — add a line for an item marked No serial- or warranty-tracked items on this GRN yet — add a line for such an
“Warranty” to capture its numbers here. item to capture one number per unit here.
</p> </p>
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -1320,11 +1441,65 @@ export default function NewGrnPage() {
const errors = lineErrors[line.key] ?? {} const errors = lineErrors[line.key] ?? {}
const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" } const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" }
const addError = warrantyAddErrors[line.key] const addError = warrantyAddErrors[line.key]
const serial = item?.trackingMode === "Serial"
const label = unitNumberLabel(item)
const entered = line.warrantyNumbers.filter((s) => s.trim().length > 0).length
return ( return (
<div key={line.key} className="flex flex-col gap-2"> <div key={line.key} className="flex flex-col gap-2">
<div className="text-sm font-semibold text-foreground"> <div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-foreground">
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
<Badge variant="outline" className="text-xs">{serial ? "Serial" : "Warranty"}</Badge>
{serial && item?.warranty === "Warranty" && (
<Badge variant="outline" className="text-xs">Warranty starts at sale</Badge>
)}
<span className={cn("text-xs font-normal", entered === units ? "text-success" : "text-muted-foreground")}>
{entered} of {units} entered
</span>
</div> </div>
{serial && (
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
<div className="flex min-w-64 flex-1 flex-col gap-1">
<Label className="text-xs text-muted-foreground">Paste or scan serial numbers</Label>
<textarea
value={pasteInputs[line.key] ?? ""}
onChange={(e) => setPasteInputs((prev) => ({ ...prev, [line.key]: e.target.value }))}
placeholder="One per line (or comma-separated)"
rows={3}
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
/>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => applyPastedNumbers(line.key, units)}>
Fill units
</Button>
</div>
)}
{serial && line.itemId && (
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">Prefix</Label>
<Input
value={genPrefixes[line.key] ?? `${item?.sku ?? "SN"}-`}
onChange={(e) => setGenPrefixes((prev) => ({ ...prev, [line.key]: e.target.value }))}
placeholder="e.g. PH-"
className="h-9 w-44 text-sm"
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={generating === line.key}
onClick={() => autoGenerateSerials(line.key, line.itemId as number, units, genPrefixes[line.key] ?? `${item?.sku ?? "SN"}-`)}
>
<Wand2 className="size-4" />
{generating === line.key ? "Generating…" : "Auto-generate"}
</Button>
<span className="text-xs text-muted-foreground">
For units without a manufacturer serial — fills only the empty units with the next
free numbers (e.g. {`${genPrefixes[line.key] ?? `${item?.sku ?? "SN"}-`}0001`}).
</span>
</div>
)}
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3"> <div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">From</Label> <Label className="text-xs text-muted-foreground">From</Label>
@@ -1365,8 +1540,10 @@ export default function NewGrnPage() {
<TableRow> <TableRow>
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead> <TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
<TableHead className="h-10 px-3 text-sm"> <TableHead className="h-10 px-3 text-sm">
Warranty number {serial ? "Serial number" : "Warranty number"}
<span className="ml-1.5 font-normal text-muted-foreground">(auto-generated — edit if needed)</span> <span className="ml-1.5 font-normal text-muted-foreground">
{serial ? "(scan or type the unit's serial)" : "(auto-generated — edit if needed)"}
</span>
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -1381,7 +1558,8 @@ export default function NewGrnPage() {
value={line.warrantyNumbers[i] ?? ""} value={line.warrantyNumbers[i] ?? ""}
aria-invalid={!!errors.warrantyNumbers} aria-invalid={!!errors.warrantyNumbers}
onChange={(e) => setWarrantyNumberAt(line.key, i, e.target.value)} onChange={(e) => setWarrantyNumberAt(line.key, i, e.target.value)}
placeholder="e.g. WTY-000123" placeholder={serial ? "e.g. SN-48213" : "e.g. WTY-000123"}
aria-label={`${label} for unit ${i + 1}`}
className="h-10 max-w-xs text-sm" className="h-10 max-w-xs text-sm"
/> />
{i === units - 1 && ( {i === units - 1 && (
@@ -7,6 +7,8 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { SuggestInput } from "@/components/ui/suggest-input" import { SuggestInput } from "@/components/ui/suggest-input"
import { trackingTags } from "@/lib/tracking-tags"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -74,6 +76,7 @@ const blankLine = (key: string): Line => ({
export default function NewFreeIssuePage() { export default function NewFreeIssuePage() {
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([]) const [items, setItems] = useState<ItemListItem[]>([])
const { serialNumbersEnabled } = useTrackingSettings()
const [uoms, setUoms] = useState<Uom[]>([]) const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([]) const [users, setUsers] = useState<ManagedUser[]>([])
@@ -162,8 +165,8 @@ export default function NewFreeIssuePage() {
} }
const itemOptions = useMemo( const itemOptions = useMemo(
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })), () => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}`, tags: trackingTags(i.trackingMode, serialNumbersEnabled) })),
[items] [items, serialNumbersEnabled]
) )
function selectItem(key: string, itemId: number) { function selectItem(key: string, itemId: number) {
@@ -22,10 +22,14 @@ import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { CompanyProfile } from "@/types/company-profile" import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template" import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales" import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
import { SalesLineSerial } from "@/types/tracking"
import { LineTrackingPicker } from "@/components/sales/LineTrackingPicker"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SuggestInput } from "@/components/ui/suggest-input" import { SuggestInput } from "@/components/ui/suggest-input"
import { trackingTags } from "@/lib/tracking-tags"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint" import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges" import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
@@ -33,7 +37,8 @@ import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHe
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter" import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets" import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
type Line = CreateSalesInvoiceLineRequest & { key: string } // batchNo/serials are display state for the tracking picker; batchId + serial ids are what saves.
type Line = CreateSalesInvoiceLineRequest & { key: string; batchNo?: string | null; serials?: SalesLineSerial[] }
const money = new Intl.NumberFormat("en-LK", { const money = new Intl.NumberFormat("en-LK", {
style: "currency", style: "currency",
@@ -70,6 +75,8 @@ function draftSnapshot(customerId: number | null, warehouseId: number | null, in
qty: Number(line.qty), qty: Number(line.qty),
freeQty: Number(line.freeQty), freeQty: Number(line.freeQty),
unitPrice: line.unitPrice ?? null, unitPrice: line.unitPrice ?? null,
batchId: line.batchId ?? null,
serialIds: (line.serials ?? []).map((s) => s.serialId),
})), })),
}) })
} }
@@ -88,6 +95,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [printOpen, setPrintOpen] = useState(false) const [printOpen, setPrintOpen] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([]) const [items, setItems] = useState<ItemListItem[]>([])
const { serialNumbersEnabled } = useTrackingSettings()
const [uoms, setUoms] = useState<Uom[]>([]) const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [company, setCompany] = useState<CompanyProfile | null>(null) const [company, setCompany] = useState<CompanyProfile | null>(null)
@@ -165,6 +173,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
taxPct: line.taxPct, taxPct: line.taxPct,
isFreeIssue: line.isFreeIssue, isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId, parentLineId: line.parentLineId,
batchId: line.batchId ?? null,
batchNo: null,
serials: line.serials ?? [],
})) }))
setLines(loadedLines) setLines(loadedLines)
setSavedSnapshot(draftSnapshot(doc.data.customerId, doc.data.warehouseId, doc.data.invoiceType, loadedLines)) setSavedSnapshot(draftSnapshot(doc.data.customerId, doc.data.warehouseId, doc.data.invoiceType, loadedLines))
@@ -242,9 +253,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
() => () =>
items.map((i) => { items.map((i) => {
const price = getSuggestedUnitPrice(items, i.itemId) const price = getSuggestedUnitPrice(items, i.itemId)
return { value: String(i.itemId), label: `${i.sku} — ${i.name}`, hint: price !== null ? money.format(price) : undefined } return {
value: String(i.itemId),
label: `${i.sku} — ${i.name}`,
hint: price !== null ? money.format(price) : undefined,
tags: trackingTags(i.trackingMode, serialNumbersEnabled),
}
}), }),
[items], [items, serialNumbersEnabled],
) )
function itemLabel(itemId: number) { function itemLabel(itemId: number) {
@@ -309,6 +325,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
taxPct: Number(line.taxPct), taxPct: Number(line.taxPct),
isFreeIssue: allowFreeItems && line.isFreeIssue, isFreeIssue: allowFreeItems && line.isFreeIssue,
parentLineId: line.parentLineId || null, parentLineId: line.parentLineId || null,
batchId: line.batchId ?? null,
serialIds: line.serials && line.serials.length > 0 ? line.serials.map((s) => s.serialId) : null,
})), })),
}, },
etag, etag,
@@ -534,6 +552,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<tr key={line.key} className="border-t border-border align-middle"> <tr key={line.key} className="border-t border-border align-middle">
<td className="min-w-64 px-4 py-3"> <td className="min-w-64 px-4 py-3">
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span> <span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
<LineTrackingPicker
itemId={line.itemId}
warehouseId={line.warehouseId || warehouseId}
trackingMode={items.find((i) => i.itemId === line.itemId)?.trackingMode}
units={Number(line.qty || 0) + (allowFreeItems ? Number(line.freeQty || 0) : 0)}
value={{ batchId: line.batchId ?? null, batchNo: line.batchNo ?? null, serials: line.serials ?? [] }}
onChange={(v) => updateLine(line.key, { batchId: v.batchId, batchNo: v.batchNo, serials: v.serials })}
/>
</td> </td>
<td className="min-w-32 px-4 py-3 text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</td> <td className="min-w-32 px-4 py-3 text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</td>
<td className="w-28 px-4 py-3"> <td className="w-28 px-4 py-3">
@@ -611,6 +637,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium text-foreground">{line.description}</div> <div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div> <div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
{(line.serials ?? []).length > 0 && (
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
S/N {(line.serials ?? []).map((s) => s.serialNo).join(", ")}
</div>
)}
</td> </td>
<td className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</td> <td className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</td>
<td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td> <td className="px-4 py-3 text-right">{line.qty.toFixed(0)}</td>
@@ -13,6 +13,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SuggestBadgeTone, SuggestInput } from "@/components/ui/suggest-input" import { SuggestBadgeTone, SuggestInput } from "@/components/ui/suggest-input"
import { trackingTags } from "@/lib/tracking-tags"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -40,6 +41,9 @@ import {
SalesStockAvailability, SalesStockAvailability,
} from "@/types/sales" } from "@/types/sales"
import { BundleSaleTemplateSummary } from "@/types/bundles" import { BundleSaleTemplateSummary } from "@/types/bundles"
import { SalesLineSerial } from "@/types/tracking"
import { LineTrackingPicker } from "@/components/sales/LineTrackingPicker"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { toast } from "@/components/ui/toast" import { toast } from "@/components/ui/toast"
// bundleGroupId/bundleName/bundleQty/bundleBaseQty are frontend-only display metadata (never sent // bundleGroupId/bundleName/bundleQty/bundleBaseQty are frontend-only display metadata (never sent
@@ -47,11 +51,10 @@ import { toast } from "@/components/ui/toast"
// component lines added from the same bundle render collapsed into one row, with a single "how // component lines added from the same bundle render collapsed into one row, with a single "how
// many bundles" quantity that scales every component's qty (bundleBaseQty holds the per-bundle // many bundles" quantity that scales every component's qty (bundleBaseQty holds the per-bundle
// qty from the template so re-scaling on a qty change is exact, not compounding). // qty from the template so re-scaling on a qty change is exact, not compounding).
// batchId/serialId/batchNo/serialNo are frontend-only display metadata from a barcode/RFID scan // batchId/serials are sent (docs/ITEM_TRACKING_PLAN.md Phase 4): a batch item may pin a batch (else
// (see ScanInput below) — the live CreateSalesInvoiceLineRequest DTO has no batch/serial fields // posting picks FEFO), a serial item must carry one serial per unit of qty + free qty. They are
// yet (only CreateSalesSlipLineRequest does), so these are carried for display/traceability on // filled by a barcode/RFID scan (ScanInput below) or the per-line LineTrackingPicker.
// this draft line but deliberately NOT sent in submit()'s payload until the invoice line request // batchNo/serialId/serialNo are display metadata from the scan.
// grows the same fields.
type Line = CreateSalesInvoiceLineRequest & { type Line = CreateSalesInvoiceLineRequest & {
key: string key: string
bundleGroupId?: string bundleGroupId?: string
@@ -62,6 +65,7 @@ type Line = CreateSalesInvoiceLineRequest & {
batchNo?: string | null batchNo?: string | null
serialId?: number | null serialId?: number | null
serialNo?: string | null serialNo?: string | null
serials?: SalesLineSerial[]
} }
type DisplayRow = type DisplayRow =
@@ -103,6 +107,7 @@ const blankLine = (key: string): Line => ({
batchNo: null, batchNo: null,
serialId: null, serialId: null,
serialNo: null, serialNo: null,
serials: [],
}) })
const lkr = new Intl.NumberFormat("en-LK", { const lkr = new Intl.NumberFormat("en-LK", {
@@ -120,6 +125,7 @@ function NewSalesInvoiceContent() {
const appliedBundleRef = useRef<string | null>(null) const appliedBundleRef = useRef<string | null>(null)
const bundleGroupSeqRef = useRef(0) const bundleGroupSeqRef = useRef(0)
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth() const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const { serialNumbersEnabled } = useTrackingSettings()
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([]) const [items, setItems] = useState<ItemListItem[]>([])
@@ -295,8 +301,7 @@ function NewSalesInvoiceContent() {
// Fast path for a barcode/RFID scan above the lines table: resolves the code to an item (and // Fast path for a barcode/RFID scan above the lines table: resolves the code to an item (and
// optionally its batch/serial), then either bumps an existing line for the same item+lot by one // optionally its batch/serial), then either bumps an existing line for the same item+lot by one
// unit, or appends a new line for it — batchId/serialId ride along on the line for traceability // unit, or appends a new line for it — a scanned serial becomes that line's chosen serial.
// (see the Line type note above re: submit() not yet sending them).
// Shared by the "Add item" suggestion box and barcode scans: bumps an existing line for the // Shared by the "Add item" suggestion box and barcode scans: bumps an existing line for the
// same item+lot by one unit, or appends a new line for it. Returns the affected line's key. // same item+lot by one unit, or appends a new line for it. Returns the affected line's key.
function addItem( function addItem(
@@ -331,6 +336,7 @@ function NewSalesInvoiceContent() {
batchNo: lot.batchNo ?? null, batchNo: lot.batchNo ?? null,
serialId: lot.serialId ?? null, serialId: lot.serialId ?? null,
serialNo: lot.serialNo ?? null, serialNo: lot.serialNo ?? null,
serials: lot.serialId ? [{ serialId: lot.serialId, serialNo: lot.serialNo ?? String(lot.serialId) }] : [],
}, },
] ]
}) })
@@ -517,9 +523,10 @@ function NewSalesInvoiceContent() {
const stocked = i.stockNature === "Stocked" const stocked = i.stockNature === "Stocked"
const qty = stockByItem ? (stockByItem.get(i.itemId) ?? 0) : null const qty = stockByItem ? (stockByItem.get(i.itemId) ?? 0) : null
const uom = uoms.find((u) => u.uomId === i.baseUomId)?.name const uom = uoms.find((u) => u.uomId === i.baseUomId)?.name
// Tracking shows as a highlighted tag (Serial / Batch) instead of detail text.
const tags = trackingTags(i.trackingMode, serialNumbersEnabled)
const detail = [ const detail = [
uom, uom,
i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null,
i.warranty === "Warranty" && i.warrantyPeriodMonths ? `${i.warrantyPeriodMonths}m warranty` : null, i.warranty === "Warranty" && i.warrantyPeriodMonths ? `${i.warrantyPeriodMonths}m warranty` : null,
i.taxRateName ? `Tax: ${i.taxRateName}` : null, i.taxRateName ? `Tax: ${i.taxRateName}` : null,
] ]
@@ -530,11 +537,12 @@ function NewSalesInvoiceContent() {
label: `${i.sku} — ${i.name}`, label: `${i.sku} — ${i.name}`,
hint: price !== null ? lkr.format(price) : undefined, hint: price !== null ? lkr.format(price) : undefined,
detail: detail || undefined, detail: detail || undefined,
tags,
badge: !stocked ? "Non-stock" : qty === null ? "Stock: —" : `Stock: ${qtyFormat.format(qty)}`, badge: !stocked ? "Non-stock" : qty === null ? "Stock: —" : `Stock: ${qtyFormat.format(qty)}`,
badgeTone: (!stocked || qty === null ? "muted" : qty > 0 ? "success" : "danger") as SuggestBadgeTone, badgeTone: (!stocked || qty === null ? "muted" : qty > 0 ? "success" : "danger") as SuggestBadgeTone,
} }
}), }),
[items, uoms, stockByItem], [items, uoms, stockByItem, serialNumbersEnabled],
) )
const bundleTemplateOptions = useMemo( const bundleTemplateOptions = useMemo(
@@ -617,6 +625,29 @@ function NewSalesInvoiceContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, bundleTemplateIdFromQuery]) }, [loading, bundleTemplateIdFromQuery])
/** Units a serial line still needs serials for (0 for any other item). */
function serialsShort(line: Line): number {
const item = items.find((i) => i.itemId === line.itemId)
if (item?.trackingMode !== "Serial" || serialNumbersEnabled === false) return 0
const units = Number(line.qty || 0) + (allowFreeItems ? Number(line.freeQty || 0) : 0)
return Math.max(0, units - (line.serials?.length ?? 0))
}
/** Batch / serial picker under a line's item name (nothing for untracked items). */
function renderTracking(line: Line) {
const item = items.find((i) => i.itemId === line.itemId)
return (
<LineTrackingPicker
itemId={line.itemId}
warehouseId={line.warehouseId || warehouseId}
trackingMode={item?.trackingMode}
units={Number(line.qty || 0) + (allowFreeItems ? Number(line.freeQty || 0) : 0)}
value={{ batchId: line.batchId ?? null, batchNo: line.batchNo ?? null, serials: line.serials ?? [] }}
onChange={(v) => updateLine(line.key, { batchId: v.batchId, batchNo: v.batchNo, serials: v.serials })}
/>
)
}
function itemLabel(itemId: number) { function itemLabel(itemId: number) {
const item = items.find((i) => i.itemId === itemId) const item = items.find((i) => i.itemId === itemId)
return item ? `${item.sku} — ${item.name}` : `Item #${itemId}` return item ? `${item.sku} — ${item.name}` : `Item #${itemId}`
@@ -693,6 +724,11 @@ function NewSalesInvoiceContent() {
const detail = [...new Set(short.map((l) => `${itemLabel(l.itemId)} (${stockShortage(l)} available)`))].join(", ") const detail = [...new Set(short.map((l) => `${itemLabel(l.itemId)} (${stockShortage(l)} available)`))].join(", ")
return setSubmitError(`Quantity exceeds available stock: ${detail}. Reduce the quantity to continue.`) return setSubmitError(`Quantity exceeds available stock: ${detail}. Reduce the quantity to continue.`)
} }
const missingSerials = lines.filter((line) => serialsShort(line) > 0)
if (missingSerials.length > 0) {
const detail = missingSerials.map((l) => `${itemLabel(l.itemId)} (${serialsShort(l)} more)`).join(", ")
return setSubmitError(`Select a serial number for every unit: ${detail}.`)
}
const payload: CreateSalesInvoiceRequest = { const payload: CreateSalesInvoiceRequest = {
customerId, customerId,
@@ -713,6 +749,8 @@ function NewSalesInvoiceContent() {
taxPct: Number(line.taxPct), taxPct: Number(line.taxPct),
isFreeIssue: allowFreeItems && line.isFreeIssue, isFreeIssue: allowFreeItems && line.isFreeIssue,
parentLineId: line.parentLineId || null, parentLineId: line.parentLineId || null,
batchId: line.batchId ?? null,
serialIds: line.serials && line.serials.length > 0 ? line.serials.map((s) => s.serialId) : null,
})), })),
} }
@@ -954,9 +992,7 @@ function NewSalesInvoiceContent() {
<div className="flex-1"> <div className="flex-1">
<Label className="text-sm">Item {idx + 1}</Label> <Label className="text-sm">Item {idx + 1}</Label>
<p className="py-1 text-sm font-medium text-foreground">{itemLabel(line.itemId)}</p> <p className="py-1 text-sm font-medium text-foreground">{itemLabel(line.itemId)}</p>
{(line.batchNo || line.serialNo) && ( {renderTracking(line)}
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
)}
</div> </div>
<Button <Button
type="button" type="button"
@@ -1150,11 +1186,7 @@ function NewSalesInvoiceContent() {
<TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell> <TableCell className="px-3 py-3 align-top text-sm">{idx + 1}</TableCell>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<div className="flex min-h-11 items-center text-sm font-medium text-foreground">{itemLabel(line.itemId)}</div> <div className="flex min-h-11 items-center text-sm font-medium text-foreground">{itemLabel(line.itemId)}</div>
{(line.batchNo || line.serialNo) && ( {renderTracking(line)}
<p className="mt-1 text-xs text-muted-foreground">
Lot: {line.batchNo ?? line.serialNo}
</p>
)}
</TableCell> </TableCell>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-sm text-muted-foreground"> <div className="flex h-11 items-center text-sm text-muted-foreground">
@@ -0,0 +1,147 @@
"use client"
import { useEffect, useState } from "react"
import { ShieldCheck } from "lucide-react"
import { trackingApi } from "@/lib/api/tracking"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PagedResponse } from "@/types/common"
import { WarrantyRegistration } from "@/types/tracking"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type View = "active" | "expiring" | "expired" | "voided" | "all"
const VIEWS: { value: View; label: string }[] = [
{ value: "active", label: "Active" },
{ value: "expiring", label: "Expiring within 30 days" },
{ value: "expired", label: "Expired" },
{ value: "voided", label: "Voided (returned)" },
{ value: "all", label: "All" },
]
const DOC_LABEL: Record<string, string> = { SI: "Invoice", SSL: "Slip", BND: "Bundle" }
/**
* Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). A warranty starts when the
* sale is posted — never at goods receipt — and is voided if the unit comes back on a return.
*/
export default function WarrantiesPage() {
const [view, setView] = useState<View>("active")
const [q, setQ] = useState("")
const [page, setPage] = useState(1)
const [data, setData] = useState<PagedResponse<WarrantyRegistration> | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const filters =
view === "active" ? { status: "Active" as const, expired: false }
: view === "expiring" ? { expiringWithinDays: 30 }
: view === "expired" ? { status: "Active" as const, expired: true }
: view === "voided" ? { status: "Voided" as const }
: {}
const timer = setTimeout(() => {
trackingApi
.listWarranties({ ...filters, q: q.trim() || undefined, page, pageSize: 25 })
.then((r) => { if (!cancelled) { setData(r); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
}, 250)
return () => { cancelled = true; clearTimeout(timer) }
}, [view, q, page])
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Warranties</h1>
<p className="text-base text-muted-foreground">
Customer warranties start on the day the sale is posted. Scan a serial to check its cover.
</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<Input
value={q}
onChange={(e) => { setPage(1); setQ(e.target.value) }}
placeholder="Scan serial, or search SKU / item…"
className="h-11 min-w-64 flex-1 text-base"
/>
<Select<View> value={view} onValueChange={(v) => { setPage(1); if (v) setView(v) }}>
<SelectTrigger className="h-11! w-60 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VIEWS.map((v) => (
<SelectItem key={v.value} value={v.value} className="text-sm">{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!data && !error && <Skeleton className="h-40 w-full" />}
{data && data.items.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<ShieldCheck className="size-10 text-muted-foreground" />
<p className="text-base text-muted-foreground">No warranties in this view.</p>
</div>
)}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="px-3">Item / serial</TableHead>
<TableHead className="px-3">Customer</TableHead>
<TableHead className="px-3">Sold on</TableHead>
<TableHead className="px-3">Cover</TableHead>
<TableHead className="px-3">Ends</TableHead>
<TableHead className="px-3">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((w) => (
<TableRow key={w.warrantyRegistrationId}>
<TableCell className="px-3 py-3">
<div className="font-medium">{w.sku} — {w.itemName}</div>
<div className="font-mono text-xs text-muted-foreground">{w.serialNo ?? `${w.qty} unit${w.qty === 1 ? "" : "s"} (no serial)`}</div>
</TableCell>
<TableCell className="px-3 py-3">{w.customerName}</TableCell>
<TableCell className="px-3 py-3">
{w.startDate}
<div className="text-xs text-muted-foreground">{DOC_LABEL[w.sourceDocType] ?? w.sourceDocType} #{w.sourceDocId}</div>
</TableCell>
<TableCell className="px-3 py-3">{w.periodMonths} months</TableCell>
<TableCell className="px-3 py-3">{w.endDate}</TableCell>
<TableCell className="px-3 py-3">
<Badge
variant="outline"
className={cn(
"text-xs",
w.status !== "Active" ? "text-muted-foreground"
: w.isExpired ? "border-destructive/40 text-destructive"
: w.daysRemaining <= 30 ? "border-warning/50 text-warning"
: "border-success/40 text-success"
)}
>
{w.status !== "Active" ? w.status : w.isExpired ? "Expired" : `${w.daysRemaining} days left`}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<PaginationFooter pagination={data.pagination} onPageChange={setPage} />
</div>
)}
</div>
)
}
@@ -14,6 +14,9 @@ import { cn } from "@/lib/utils"
import { CreateAdjustmentLineInput, ReasonCode, StockAdjustment } from "@/types/stock" import { CreateAdjustmentLineInput, ReasonCode, StockAdjustment } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data" import { ItemListItem, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { SalesLineSerial } from "@/types/tracking"
import { LineTrackingPicker } from "@/components/sales/LineTrackingPicker"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
@@ -29,6 +32,14 @@ interface DraftLine {
key: string key: string
itemId: number | null itemId: number | null
qtyDelta: string qtyDelta: string
/** Serial item, negative delta: the units written off. */
serials: SalesLineSerial[]
/** Serial item, positive delta: the serial numbers found, one per line/comma. */
serialNos: string
}
function parseSerialNos(text: string): string[] {
return text.split(/[\r\n,;\t]+/).map((s) => s.trim()).filter((s) => s.length > 0)
} }
let keySeq = 0 let keySeq = 0
@@ -38,11 +49,12 @@ function newKey() {
} }
function emptyLine(): DraftLine { function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, qtyDelta: "" } return { key: newKey(), itemId: null, qtyDelta: "", serials: [], serialNos: "" }
} }
export default function NewAdjustmentPage() { export default function NewAdjustmentPage() {
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth() const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const { serialNumbersEnabled } = useTrackingSettings()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -90,6 +102,41 @@ export default function NewAdjustmentPage() {
setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev)) setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev))
} }
// Serial numbers switched off in Product Configuration: serial items adjust like plain stock.
const isSerialItem = (itemId: number | null) =>
serialNumbersEnabled !== false && items?.find((i) => i.itemId === itemId)?.trackingMode === "Serial"
/**
* Serial items only: a write-off (negative delta) picks the serials going out; a gain
* (positive delta) takes the serial numbers found, one per line or comma-separated.
*/
function renderSerials(line: DraftLine) {
if (!line.itemId || !isSerialItem(line.itemId)) return null
const delta = Number(line.qtyDelta) || 0
if (delta < 0)
return (
<LineTrackingPicker
itemId={line.itemId}
warehouseId={warehouseId}
trackingMode="Serial"
units={-delta}
value={{ batchId: null, batchNo: null, serials: line.serials }}
onChange={(v) => updateLine(line.key, { serials: v.serials })}
/>
)
if (delta > 0)
return (
<textarea
value={line.serialNos}
onChange={(e) => updateLine(line.key, { serialNos: e.target.value })}
placeholder={`${delta} serial number(s) found — one per line`}
rows={2}
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
/>
)
return null
}
async function handleSubmit() { async function handleSubmit() {
setSubmitError(null) setSubmitError(null)
@@ -110,6 +157,13 @@ export default function NewAdjustmentPage() {
for (const line of lines) { for (const line of lines) {
if (!line.itemId) nextLineErrors[line.key] = "Select an item" if (!line.itemId) nextLineErrors[line.key] = "Select an item"
else if (!line.qtyDelta || Number(line.qtyDelta) === 0) nextLineErrors[line.key] = "Enter a non-zero quantity change" else if (!line.qtyDelta || Number(line.qtyDelta) === 0) nextLineErrors[line.key] = "Enter a non-zero quantity change"
else if (isSerialItem(line.itemId)) {
const delta = Number(line.qtyDelta)
const count = delta < 0 ? line.serials.length : parseSerialNos(line.serialNos).length
if (!Number.isInteger(delta)) nextLineErrors[line.key] = "Serial items change in whole units"
else if (count !== Math.abs(delta))
nextLineErrors[line.key] = `${delta < 0 ? "Select" : "Enter"} ${Math.abs(delta)} serial number(s) — ${count} given`
}
} }
setLineErrors(nextLineErrors) setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) { if (Object.keys(nextLineErrors).length > 0) {
@@ -120,6 +174,8 @@ export default function NewAdjustmentPage() {
const payloadLines: CreateAdjustmentLineInput[] = lines.map((l) => ({ const payloadLines: CreateAdjustmentLineInput[] = lines.map((l) => ({
itemId: l.itemId as number, itemId: l.itemId as number,
qtyDelta: Number(l.qtyDelta), qtyDelta: Number(l.qtyDelta),
serialIds: isSerialItem(l.itemId) && Number(l.qtyDelta) < 0 ? l.serials.map((s) => s.serialId) : null,
serialNos: isSerialItem(l.itemId) && Number(l.qtyDelta) > 0 ? parseSerialNos(l.serialNos) : null,
})) }))
setSubmitting(true) setSubmitting(true)
@@ -290,6 +346,7 @@ export default function NewAdjustmentPage() {
className="h-11 text-base" className="h-11 text-base"
/> />
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} /> <FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
{renderSerials(line)}
</div> </div>
</div> </div>
))} ))}
@@ -333,6 +390,7 @@ export default function NewAdjustmentPage() {
className="h-11 text-base" className="h-11 text-base"
/> />
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} /> <FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
{renderSerials(line)}
</TableCell> </TableCell>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line"> <Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
@@ -0,0 +1,147 @@
"use client"
import { useEffect, useState } from "react"
import { trackingApi } from "@/lib/api/tracking"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PagedResponse } from "@/types/common"
import { Warehouse } from "@/types/master-data"
import { BatchStock } from "@/types/tracking"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const WINDOWS: { label: string; days: number | null }[] = [
{ label: "All batches", days: null },
{ label: "Expired or within 7 days", days: 7 },
{ label: "Expired or within 30 days", days: 30 },
{ label: "Expired or within 90 days", days: 90 },
]
/** Batch stock with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9) — soonest expiry first. */
export default function BatchExpiryPage() {
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [windowDays, setWindowDays] = useState<number | null>(30)
const [q, setQ] = useState("")
const [page, setPage] = useState(1)
const [data, setData] = useState<PagedResponse<BatchStock> | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
warehousesApi.list().then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
useEffect(() => {
let cancelled = false
const timer = setTimeout(() => {
trackingApi
.listBatchStock({ q: q.trim() || undefined, warehouseId: warehouseId ?? undefined, expiringWithinDays: windowDays ?? undefined, page, pageSize: 25 })
.then((r) => { if (!cancelled) { setData(r); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
}, 250)
return () => { cancelled = true; clearTimeout(timer) }
}, [q, warehouseId, windowDays, page])
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Batch Expiry</h1>
<p className="text-base text-muted-foreground">
Stock on hand per batch. Expired batches can no longer be issued; sales pick the soonest-expiring batch first.
</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<Input
value={q}
onChange={(e) => { setPage(1); setQ(e.target.value) }}
placeholder="Search batch, SKU or item…"
className="h-11 min-w-64 flex-1 text-base"
/>
<Select<number | null> value={windowDays} onValueChange={(v) => { setPage(1); setWindowDays(v) }}>
<SelectTrigger className="h-11! w-60 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOWS.map((w) => (
<SelectItem key={w.label} value={w.days} className="text-sm">{w.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<number | null> value={warehouseId} onValueChange={(v) => { setPage(1); setWarehouseId(v) }}>
<SelectTrigger className="h-11! w-48 text-sm">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-sm">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-sm">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!data && !error && <Skeleton className="h-40 w-full" />}
{data && data.items.length === 0 && (
<div className="rounded-xl border border-dashed py-16 text-center text-base text-muted-foreground">No batch stock in this window.</div>
)}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="px-3">Item</TableHead>
<TableHead className="px-3">Batch</TableHead>
<TableHead className="px-3">Warehouse</TableHead>
<TableHead className="px-3">Expiry</TableHead>
<TableHead className="px-3 text-right">Qty on hand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((b) => (
<TableRow key={`${b.itemId}-${b.warehouseId}-${b.batchId}`}>
<TableCell className="px-3 py-3">
<div className="font-medium">{b.sku}</div>
<div className="text-xs text-muted-foreground">{b.itemName}</div>
</TableCell>
<TableCell className="px-3 py-3 font-mono">{b.batchNo}</TableCell>
<TableCell className="px-3 py-3">{b.warehouseName}</TableCell>
<TableCell className="px-3 py-3">
{b.expiryDate ? (
<span className="flex items-center gap-2">
{b.expiryDate}
<Badge
variant="outline"
className={cn(
"text-xs",
b.isExpired ? "border-destructive/40 text-destructive" : (b.daysToExpiry ?? 999) <= 30 ? "border-warning/50 text-warning" : "text-muted-foreground"
)}
>
{b.isExpired ? "Expired" : `${b.daysToExpiry} days`}
</Badge>
</span>
) : (
<span className="text-muted-foreground">No expiry</span>
)}
</TableCell>
<TableCell className="px-3 py-3 text-right tabular-nums">{b.qty}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<PaginationFooter pagination={data.pagination} onPageChange={setPage} />
</div>
)}
</div>
)
}
@@ -0,0 +1,244 @@
"use client"
import { useEffect, useState } from "react"
import { ScanLine, X } from "lucide-react"
import { trackingApi } from "@/lib/api/tracking"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { cn } from "@/lib/utils"
import { PagedResponse } from "@/types/common"
import { Warehouse } from "@/types/master-data"
import { SerialDetail, SerialStatus, SerialSummary } from "@/types/tracking"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const STATUSES: SerialStatus[] = ["InStock", "InTransit", "Sold", "ReturnedByCustomer", "ReturnedToVendor", "Damaged", "Scrapped", "IssuedUntracked"]
const STATUS_TONE: Partial<Record<SerialStatus, string>> = {
InStock: "border-success/40 text-success",
InTransit: "border-info/40 text-info",
Sold: "border-muted-foreground/30 text-muted-foreground",
Damaged: "border-warning/50 text-warning",
Scrapped: "border-destructive/40 text-destructive",
ReturnedToVendor: "border-muted-foreground/30 text-muted-foreground",
}
/** Friendly names for the document-type prefixes a serial movement references. */
const DOC_LABEL: Record<string, string> = {
GRN: "Goods receipt",
SI: "Sales invoice",
SSL: "Sales slip",
BND: "Bundle sale",
SRET: "Sales return",
PRET: "Purchase return",
TRF: "Transfer",
ADJ: "Adjustment",
CNT: "Stock count",
}
function SerialStatusBadge({ status }: { status: SerialStatus }) {
return (
<Badge variant="outline" className={cn("text-xs", STATUS_TONE[status])}>
{status.replace(/([a-z])([A-Z])/g, "$1 $2")}
</Badge>
)
}
/** Serial register (docs/ITEM_TRACKING_PLAN.md Phase 9): every serial, its status and location, and its history. */
export default function SerialsPage() {
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [q, setQ] = useState("")
const [search, setSearch] = useState("")
const [status, setStatus] = useState<SerialStatus | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [page, setPage] = useState(1)
const [data, setData] = useState<PagedResponse<SerialSummary> | null>(null)
const [error, setError] = useState<string | null>(null)
const [detail, setDetail] = useState<SerialDetail | null>(null)
const { serialNumbersEnabled } = useTrackingSettings()
useEffect(() => {
warehousesApi.list().then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
useEffect(() => {
let cancelled = false
trackingApi
.listSerials({ q: search || undefined, status: status ?? undefined, warehouseId: warehouseId ?? undefined, page, pageSize: 25 })
.then((r) => { if (!cancelled) { setData(r); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
return () => { cancelled = true }
}, [search, status, warehouseId, page])
async function openDetail(serialId: number) {
try {
setDetail(await trackingApi.getSerial(serialId))
} catch (err) {
setError(errorMessage(err))
}
}
const warehouseName = (id: number | null) => (id ? warehouses.find((w) => w.warehouseId === id)?.name ?? `#${id}` : "—")
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Serials</h1>
<p className="text-base text-muted-foreground">Every serial-tracked unit — where it is, what happened to it, and its warranty.</p>
</div>
{serialNumbersEnabled === false && (
<div className="rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
Serial numbers are switched off in Products → Configuration. This is past history only — no new
serials are recorded, and units issued meanwhile show as “Issued Untracked”.
</div>
)}
<div className="flex flex-wrap items-end gap-3">
<form
className="flex min-w-64 flex-1 gap-2"
onSubmit={(e) => {
e.preventDefault()
setPage(1)
setSearch(q.trim())
}}
>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Scan or search serial, SKU or item…" className="h-11 text-base" />
<Button type="submit" className="h-11">
<ScanLine className="size-4" />
Search
</Button>
</form>
<Select<SerialStatus | null> value={status} onValueChange={(v) => { setPage(1); setStatus(v) }}>
<SelectTrigger className="h-11! w-48 text-sm">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-sm">All statuses</SelectItem>
{STATUSES.map((s) => (
<SelectItem key={s} value={s} className="text-sm">{s.replace(/([a-z])([A-Z])/g, "$1 $2")}</SelectItem>
))}
</SelectContent>
</Select>
<Select<number | null> value={warehouseId} onValueChange={(v) => { setPage(1); setWarehouseId(v) }}>
<SelectTrigger className="h-11! w-48 text-sm">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-sm">All warehouses</SelectItem>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-sm">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
{!data && !error && <Skeleton className="h-40 w-full" />}
{data && data.items.length === 0 && (
<div className="rounded-xl border border-dashed py-16 text-center text-base text-muted-foreground">
No serials match. Serials are created when a GRN for a serial-tracked item is confirmed.
</div>
)}
{data && data.items.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="px-3">Serial</TableHead>
<TableHead className="px-3">Item</TableHead>
<TableHead className="px-3">Status</TableHead>
<TableHead className="px-3">Warehouse</TableHead>
<TableHead className="px-3">Received</TableHead>
<TableHead className="px-3">GRN</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((s) => (
<TableRow key={s.serialId} className="cursor-pointer" onClick={() => openDetail(s.serialId)}>
<TableCell className="px-3 py-3 font-mono">{s.serialNo}</TableCell>
<TableCell className="px-3 py-3">
<div className="font-medium">{s.sku}</div>
<div className="text-xs text-muted-foreground">{s.itemName}</div>
</TableCell>
<TableCell className="px-3 py-3"><SerialStatusBadge status={s.status} /></TableCell>
<TableCell className="px-3 py-3">{s.warehouseName ?? "—"}</TableCell>
<TableCell className="px-3 py-3">{s.receivedAt ? new Date(s.receivedAt).toLocaleDateString() : "—"}</TableCell>
<TableCell className="px-3 py-3">{s.grnDocNo ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<PaginationFooter pagination={data.pagination} onPageChange={setPage} />
</div>
)}
{detail && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setDetail(null)}>
<div className="max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-background p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-mono text-xl font-bold">{detail.serial.serialNo}</h2>
<p className="text-sm text-muted-foreground">{detail.serial.sku} — {detail.serial.itemName}</p>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => setDetail(null)} aria-label="Close">
<X className="size-5" />
</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div><p className="text-muted-foreground">Status</p><SerialStatusBadge status={detail.serial.status} /></div>
<div><p className="text-muted-foreground">Warehouse</p><p>{detail.serial.warehouseName ?? "—"}</p></div>
<div><p className="text-muted-foreground">GRN</p><p>{detail.serial.grnDocNo ?? "—"}</p></div>
<div><p className="text-muted-foreground">Warranty</p><p>{detail.serial.warrantyPeriodMonths ? `${detail.serial.warrantyPeriodMonths} months` : "None"}</p></div>
</div>
{detail.warranties.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold">Warranty</h3>
{detail.warranties.map((w) => (
<div key={w.warrantyRegistrationId} className="rounded-lg border p-3 text-sm">
<p>
{w.customerName} · {w.startDate} → {w.endDate}{" "}
<Badge variant="outline" className={cn("ml-1 text-xs", w.status === "Active" && !w.isExpired ? "text-success" : "text-muted-foreground")}>
{w.status === "Active" ? (w.isExpired ? "Expired" : `${w.daysRemaining} days left`) : w.status}
</Badge>
</p>
</div>
))}
</div>
)}
<div className="mt-5">
<h3 className="mb-2 text-sm font-semibold">History</h3>
{detail.history.length === 0 ? (
<p className="text-sm text-muted-foreground">No recorded movements.</p>
) : (
<ol className="flex flex-col gap-2 border-l pl-4">
{detail.history.map((m) => (
<li key={m.serialMovementId} className="text-sm">
<span className="text-muted-foreground">{new Date(m.createdAt).toLocaleString()}</span>{" — "}
{DOC_LABEL[m.sourceDocType] ?? m.sourceDocType} #{m.sourceDocId}{" → "}
<SerialStatusBadge status={m.status} />
{m.warehouseId && <span className="text-muted-foreground"> at {warehouseName(m.warehouseId)}</span>}
</li>
))}
</ol>
)}
</div>
</div>
</div>
)}
</div>
)
}
@@ -14,6 +14,9 @@ import { cn } from "@/lib/utils"
import { CreateTransferLineInput } from "@/types/stock" import { CreateTransferLineInput } from "@/types/stock"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data" import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm" import { Branch } from "@/types/hrm"
import { SalesLineSerial } from "@/types/tracking"
import { LineTrackingPicker } from "@/components/sales/LineTrackingPicker"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -29,6 +32,11 @@ interface DraftLine {
itemId: number | null itemId: number | null
destBinId: number | null destBinId: number | null
qty: string qty: string
/** Pinned batch (null = server splits FEFO for a batch item). */
batchId: number | null
batchNo: string | null
/** Serial item: the units being moved. */
serials: SalesLineSerial[]
} }
let keySeq = 0 let keySeq = 0
@@ -38,11 +46,12 @@ function newKey() {
} }
function emptyLine(): DraftLine { function emptyLine(): DraftLine {
return { key: newKey(), itemId: null, destBinId: null, qty: "" } return { key: newKey(), itemId: null, destBinId: null, qty: "", batchId: null, batchNo: null, serials: [] }
} }
export default function NewTransferPage() { export default function NewTransferPage() {
const router = useRouter() const router = useRouter()
const { serialNumbersEnabled } = useTrackingSettings()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null) const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null) const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -114,6 +123,26 @@ export default function NewTransferPage() {
setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev)) setLines((prev) => (prev.length > 1 ? prev.filter((l) => l.key !== key) : prev))
} }
/** Changing a line's item clears its batch/serial picks — they belonged to the old item. */
function setLineItem(key: string, itemId: number | null) {
updateLine(key, { itemId, batchId: null, batchNo: null, serials: [] })
}
/** Serial / batch picker for a line, drawing on the source warehouse's stock. */
function renderTracking(line: DraftLine) {
if (!line.itemId) return null
return (
<LineTrackingPicker
itemId={line.itemId}
warehouseId={srcWarehouseId}
trackingMode={items?.find((i) => i.itemId === line.itemId)?.trackingMode}
units={Number(line.qty) || 0}
value={{ batchId: line.batchId, batchNo: line.batchNo, serials: line.serials }}
onChange={(v) => updateLine(line.key, { batchId: v.batchId, batchNo: v.batchNo, serials: v.serials })}
/>
)
}
async function handleSubmit() { async function handleSubmit() {
setSubmitError(null) setSubmitError(null)
@@ -132,8 +161,11 @@ export default function NewTransferPage() {
const nextLineErrors: Record<string, string> = {} const nextLineErrors: Record<string, string> = {}
for (const line of lines) { for (const line of lines) {
const serialItem = serialNumbersEnabled !== false && items?.find((i) => i.itemId === line.itemId)?.trackingMode === "Serial"
if (!line.itemId) nextLineErrors[line.key] = "Select an item" if (!line.itemId) nextLineErrors[line.key] = "Select an item"
else if (!line.qty || Number(line.qty) <= 0) nextLineErrors[line.key] = "Quantity must be greater than 0" else if (!line.qty || Number(line.qty) <= 0) nextLineErrors[line.key] = "Quantity must be greater than 0"
else if (serialItem && line.serials.length !== Number(line.qty))
nextLineErrors[line.key] = `Select ${Number(line.qty)} serial number(s) — ${line.serials.length} selected`
} }
setLineErrors(nextLineErrors) setLineErrors(nextLineErrors)
if (Object.keys(nextLineErrors).length > 0) { if (Object.keys(nextLineErrors).length > 0) {
@@ -146,6 +178,8 @@ export default function NewTransferPage() {
srcBinId, srcBinId,
destBinId: l.destBinId, destBinId: l.destBinId,
qty: Number(l.qty), qty: Number(l.qty),
batchId: l.batchId,
serialIds: l.serials.length > 0 ? l.serials.map((s) => s.serialId) : null,
})) }))
setSubmitting(true) setSubmitting(true)
@@ -269,7 +303,7 @@ export default function NewTransferPage() {
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="flex-1"> <div className="flex-1">
<Label className="text-sm">Item</Label> <Label className="text-sm">Item</Label>
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Select<number | null> value={line.itemId} onValueChange={(v) => setLineItem(line.key, v)}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select item" /> <SelectValue placeholder="Select item" />
</SelectTrigger> </SelectTrigger>
@@ -281,6 +315,7 @@ export default function NewTransferPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{renderTracking(line)}
</div> </div>
<Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line"> <Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line">
<Trash2 className="size-5" /> <Trash2 className="size-5" />
@@ -337,7 +372,7 @@ export default function NewTransferPage() {
{lines.map((line) => ( {lines.map((line) => (
<TableRow key={line.key}> <TableRow key={line.key}>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}> <Select<number | null> value={line.itemId} onValueChange={(v) => setLineItem(line.key, v)}>
<SelectTrigger className="h-11! w-full text-base"> <SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Select item" /> <SelectValue placeholder="Select item" />
</SelectTrigger> </SelectTrigger>
@@ -349,6 +384,7 @@ export default function NewTransferPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{renderTracking(line)}
</TableCell> </TableCell>
<TableCell className="px-3 py-3 align-top"> <TableCell className="px-3 py-3 align-top">
<Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}> <Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}>
@@ -44,6 +44,7 @@ import {
Receipt, Receipt,
ReceiptText, ReceiptText,
Scale, Scale,
ScanLine,
ScrollText, ScrollText,
Settings, Settings,
ShieldCheck, ShieldCheck,
@@ -60,6 +61,9 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
const SERIALS_HREF = "/dashboard/stock/serials"
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore // `code` must match the seeded NavItem/SubNavItem codes in ERPCore
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs) // (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
@@ -148,6 +152,9 @@ const navItems: {
children: [ children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText }, { title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch }, { title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
// Serial register and batch expiry are enquiries — they reuse stock.enquiry's permission code.
{ title: "Serials", code: "stock.enquiry", href: "/dashboard/stock/serials", icon: ScanLine },
{ title: "Batch Expiry", code: "stock.enquiry", href: "/dashboard/stock/batches", icon: CalendarClock },
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign }, { title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight }, { title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal }, { title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
@@ -427,6 +434,8 @@ export function AppSidebar() {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const { navCodes, loading } = useAuth() const { navCodes, loading } = useAuth()
// The Serials register is hidden while serial numbers are switched off in Product Configuration.
const { serialNumbersEnabled } = useTrackingSettings()
// While /auth/me hasn't resolved yet, show nothing rather than briefly // While /auth/me hasn't resolved yet, show nothing rather than briefly
// flashing the full menu to a restricted role. Once resolved, a nav item // flashing the full menu to a restricted role. Once resolved, a nav item
@@ -452,9 +461,10 @@ export function AppSidebar() {
) )
.map((item) => ({ .map((item) => ({
...item, ...item,
children: bypassCodes.has(item.code) children: (bypassCodes.has(item.code)
? item.children ? item.children
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))), : item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
)?.filter((c) => !(serialNumbersEnabled === false && c.href === SERIALS_HREF)),
})) }))
// Close mobile menu on route change // Close mobile menu on route change
@@ -30,6 +30,9 @@ const SEGMENT_LABELS: Record<string, string> = {
counts: "Counts", counts: "Counts",
"reorder-alerts": "Reorder Alerts", "reorder-alerts": "Reorder Alerts",
wastage: "Wastage", wastage: "Wastage",
serials: "Serials",
batches: "Batch Expiry",
warranties: "Warranties",
procurement: "Procurement", procurement: "Procurement",
requisitions: "Requisitions", requisitions: "Requisitions",
rfqs: "RFQs", rfqs: "RFQs",
@@ -88,6 +88,8 @@ const STOCK_TITLES: Record<string, string> = {
"/dashboard/stock/reorder-alerts": "Reorder Alerts", "/dashboard/stock/reorder-alerts": "Reorder Alerts",
"/dashboard/stock/wastage": "Wastage", "/dashboard/stock/wastage": "Wastage",
"/dashboard/stock/wastage/new": "Record Wastage", "/dashboard/stock/wastage/new": "Record Wastage",
"/dashboard/stock/serials": "Serials",
"/dashboard/stock/batches": "Batch Expiry",
} }
const PRODUCTION_TITLES: Record<string, string> = { const PRODUCTION_TITLES: Record<string, string> = {
@@ -101,6 +103,7 @@ const PRODUCTION_TITLES: Record<string, string> = {
function titleFromPath(pathname: string) { function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales" if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale" if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes" if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note" if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
@@ -0,0 +1,227 @@
"use client"
import { useEffect, useState } from "react"
import { Layers, ScanLine } from "lucide-react"
import { trackingApi } from "@/lib/api/tracking"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { AvailableBatch, AvailableSerial, SalesLineSerial } from "@/types/tracking"
import { TrackingMode } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
export interface LineTrackingValue {
batchId: number | null
batchNo: string | null
serials: SalesLineSerial[]
}
interface Props {
itemId: number
warehouseId: number | null
trackingMode: TrackingMode | undefined
/** Units the line issues (qty + free qty) — a serial line needs exactly this many serials. */
units: number
value: LineTrackingValue
onChange: (next: LineTrackingValue) => void
disabled?: boolean
}
/**
* Batch / serial choice for one sales line (docs/ITEM_TRACKING_PLAN.md Phase 8).
* Serial items: pick exactly `units` in-stock serials (search, scan + Enter, or tick).
* Batch items: pin a batch, or leave "Auto (FEFO)" for posting to pick soonest-expiring first.
* Renders nothing for untracked items.
*/
export function LineTrackingPicker({ itemId, warehouseId, trackingMode, units, value, onChange, disabled }: Props) {
const { serialNumbersEnabled } = useTrackingSettings()
if (!itemId || !warehouseId || (trackingMode !== "Serial" && trackingMode !== "Batch")) return null
// Serial numbers switched off in Product Configuration: serial items are plain stock.
if (trackingMode === "Serial" && serialNumbersEnabled === false) return null
return trackingMode === "Serial" ? (
<SerialPicker itemId={itemId} warehouseId={warehouseId} units={units} value={value} onChange={onChange} disabled={disabled} />
) : (
<BatchPicker itemId={itemId} warehouseId={warehouseId} value={value} onChange={onChange} disabled={disabled} />
)
}
function SerialPicker({
itemId, warehouseId, units, value, onChange, disabled,
}: { itemId: number; warehouseId: number; units: number; value: LineTrackingValue; onChange: (v: LineTrackingValue) => void; disabled?: boolean }) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [available, setAvailable] = useState<AvailableSerial[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) return
let cancelled = false
trackingApi
.availableSerials(itemId, warehouseId)
.then((rows) => { if (!cancelled) { setAvailable(rows); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
return () => { cancelled = true }
}, [open, itemId, warehouseId])
const selected = value.serials
const selectedIds = new Set(selected.map((s) => s.serialId))
const complete = selected.length === units
function toggle(s: { serialId: number; serialNo: string }) {
const next = selectedIds.has(s.serialId)
? selected.filter((x) => x.serialId !== s.serialId)
: [...selected, { serialId: s.serialId, serialNo: s.serialNo }]
onChange({ ...value, serials: next })
}
// Scan / type a serial and press Enter: exact (case-insensitive) match ticks it.
function addTyped() {
const term = query.trim().toLowerCase()
if (!term || !available) return
const match = available.find((s) => s.serialNo.toLowerCase() === term)
if (!match) {
setError(`Serial "${query.trim()}" is not in stock at this warehouse.`)
return
}
if (!selectedIds.has(match.serialId)) toggle(match)
setQuery("")
setError(null)
}
const shown = (available ?? []).filter((s) => !query.trim() || s.serialNo.toLowerCase().includes(query.trim().toLowerCase()))
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
className={cn("mt-1 h-7 gap-1 px-2 text-xs", complete ? "border-success/50 text-success" : "border-warning/60 text-warning")}
/>
}
>
<ScanLine className="size-3.5" />
Serials {selected.length}/{units}
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-3">
<div className="flex flex-col gap-2">
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
addTyped()
}
}}
placeholder="Scan or search a serial — Enter to add"
className="h-9 text-sm"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<p className={cn("text-xs", complete ? "text-success" : "text-muted-foreground")}>
{selected.length} of {units} selected
</p>
<div className="max-h-60 overflow-y-auto rounded-md border">
{available === null && !error && <p className="p-3 text-xs text-muted-foreground">Loading…</p>}
{available !== null && shown.length === 0 && (
<p className="p-3 text-xs text-muted-foreground">No in-stock serials match.</p>
)}
{shown.map((s) => {
const checked = selectedIds.has(s.serialId)
const full = !checked && selected.length >= units
return (
<label
key={s.serialId}
className={cn("flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50", full && "cursor-not-allowed opacity-50")}
>
<Checkbox checked={checked} disabled={full} onCheckedChange={() => toggle(s)} />
<span className="font-mono">{s.serialNo}</span>
{s.batchNo && <span className="ml-auto text-xs text-muted-foreground">{s.batchNo}</span>}
</label>
)
})}
</div>
{selected.length > 0 && (
<Button type="button" variant="ghost" size="sm" className="self-end text-xs" onClick={() => onChange({ ...value, serials: [] })}>
Clear selection
</Button>
)}
</div>
</PopoverContent>
</Popover>
)
}
function BatchPicker({
itemId, warehouseId, value, onChange, disabled,
}: { itemId: number; warehouseId: number; value: LineTrackingValue; onChange: (v: LineTrackingValue) => void; disabled?: boolean }) {
const [open, setOpen] = useState(false)
const [batches, setBatches] = useState<AvailableBatch[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) return
let cancelled = false
trackingApi
.availableBatches(itemId, warehouseId)
.then((rows) => { if (!cancelled) { setBatches(rows); setError(null) } })
.catch((err) => { if (!cancelled) setError(errorMessage(err)) })
return () => { cancelled = true }
}, [open, itemId, warehouseId])
function pick(batch: AvailableBatch | null) {
onChange({ ...value, batchId: batch?.batchId ?? null, batchNo: batch?.batchNo ?? null })
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={<Button type="button" variant="outline" size="sm" disabled={disabled} className="mt-1 h-7 gap-1 px-2 text-xs" />}
>
<Layers className="size-3.5" />
{value.batchId ? `Batch ${value.batchNo ?? value.batchId}` : "Batch: auto (FEFO)"}
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-2">
<button
type="button"
onClick={() => pick(null)}
className={cn("flex w-full flex-col rounded-md px-3 py-2 text-left hover:bg-muted/50", !value.batchId && "bg-muted")}
>
<span className="text-sm font-medium">Auto (FEFO)</span>
<span className="text-xs text-muted-foreground">Soonest-expiring batches are issued first when posted.</span>
</button>
{error && <p className="px-3 py-2 text-xs text-destructive">{error}</p>}
{batches === null && !error && <p className="px-3 py-2 text-xs text-muted-foreground">Loading…</p>}
{batches?.filter((b) => b.batchId !== null).map((b) => (
<button
key={b.batchId}
type="button"
onClick={() => pick(b)}
className={cn("flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left hover:bg-muted/50", value.batchId === b.batchId && "bg-muted")}
>
<span className="flex flex-col">
<span className="font-mono text-sm">{b.batchNo}</span>
<span className={cn("text-xs", b.daysToExpiry !== null && b.daysToExpiry <= 30 ? "text-warning" : "text-muted-foreground")}>
{b.expiryDate ? `Expires ${b.expiryDate}${b.daysToExpiry !== null ? ` (${b.daysToExpiry}d)` : ""}` : "No expiry"}
</span>
</span>
<span className="text-xs tabular-nums text-muted-foreground">{b.qty}</span>
</button>
))}
{batches !== null && batches.every((b) => b.batchId === null) && (
<p className="px-3 py-2 text-xs text-muted-foreground">No batches in stock at this warehouse.</p>
)}
</PopoverContent>
</Popover>
)
}
@@ -8,9 +8,10 @@ import { reasonCodesApi } from "@/lib/api/reason-codes"
import { itemsApi } from "@/lib/api/items" import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { validateSalesReturnLine } from "@/lib/validations/sales" import { validateSalesReturnLine } from "@/lib/validations/sales"
import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine } from "@/types/sales" import { CreateSalesReturnLineInput, SalesInvoice, SalesInvoiceLine, SalesLineAllocation } from "@/types/sales"
import { ItemListItem } from "@/types/master-data" import { ItemListItem } from "@/types/master-data"
import { ReasonCode } from "@/types/stock" import { ReasonCode } from "@/types/stock"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -25,6 +26,10 @@ import { toast } from "@/components/ui/toast"
interface LineState { interface LineState {
selected: boolean selected: boolean
qty: string qty: string
/** Serial line: the sold serials being returned (qty follows the count). */
serialIds?: number[]
/** Line that issued several batches: which one comes back. */
batchId?: number | null
} }
export interface SalesReturnFormProps { export interface SalesReturnFormProps {
@@ -43,7 +48,10 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([]) const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({}) const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
const [allocationsByLine, setAllocationsByLine] = useState<Record<number, SalesLineAllocation[]>>({})
const [remainingLoading, setRemainingLoading] = useState(false) const [remainingLoading, setRemainingLoading] = useState(false)
const [markDamaged, setMarkDamaged] = useState(false)
const { serialNumbersEnabled } = useTrackingSettings()
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId) const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null) const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
@@ -83,8 +91,13 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
.getRemaining(selectedInvoice.salesInvoiceId) .getRemaining(selectedInvoice.salesInvoiceId)
.then((rows) => { .then((rows) => {
const map: Record<number, number> = {} const map: Record<number, number> = {}
for (const row of rows) map[row.salesInvoiceLineId] = row.remainingQty const allocations: Record<number, SalesLineAllocation[]> = {}
for (const row of rows) {
map[row.salesInvoiceLineId] = row.remainingQty
allocations[row.salesInvoiceLineId] = row.allocations ?? []
}
setRemainingByLine(map) setRemainingByLine(map)
setAllocationsByLine(allocations)
if (presetLineId) { if (presetLineId) {
const remaining = map[presetLineId] ?? 0 const remaining = map[presetLineId] ?? 0
setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } })) setLineState((prev) => ({ ...prev, [presetLineId]: { selected: remaining > 0, qty: remaining > 0 ? String(remaining) : "" } }))
@@ -115,6 +128,74 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } })) setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], qty } }))
} }
/** Sold serials of a line not yet returned — non-empty means the line is serial-tracked. */
function returnableSerials(lineId: number) {
if (serialNumbersEnabled === false) return [] // switched off: serial items return as plain stock
return (allocationsByLine[lineId] ?? []).filter((a) => a.serialId !== null && a.qtyReturned < a.qty)
}
/** Distinct batches a line issued that still have returnable qty. */
function returnableBatches(lineId: number) {
const seen = new Map<number, SalesLineAllocation>()
for (const a of allocationsByLine[lineId] ?? [])
if (a.batchId !== null && a.serialId === null && a.qtyReturned < a.qty && !seen.has(a.batchId)) seen.set(a.batchId, a)
return [...seen.values()]
}
function toggleSerial(lineId: number, serialId: number) {
setLineState((prev) => {
const current = prev[lineId]?.serialIds ?? []
const next = current.includes(serialId) ? current.filter((id) => id !== serialId) : [...current, serialId]
return { ...prev, [lineId]: { ...prev[lineId], selected: next.length > 0, serialIds: next, qty: String(next.length) } }
})
}
/** Serial checkboxes or batch choice under a line, when the sale recorded either. */
function renderLineTracking(line: SalesInvoiceLine) {
const lineId = line.salesInvoiceLineId
const serials = returnableSerials(lineId)
const batches = returnableBatches(lineId)
const state = lineState[lineId]
if (serials.length > 0) {
return (
<div className="mt-2 flex flex-col gap-1">
<p className="text-xs text-muted-foreground">Tick the serials coming back:</p>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{serials.map((s) => (
<label key={s.serialId} className="flex items-center gap-1.5 text-sm">
<Checkbox checked={state?.serialIds?.includes(s.serialId!) ?? false} onCheckedChange={() => toggleSerial(lineId, s.serialId!)} />
<span className="font-mono">{s.serialNo}</span>
</label>
))}
</div>
</div>
)
}
if (batches.length > 1 && state?.selected) {
return (
<div className="mt-2 max-w-64">
<Select<number | null>
value={state.batchId ?? null}
onValueChange={(v) => setLineState((prev) => ({ ...prev, [lineId]: { ...prev[lineId], batchId: v } }))}
>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Which batch comes back?" />
</SelectTrigger>
<SelectContent>
{batches.map((b) => (
<SelectItem key={b.batchId} value={b.batchId} className="text-sm">
{b.batchNo}{b.expiryDate ? ` · exp ${b.expiryDate}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
if (batches.length === 1) return <p className="mt-1 text-xs text-muted-foreground">Batch {batches[0].batchNo}</p>
return null
}
async function handleSubmit() { async function handleSubmit() {
setHeaderError(null) setHeaderError(null)
setSubmitError(null) setSubmitError(null)
@@ -141,6 +222,11 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
qty: lineState[line.salesInvoiceLineId].qty, qty: lineState[line.salesInvoiceLineId].qty,
maxQty: remainingFor(line.salesInvoiceLineId, line.qty), maxQty: remainingFor(line.salesInvoiceLineId, line.qty),
}) })
const state = lineState[line.salesInvoiceLineId]
if (returnableSerials(line.salesInvoiceLineId).length > 0 && (state.serialIds?.length ?? 0) !== Number(state.qty))
errors.qty = "Tick one serial per unit returned"
if (returnableBatches(line.salesInvoiceLineId).length > 1 && !state.batchId)
errors.qty = "Choose which batch comes back"
if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors if (Object.keys(errors).length > 0) nextErrors[line.salesInvoiceLineId] = errors
} }
setLineErrors(nextErrors) setLineErrors(nextErrors)
@@ -153,6 +239,8 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
salesInvoiceLineId: l.salesInvoiceLineId, salesInvoiceLineId: l.salesInvoiceLineId,
itemId: l.itemId, itemId: l.itemId,
qty: Number(lineState[l.salesInvoiceLineId].qty), qty: Number(lineState[l.salesInvoiceLineId].qty),
batchId: lineState[l.salesInvoiceLineId].batchId ?? null,
serialIds: lineState[l.salesInvoiceLineId].serialIds?.length ? lineState[l.salesInvoiceLineId].serialIds : null,
})) }))
setSubmitting(true) setSubmitting(true)
@@ -161,6 +249,7 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
customerId: selectedInvoice.customerId, customerId: selectedInvoice.customerId,
warehouseId: selectedInvoice.warehouseId, warehouseId: selectedInvoice.warehouseId,
reasonCodeId, reasonCodeId,
markDamaged: markDamaged || null,
lines: payloadLines, lines: payloadLines,
}) })
toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`) toast.success("Sales return posted", `${salesReturn.docNo} — ${salesReturn.ledgerRefs.length} ledger entr${salesReturn.ledgerRefs.length === 1 ? "y" : "ies"} posted.`)
@@ -216,6 +305,10 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<Checkbox checked={markDamaged} onCheckedChange={(v) => setMarkDamaged(!!v)} />
Returned serials are damaged (not resaleable) — Defective / Quality Reject reasons imply this
</label>
</div> </div>
{headerError && ( {headerError && (
@@ -242,6 +335,7 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
{item ? `${item.sku} — ${item.name}` : line.description} {item ? `${item.sku} — ${item.name}` : line.description}
</span> </span>
</label> </label>
{renderLineTracking(line)}
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm"> <div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
<div> <div>
@@ -305,7 +399,10 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
<TableCell> <TableCell>
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} /> <Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
</TableCell> </TableCell>
<TableCell>{item ? `${item.sku} — ${item.name}` : line.description}</TableCell> <TableCell>
{item ? `${item.sku} — ${item.name}` : line.description}
{renderLineTracking(line)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{line.qty}</TableCell> <TableCell className="text-sm text-muted-foreground">{line.qty}</TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{remainingLoading ? "…" : fullyReturned ? <span>Fully returned</span> : remaining} {remainingLoading ? "…" : fullyReturned ? <span>Fully returned</span> : remaining}
@@ -17,6 +17,8 @@ export interface SuggestOption {
/** Optional pill shown on the right, before the hint (e.g. an available stock count). */ /** Optional pill shown on the right, before the hint (e.g. an available stock count). */
badge?: string badge?: string
badgeTone?: SuggestBadgeTone badgeTone?: SuggestBadgeTone
/** Optional highlighted pills right after the label (e.g. "Serial" / "Batch" tracking). */
tags?: { label: string; tone: SuggestTagTone }[]
} }
export type SuggestBadgeTone = "muted" | "success" | "danger" export type SuggestBadgeTone = "muted" | "success" | "danger"
@@ -27,6 +29,29 @@ const badgeToneClass: Record<SuggestBadgeTone, string> = {
danger: "bg-destructive/10 text-destructive", danger: "bg-destructive/10 text-destructive",
} }
export type SuggestTagTone = "serial" | "batch"
const tagToneClass: Record<SuggestTagTone, string> = {
serial: "border-sky-500/40 bg-sky-500/15 text-sky-700 dark:text-sky-300",
batch: "border-amber-500/40 bg-amber-500/15 text-amber-700 dark:text-amber-300",
}
function OptionLabel({ option }: { option: SuggestOption }) {
return (
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate">{option.label}</span>
{option.tags?.map((tag) => (
<span
key={tag.label}
className={cn("shrink-0 rounded-md border px-1.5 py-px text-[11px] font-semibold uppercase tracking-wide", tagToneClass[tag.tone])}
>
{tag.label}
</span>
))}
</span>
)
}
interface SuggestInputProps { interface SuggestInputProps {
options: SuggestOption[] options: SuggestOption[]
/** Called with the picked option's value; the box then clears for the next entry. */ /** Called with the picked option's value; the box then clears for the next entry. */
@@ -259,11 +284,11 @@ export function SuggestInput({
> >
{option.detail ? ( {option.detail ? (
<span className="flex min-w-0 flex-col"> <span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span> <OptionLabel option={option} />
<span className="truncate text-xs text-muted-foreground">{option.detail}</span> <span className="truncate text-xs text-muted-foreground">{option.detail}</span>
</span> </span>
) : ( ) : (
<span className="truncate">{option.label}</span> <OptionLabel option={option} />
)} )}
{(option.badge || option.hint) && ( {(option.badge || option.hint) && (
<span className="flex shrink-0 items-center gap-2"> <span className="flex shrink-0 items-center gap-2">
@@ -0,0 +1,36 @@
"use client"
import { useEffect, useState } from "react"
import { trackingApi } from "@/lib/api/tracking"
import { TrackingSettings } from "@/types/tracking"
// One request per page load, shared by every component that asks; the Product Configuration
// page calls invalidateTrackingSettings() after a toggle so the next read is fresh.
let cached: Promise<TrackingSettings> | null = null
function load(): Promise<TrackingSettings> {
// A failed read falls back to the server's own default (serial numbers on).
cached ??= trackingApi.settings().catch(() => ({ serialNumbersEnabled: true }))
return cached
}
export function invalidateTrackingSettings() {
cached = null
}
/**
* Product Configuration tracking switches (docs/ITEM_TRACKING_PLAN.md §8). `serialNumbersEnabled`
* is null until loaded — callers treat that like "on", the server default.
*/
export function useTrackingSettings(): { serialNumbersEnabled: boolean | null } {
const [settings, setSettings] = useState<TrackingSettings | null>(null)
useEffect(() => {
let cancelled = false
load().then((s) => { if (!cancelled) setSettings(s) })
return () => { cancelled = true }
}, [])
return { serialNumbersEnabled: settings?.serialNumbersEnabled ?? null }
}
+89
View File
@@ -0,0 +1,89 @@
// Item tracking endpoints — pickers (/tracking), serial register (/serials), warranty register
// (/warranties) and batch stock (/stock/batches). docs/ITEM_TRACKING_PLAN.md.
import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
AvailableBatch,
AvailableSerial,
BatchStock,
BatchSuggestion,
SerialDetail,
SerialStatus,
SerialSummary,
TrackingSettings,
WarrantyRegistration,
WarrantyStatus,
} from "@/types/tracking"
export interface SerialListParams {
q?: string
itemId?: number
warehouseId?: number
status?: SerialStatus
page?: number
pageSize?: number
}
export interface WarrantyListParams {
q?: string
customerId?: number
itemId?: number
serialNo?: string
status?: WarrantyStatus
expiringWithinDays?: number
expired?: boolean
page?: number
pageSize?: number
}
export interface BatchStockParams {
q?: string
itemId?: number
warehouseId?: number
expiringWithinDays?: number
page?: number
pageSize?: number
}
export const trackingApi = {
settings() {
return apiRequest<TrackingSettings>("/tracking/settings")
},
/** Next free serial numbers for an item under a prefix (auto-generate on GRN). */
nextSerialNumbers(itemId: number, prefix: string, count: number) {
return apiRequest<string[]>(`/tracking/serials/next-numbers${buildQuery({ itemId, prefix, count })}`)
},
availableSerials(itemId: number, warehouseId: number, q?: string) {
return apiRequest<AvailableSerial[]>(`/tracking/serials/available${buildQuery({ itemId, warehouseId, q })}`)
},
availableBatches(itemId: number, warehouseId: number) {
return apiRequest<AvailableBatch[]>(`/tracking/batches/available${buildQuery({ itemId, warehouseId })}`)
},
suggestBatches(itemId: number, warehouseId: number, qty: number) {
return apiRequest<BatchSuggestion[]>(`/tracking/batches/suggest${buildQuery({ itemId, warehouseId, qty })}`)
},
listSerials(params: SerialListParams = {}) {
return apiRequest<PagedResponse<SerialSummary>>(`/serials${buildQuery(params)}`)
},
getSerial(serialId: number) {
return apiRequest<SerialDetail>(`/serials/${serialId}`)
},
lookupSerial(serialNo: string) {
return apiRequest<SerialDetail[]>(`/serials/lookup${buildQuery({ serialNo })}`)
},
listWarranties(params: WarrantyListParams = {}) {
return apiRequest<PagedResponse<WarrantyRegistration>>(`/warranties${buildQuery(params)}`)
},
listBatchStock(params: BatchStockParams = {}) {
return apiRequest<PagedResponse<BatchStock>>(`/stock/batches${buildQuery(params)}`)
},
}
+15
View File
@@ -0,0 +1,15 @@
import type { SuggestTagTone } from "@/components/ui/suggest-input"
import type { TrackingMode } from "@/types/master-data"
/**
* Highlighted tracking tag for an item in a dropdown: "Serial" or "Batch". A Serial item gets no
* tag while serial numbers are switched off in Product Configuration — it is plain stock then.
*/
export function trackingTags(
trackingMode: TrackingMode,
serialNumbersEnabled: boolean | null,
): { label: string; tone: SuggestTagTone }[] {
if (trackingMode === "Serial" && serialNumbersEnabled !== false) return [{ label: "Serial", tone: "serial" }]
if (trackingMode === "Batch") return [{ label: "Batch", tone: "batch" }]
return []
}
+14 -5
View File
@@ -3,7 +3,7 @@
// (over-receipt tolerance, referential existence, concurrency) are never // (over-receipt tolerance, referential existence, concurrency) are never
// re-implemented here; the server's ProblemDetails is the final word (§3.3). // re-implemented here; the server's ProblemDetails is the final word (§3.3).
import { z } from "zod" import { z } from "zod"
import { Warranty } from "@/types/master-data" import { TrackingMode, Warranty } from "@/types/master-data"
export const grnHeaderSchema = z.object({ export const grnHeaderSchema = z.object({
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"), warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
@@ -18,6 +18,9 @@ export function validateLine(input: {
discountPct: string discountPct: string
vatPct: string vatPct: string
warranty: Warranty | null warranty: Warranty | null
trackingMode: TrackingMode | null
batchNo: string
/** Per-unit numbers: serial numbers for a Serial item, else warranty numbers for a Warranty item. */
warrantyNumbers: string[] warrantyNumbers: string[]
}): Record<string, string> { }): Record<string, string> {
const errors: Record<string, string> = {} const errors: Record<string, string> = {}
@@ -38,14 +41,20 @@ export function validateLine(input: {
if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100)) if (input.vatPct !== "" && (Number.isNaN(vatPct) || vatPct < 0 || vatPct > 100))
errors.vatPct = "VAT must be 0–100%" errors.vatPct = "VAT must be 0–100%"
if (input.warranty === "Warranty") { if (input.trackingMode === "Batch" && !input.batchNo.trim()) errors.batchNo = "Enter the batch number"
const serial = input.trackingMode === "Serial"
if (serial && !Number.isNaN(qty) && !Number.isInteger(qty)) errors.qty = "Serial-tracked items are received in whole units"
if (serial || input.warranty === "Warranty") {
const label = serial ? "serial number" : "warranty number"
const numbers = input.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0) const numbers = input.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
if (numbers.length === 0) { if (numbers.length === 0) {
errors.warrantyNumbers = "Enter one warranty number per unit" errors.warrantyNumbers = `Enter one ${label} per unit`
} else if (!Number.isNaN(qty) && numbers.length !== qty) { } else if (!Number.isNaN(qty) && numbers.length !== qty) {
errors.warrantyNumbers = `Enter exactly ${qty || 0} warranty number(s) — got ${numbers.length}` errors.warrantyNumbers = `Enter exactly ${qty || 0} ${label}(s) — got ${numbers.length}`
} else if (new Set(numbers.map((s) => s.toLowerCase())).size !== numbers.length) { } else if (new Set(numbers.map((s) => s.toLowerCase())).size !== numbers.length) {
errors.warrantyNumbers = "Warranty numbers must be unique" errors.warrantyNumbers = `${serial ? "Serial" : "Warranty"} numbers must be unique`
} }
} }
+18 -6
View File
@@ -1,10 +1,7 @@
// Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08). // Goods Receipt Note types (docs/11-BACKEND-PHASE1.md §4; FR-GRN-01..08).
// Mirrors the live backend DTOs. GET /grns and GET /grns/{id} both exist as of 2026-07-16. // Mirrors the live backend DTOs. GET /grns and GET /grns/{id} both exist as of 2026-07-16.
// // Serial numbers (FR-GRN-04) are captured per unit for Serial-tracked items — see
// KNOWN GAP — serial numbers: FR-GRN-04 requires capturing serial numbers on receipt for // docs/ITEM_TRACKING_PLAN.md Phase 2.
// Serial-tracked items, but the backend's CreateGrnLineInput has no such field (only
// `batch`). There is therefore deliberately no `serialNumbers` here: typing it would
// invite the UI to send data the server silently drops. Tracked in Frontend/PROGRESS.md.
export type GrnStatus = "Draft" | "Confirmed" | "Closed" export type GrnStatus = "Draft" | "Confirmed" | "Closed"
export type HoldStatus = "OnHold" | "Available" | "Rejected" export type HoldStatus = "OnHold" | "Available" | "Rejected"
@@ -51,9 +48,15 @@ export interface CreateGrnLineInput {
batch?: BatchInput | null batch?: BatchInput | null
/** /**
* Required, one per received unit (count must equal `qty`), when the item is * Required, one per received unit (count must equal `qty`), when the item is
* warranty-tracked (`Item.warranty === "Warranty"`). Ignored otherwise. * warranty-tracked (`Item.warranty === "Warranty"`) and NOT serial-tracked — a serial
* item's serial number is its warranty identity. Ignored otherwise.
*/ */
warrantyNumbers?: string[] | null warrantyNumbers?: string[] | null
/**
* Required, one per received unit (qty must be whole), when the item is serial-tracked
* (`Item.trackingMode === "Serial"`). Rejected for any other item.
*/
serialNumbers?: string[] | null
} }
export interface CreateGrnRequest { export interface CreateGrnRequest {
@@ -90,6 +93,15 @@ export interface GrnLine {
holdStatus: HoldStatus holdStatus: HoldStatus
batchId: number | null batchId: number | null
warrantyNumbers: GrnLineWarrantyNumber[] warrantyNumbers: GrnLineWarrantyNumber[]
batchNo: string | null
batchExpiryDate: string | null
/** Serials captured on the line; `serialId` is set once the GRN is confirmed. */
serials: GrnLineSerial[]
}
export interface GrnLineSerial {
serialNo: string
serialId: number | null
} }
export interface Grn { export interface Grn {
+2
View File
@@ -265,6 +265,8 @@ export interface CreatePurchaseReturnLineInput {
grnLineId?: number | null grnLineId?: number | null
itemId: number itemId: number
qty: number qty: number
/** Serial-tracked item: the serials sent back (become ReturnedToVendor). */
serialIds?: number[] | null
} }
export interface CreatePurchaseReturnRequest { export interface CreatePurchaseReturnRequest {
+37
View File
@@ -1,3 +1,4 @@
import { SalesLineSerial } from "@/types/tracking"
import { EntityStatus } from "@/types/common" import { EntityStatus } from "@/types/common"
export type SalesInvoiceType = "B2B" | "B2C" | "Cash" | "Credit" export type SalesInvoiceType = "B2B" | "B2C" | "Cash" | "Credit"
@@ -24,6 +25,8 @@ export interface SalesInvoiceLine {
taxAmount: number taxAmount: number
isFreeIssue: boolean isFreeIssue: boolean
parentLineId: number | null parentLineId: number | null
batchId: number | null
serials: SalesLineSerial[]
} }
export interface SalesInvoiceTotals { export interface SalesInvoiceTotals {
@@ -197,6 +200,10 @@ export interface CreateSalesInvoiceLineRequest {
taxPct: number taxPct: number
isFreeIssue: boolean isFreeIssue: boolean
parentLineId?: number | null parentLineId?: number | null
/** Pins one batch; omit to let posting pick FEFO. */
batchId?: number | null
/** Serial-tracked item: one in-stock serial per unit of qty + freeQty. */
serialIds?: number[] | null
} }
export interface CreateSalesInvoiceRequest { export interface CreateSalesInvoiceRequest {
@@ -230,6 +237,9 @@ export interface SalesSlipLine {
taxAmount: number taxAmount: number
isFreeIssue: boolean isFreeIssue: boolean
parentLineId: number | null parentLineId: number | null
batchId: number | null
serialId: number | null
serials: SalesLineSerial[]
} }
export interface SalesSlipTotals { export interface SalesSlipTotals {
@@ -283,6 +293,12 @@ export interface CreateSalesSlipLineRequest {
taxPct: number taxPct: number
isFreeIssue: boolean isFreeIssue: boolean
parentLineId?: number | null parentLineId?: number | null
/** Pins one batch; omit to let posting pick FEFO. */
batchId?: number | null
/** Legacy single-serial pin; prefer `serialIds`. */
serialId?: number | null
/** Serial-tracked item: one in-stock serial per unit of qty + freeQty. */
serialIds?: number[] | null
} }
export interface CreateSalesSlipRequest { export interface CreateSalesSlipRequest {
@@ -409,6 +425,9 @@ export interface SalesReturnLine {
salesInvoiceLineId: number | null salesInvoiceLineId: number | null
itemId: number itemId: number
qty: number qty: number
/** Batch restocked into — the batch the goods were sold from. */
batchId: number | null
serials: SalesLineSerial[]
} }
export interface SalesReturn { export interface SalesReturn {
@@ -444,12 +463,28 @@ export interface SalesReturnSummary {
export interface SalesInvoiceLineRemaining { export interface SalesInvoiceLineRemaining {
salesInvoiceLineId: number salesInvoiceLineId: number
remainingQty: number remainingQty: number
/** What the posted line actually issued (batch/serial), net of returns — what a return can take back. */
allocations: SalesLineAllocation[]
}
export interface SalesLineAllocation {
batchId: number | null
batchNo: string | null
expiryDate: string | null
serialId: number | null
serialNo: string | null
qty: number
qtyReturned: number
} }
export interface CreateSalesReturnLineInput { export interface CreateSalesReturnLineInput {
salesInvoiceLineId?: number | null salesInvoiceLineId?: number | null
itemId: number itemId: number
qty: number qty: number
/** Batch to restock into; optional when the line issued a single batch. */
batchId?: number | null
/** Serial-tracked item: the returned serials, one per unit. */
serialIds?: number[] | null
} }
export interface CreateSalesReturnRequest { export interface CreateSalesReturnRequest {
@@ -457,6 +492,8 @@ export interface CreateSalesReturnRequest {
warehouseId: number warehouseId: number
/** Mandatory; omitting it returns 400 REASON_CODE_REQUIRED. */ /** Mandatory; omitting it returns 400 REASON_CODE_REQUIRED. */
reasonCodeId: number reasonCodeId: number
/** Returned serials become Damaged instead of InStock. Omit to derive from the reason (DEF/QREJ ⇒ damaged). */
markDamaged?: boolean | null
lines: CreateSalesReturnLineInput[] lines: CreateSalesReturnLineInput[]
} }
+12
View File
@@ -76,6 +76,8 @@ export interface TransferLine {
qty: number qty: number
/** Accrues on partial receive; qty - qtyReceived is what is still in transit. */ /** Accrues on partial receive; qty - qtyReceived is what is still in transit. */
qtyReceived: number qtyReceived: number
/** Serial-tracked item: the units moved; `received` once each has arrived. */
serials: { serialId: number; serialNo: string; received: boolean }[]
} }
export interface StockTransfer { export interface StockTransfer {
@@ -106,8 +108,11 @@ export interface CreateTransferLineInput {
itemId: number itemId: number
srcBinId?: number | null srcBinId?: number | null
destBinId?: number | null destBinId?: number | null
/** Omit for a batch-tracked item to have the server split the line FEFO across batches. */
batchId?: number | null batchId?: number | null
qty: number qty: number
/** Serial-tracked item: one in-stock serial (at the source) per unit. */
serialIds?: number[] | null
} }
export interface CreateTransferRequest { export interface CreateTransferRequest {
@@ -136,6 +141,8 @@ export interface DispatchTransferResponse {
export interface ReceiveTransferLineInput { export interface ReceiveTransferLineInput {
transferLineId: number transferLineId: number
qty: number qty: number
/** Serial line: which in-transit serials arrived; omit when receiving all outstanding. */
serialIds?: number[] | null
} }
export interface ReceiveTransferRequest { export interface ReceiveTransferRequest {
@@ -169,6 +176,7 @@ export interface AdjustmentLine {
batchId: number | null batchId: number | null
/** Signed: negative FIFO-consumes, positive creates a layer. */ /** Signed: negative FIFO-consumes, positive creates a layer. */
qtyDelta: number qtyDelta: number
serialId: number | null
} }
export interface StockAdjustment { export interface StockAdjustment {
@@ -202,6 +210,10 @@ export interface CreateAdjustmentLineInput {
binId?: number | null binId?: number | null
batchId?: number | null batchId?: number | null
qtyDelta: number qtyDelta: number
/** Negative delta, serial item: the serials written off (become Scrapped). */
serialIds?: number[] | null
/** Positive delta, serial item: the serial numbers found (created, or brought back InStock). */
serialNos?: string[] | null
} }
export interface CreateAdjustmentRequest { export interface CreateAdjustmentRequest {
+120
View File
@@ -0,0 +1,120 @@
// Item tracking — serials, batches, warranty (docs/ITEM_TRACKING_PLAN.md Phases 5, 8, 9).
// Mirrors Backend Dtos/Stock/TrackingDtos.cs.
export type SerialStatus =
| "InStock"
| "Reserved"
| "InTransit"
| "Sold"
| "ReturnedByCustomer"
| "ReturnedToVendor"
| "Damaged"
| "Scrapped"
/** Left stock without being named while serial numbers were switched off. */
| "IssuedUntracked"
export type WarrantyStatus = "Active" | "Voided" | "Claimed"
/** Product Configuration switches every screen can read (GET /tracking/settings). */
export interface TrackingSettings {
serialNumbersEnabled: boolean
}
/** A serial chosen on (or sold by) a sales line. */
export interface SalesLineSerial {
serialId: number
serialNo: string
}
export interface AvailableSerial {
serialId: number
serialNo: string
batchId: number | null
batchNo: string | null
status: SerialStatus
}
export interface AvailableBatch {
batchId: number | null
batchNo: string | null
/** `YYYY-MM-DD` */
expiryDate: string | null
qty: number
daysToExpiry: number | null
}
export interface BatchSuggestion {
batchId: number | null
batchNo: string | null
expiryDate: string | null
qty: number
}
export interface SerialSummary {
serialId: number
itemId: number
sku: string
itemName: string
serialNo: string
status: SerialStatus
warehouseId: number | null
warehouseName: string | null
binId: number | null
receivedAt: string | null
grnId: number | null
grnDocNo: string | null
warrantyPeriodMonths: number | null
}
export interface SerialMovement {
serialMovementId: number
/** Document-type prefix (GRN, SI, SSL, SRET, PRET, TRF, ADJ, CNT). */
sourceDocType: string
sourceDocId: number
status: SerialStatus
fromWarehouseId: number | null
warehouseId: number | null
userId: number
createdAt: string
}
export interface WarrantyRegistration {
warrantyRegistrationId: number
itemId: number
sku: string
itemName: string
serialId: number | null
serialNo: string | null
customerId: number
customerName: string
sourceDocType: string
sourceDocId: number
sourceLineId: number
qty: number
startDate: string
periodMonths: number
endDate: string
status: WarrantyStatus
daysRemaining: number
isExpired: boolean
}
export interface SerialDetail {
serial: SerialSummary
history: SerialMovement[]
warranties: WarrantyRegistration[]
}
export interface BatchStock {
itemId: number
sku: string
itemName: string
warehouseId: number
warehouseName: string
batchId: number | null
batchNo: string | null
expiryDate: string | null
qty: number
daysToExpiry: number | null
isExpired: boolean
}
+242
View File
@@ -0,0 +1,242 @@
# Item Tracking (Serial / Batch / Expiry / Warranty) — ERP-core Plan
**Status**: Implemented 2026-10-07 on branch `ui-bug-fixers` (uncommitted). Migration
`20261007054513_AddItemTrackingSerialsWarranty` generated and **not yet applied** — `Program.cs` runs
`MigrateAsync` on startup, so it applies on the next backend start. See §6 for what was built
differently from the plan and §7 for what is still open.
**Origin**: adapted from the distribution system's `item_tracking_plan.md`. That plan does **not** map
1:1 — ERP-core already has most of its foundation (see §1). This plan covers only the gaps.
| Phase | Title | Status |
|-------|-------|--------|
| 0 | Decisions (see §3) | ✅ Recommendations D1–D7 adopted as written |
| 1 | `Serial` entity hardening (status enum, location, provenance) | ✅ |
| 2 | GRN serial capture + per-serial FIFO layers | ✅ |
| 3 | Serial-aware / FEFO stock consumption | ✅ |
| 4 | Sales documents — batch/serial on invoice lines, mandatory on tracked items | ✅ (bundles: see §7) |
| 5 | Warranty start at sale (`WarrantyRegistration`) | ✅ |
| 6 | Returns — sales return restock to batch/serial, purchase return of serials | ✅ |
| 7 | Transfers, adjustments, counts — serial carry-through | ✅ |
| 8 | Frontend — GRN serial entry, sale pickers, lookups | ✅ (gaps in §7) |
| 9 | Reports — serial/warranty lookup, batch expiry, stock by batch | ✅ (expiry notifications: §7) |
| 10 | Migration + tests | ✅ migration generated · ✅ 15 unit tests · ❌ e2e |
---
## 1. What already exists (do not rebuild)
| Area | Where | Notes |
|---|---|---|
| Global toggles | `Domain/Entities/ProductConfig.cs` (`SerialNumbersEnabled`, `BatchListsEnabled`, `WarrantyPeriodsEnabled`, `CustomWarrantyPeriodsEnabled`) | Singleton row, enforced on item writes. Replaces the other plan's `SysConfig` keys. |
| Per-item mode | `Item.TrackingMode` (`None`/`Batch`/`Serial`), `Item.Warranty`, `Item.WarrantyPeriodMonths` | Mutually exclusive mode — an item is batch **or** serial, never both. Expiry lives on `Batch`, so there is no separate expiry flag. |
| Warranty periods | `WarrantyPeriod` master + `WarrantyPeriods.AllowedMonths` (3/6/12/18) | Months, not days. Validated in `ItemService.ValidateWarrantyPeriod`. |
| Batches | `Batch` (`BatchNo`, `ExpiryDate`, `Barcode`), unique `(ItemId, BatchNo)` | Created/reused at GRN by `GrnService.ResolveBatchAsync`. |
| Serials | `Serial` (`SerialNo`, `Status` **string**, `Barcode`), unique `(ItemId, SerialNo)` | Entity exists but **nothing creates rows** (see §2). |
| Ledger / layers | `StockLedger.BatchId/SerialId`, `StockLayer.BatchId/SerialId` | Columns exist; `SerialId` is always written `null` today. |
| Expiry block | `FifoCostingService.ConsumeAsync` excludes expired batches, throws `EXPIRED_BATCH_BLOCKED` for an explicit expired batch | |
| Warranty numbers at GRN | `GrnLineWarrantyNumber` — one per received unit, period snapshotted from item | Unique only per GRN line, not globally. |
| Sales slip | `SalesSlipLine.BatchId/SerialId`; `SalesPostingService` passes batch to FIFO and flips `Serial.Status = "Sold"` | |
| Scan lookup | `ItemService` scan resolves item / batch / serial by barcode or number | |
| Counts | `CountService` snapshots per batch and per serial | Serial branch relies on per-serial layers that never exist. |
## 2. Gaps found (the actual work)
1. **Serials are never received.** `CreateGrnLineInput` has no serial field (frontend `types/grn.ts`
documents this as "KNOWN GAP"), `GrnService` never creates `Serial` rows, and
`ConfirmAsync` creates one layer per line with `serialId: null`. A `Serial`-mode item can be
received with no serials at all.
2. **FIFO cannot consume a specific serial.** `ConsumeAsync` takes `batchId` only; the slip posting
flips the serial to `Sold` without proving that serial's stock was the stock consumed.
3. **Sales invoices drop batch/serial.** `SalesInvoiceLine` has no `BatchId`/`SerialId`;
`PostInvoiceAsync` passes `null, null` (`SalesPostingService.cs:166`). The invoice UI carries
them as display-only metadata. Bundle sales also pass `null, null`.
4. **Tracking is never mandatory on issue.** A batch- or serial-tracked item can be sold, transferred,
or adjusted with no batch/serial, so batch balances and serial status drift.
5. **No FEFO.** Consumption orders by `ReceiptDate`, although `Batch` docs say "expiry drives FEFO".
6. **Warranty never starts.** `GrnLineWarrantyNumber` records a period, but nothing records the sale
date, customer, or expiry date, and there is no lookup.
7. **Returns lose identity.** `SalesReturnService` restocks via `StockDelta(itemId, null, null, qty)`
— no batch, no serial status change, warranty not voided. `PurchaseReturnService` likewise
ignores serials.
8. **Transfers/adjustments** carry `BatchId` only; serial location is never tracked.
9. **`Serial.Status` is a free string** (`"InStock"`, `"Sold"`) — no enum, no validation.
## 3. Decisions needed before Phase 1 (Phase 0)
| # | Question | Recommendation |
|---|---|---|
| D1 | Warranty number vs serial number — for a `Serial` + `Warranty` item, are they the same identifier? | **Yes, use the serial as the warranty identity** and skip `WarrantyNumbers` for serial items. Keep `GrnLineWarrantyNumber` only for warranty items with `TrackingMode = None`. |
| D2 | Should `Warranty = Warranty` require `TrackingMode = Serial`? | Not forced now (would break existing items); revisit after D1 ships. |
| D3 | Which document starts warranty? | **Posting of the stock-consuming sale**: slip, invoice and bundle all go through `SalesPostingService.PostAsync`, so one hook covers all three. This settles the other plan's open Phase 13 question. |
| D4 | One serial per sales line, or many? | **Many**: add a child `…LineSerial` table rather than forcing `Qty = 1` per line (`SalesSlipLine.SerialId` becomes legacy/compat). |
| D5 | Batch item sold with no batch chosen | **Auto-allocate FEFO** server-side, splitting across batches. Record each allocation on the line. |
| D6 | Legacy stock of serial items received before Phase 2 (layers with no serial) | Allow issuing serial-less stock **only while** untracked layers remain (`QtyRemaining > 0`, `SerialId IS NULL`); after that, a serial is required. Optionally a one-off "assign serials to existing stock" adjustment. |
| D7 | Warranty start date: post date or a delivery date? | Post date (no delivery document exists). |
## 4. Phases
### Phase 1 — `Serial` hardening
- `Domain/Enums/SerialStatus.cs`: `InStock, Reserved, Sold, ReturnedByCustomer, ReturnedToVendor, Damaged, Scrapped`.
Map with `HasConversion<string>()` in `BatchSerialConfiguration` — existing `"InStock"`/`"Sold"`
values stay valid, so no data migration is needed.
- Add to `Serial`: `WarehouseId?`, `BinId?` (current location), `GrnLineId?` (provenance),
`ReceivedAt`, `CreatedAt`, `UpdatedAt`, `RowVersion`.
- Index `(ItemId, Status)`, `(WarehouseId, ItemId, Status)`.
### Phase 2 — GRN serial capture
- `CreateGrnLineInput.SerialNumbers: List<string>?`; `GrnLineDto.Serials`.
- New `GrnLineSerial` (`GrnLineId`, `SerialNo`), unique `(GrnLineId, SerialNo)` — the draft holds numbers only.
- `GrnService.CreateAsync` → `ResolveSerialNumbers(item, input, qty)`, the same pattern as
`ResolveWarrantyNumbers`: required iff `TrackingMode == Serial`; count must equal `Qty`, and `Qty`
must be a whole number; trimmed, distinct within the request, and not already an existing `Serial` for the item;
rejected when the mode is not `Serial`. Per D1, a serial+warranty item does not need `WarrantyNumbers`.
- `ConfirmAsync`: for serial lines create one `Serial` per number (`InStock`, warehouse/bin, `GrnLineId`)
and **one layer + one ledger row per serial (qty 1)** via the existing
`CreateInboundLayerAsync(..., serialId, ...)` / `PostLedgerAsync(..., serialId, ...)`. Re-check
uniqueness inside the transaction (a concurrent GRN could take the number). GL posting is unchanged.
- Hold/reject: serials on an `OnHold`/`Rejected` line remain `InStock` but non-issuable through the existing layer filter.
### Phase 3 — Serial-aware and FEFO consumption (`FifoCostingService`)
- `ConsumeAsync(itemId, warehouseId, batchId, qty, ct, allowShortfall, serialIds = null)`: when
`serialIds` is given, issue exactly those layers (each must be open, qty 1, issuable) or throw a new
`SERIAL_NOT_AVAILABLE` (409).
- Ordering: for `Batch` items with no batch given, order candidates by `b.ExpiryDate` (nulls last),
then `ReceiptDate`, which gives FEFO. `None` items keep pure FIFO.
- New `AllocateBatchesAsync(itemId, warehouseId, qty)` → `[(batchId, qty)]` for D5 and for UI suggestions.
- Keep the deliberate "settle shortfall layers before the batch filter" behaviour; add tests around it.
### Phase 4 — Sales documents
- New `SalesInvoiceLineAllocation` / `SalesSlipLineAllocation` (`LineId`, `BatchId?`, `SerialId?`, `Qty`),
following D4/D5. Request DTOs accept `batchId?` and `serialIds?`; existing `SalesSlipLine.SerialId`/`BatchId`
are still read for old documents.
- Validation in `SalesDomainService` (shared by slip/invoice/bundle):
`Serial` → serial count equals `Qty + FreeQty`, each `InStock` in the line warehouse (D6 exception);
`Batch` → given batch has stock and is not expired, or none given → FEFO allocation on post.
- `SalesPostingService.PostAsync`: `PostingLine` carries allocations; consume per allocation; one
ledger row per serial; serial → `Sold`, location cleared. Replace the current `null, null` for
invoices (line 166) and bundles (line 236).
- Over-stock sale (`allowShortfall`) is not allowed for serial items (a serial cannot go negative).
### Phase 5 — Warranty registration
- New `WarrantyRegistration`: `ItemId`, `SerialId?`, `GrnLineWarrantyNumberId?`, `CustomerId?`,
`SourceDocType`/`SourceDocId`/`LineId`, `StartDate`, `PeriodMonths`, `EndDate`, `Status` (`Active`, `Voided`, `Claimed`).
- Created in `PostAsync` after consumption for items with `Warranty = Warranty`:
`PeriodMonths` = the receipt snapshot (`GrnLineWarrantyNumber.WarrantyPeriodMonths`, or the item's
current `WarrantyPeriodMonths` for serial items) and `EndDate = StartDate.AddMonths(PeriodMonths)`.
- For non-serial warranty items: the sale must name which warranty numbers leave (picker, the same
shape as serials), or else D1/D2 is revisited.
- `GET /api/warranty?serialNo=|warrantyNo=|customerId=` → status and days remaining.
### Phase 6 — Returns
- `SalesReturnLine` gets `BatchId?` and serial allocations. `StockDelta` (`IStockMutator`) gets an
optional `SerialId`; `StockMutator` passes it to layer and ledger.
- The return reason (existing `ReasonCode`) maps the serial to `InStock` (restock) or `Damaged`;
the batch restocks into the **same** batch; the matching `WarrantyRegistration` → `Voided`.
- A serial can be returned only if it was sold on the referenced invoice line.
- `PurchaseReturnService`: serial layers being returned → `ReturnedToVendor`.
### Phase 7 — Transfers, adjustments, counts
- `StockTransferLine` + `StockAdjustmentLine`: serial allocations; transfer moves the per-serial
layer and updates `Serial.WarehouseId/BinId`; a negative adjustment of a serial → `Scrapped`/`Damaged`.
- `CountService` variance posting for serials: found → create layer + `InStock`; missing → consume + `Damaged`
(confirm wording with ops). Its existing serial snapshot starts working once Phase 2 writes per-serial layers.
- Production (`ProductionRunService` blocks tracked finished goods) — **out of scope**, unchanged.
### Phase 8 — Frontend (`Frontend/erp-system`)
- `receiving/grn/new`: per-line serial entry for `Serial` items (scan field — Enter adds; paste list;
auto-generate prefix + start; "n of N" counter; save disabled until complete). Remove the
"KNOWN GAP" note in `types/grn.ts` and add rules in `lib/validations/grn.ts`. Hide warranty-number
entry for serial items (D1).
- `receiving/grn/[id]`: show serials per line (collapsible).
- Sales slip / invoice / bundle: serial picker (scan or search `InStock` serials in the line warehouse)
and batch picker with expiry and FEFO suggestion; turn the existing scan metadata into real request fields.
- Sales return: pick serials and batches from the original invoice line.
- New `stock/serials` (search by serial → status, location, GRN, sale, warranty) and a warranty lookup.
### Phase 9 — Reports
- Stock on hand by batch, with expiry; "expiring in N days" (feeds the notification module —
`NotificationModuleCatalog`).
- Serial register / history (from `StockLedger.SerialId`).
- Warranty register: active, expiring, expired.
### Phase 10 — Migration + tests
- One EF migration per phase group (1–2, 3–4, 5–7). **This is MySQL/MariaDB**: re-check any CHECK
constraints for the quoting issue fixed in `20261005190000_FixCheckConstraintQuotingForMySql`, and
note that entity comments still mention PostgreSQL `xmin` for `RowVersion`.
- Backfill: none required (status values are compatible; legacy layers are handled by D6).
- Tests (`Testing/`): GRN serial validation and duplicate race; FEFO order with expired/null-expiry
batches; serial issue on slip, invoice, and bundle; shortfall blocked for serials; return restores
status and voids warranty; transfer moves serial location; count variance for serials.
## 5. Execution order
```
Phase 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 (backend, sequential: shared FifoCostingService / SalesPostingService)
Phase 8 starts per screen once its backend phase lands (GRN after 2, sales after 4, returns after 6)
Phase 9 after 5 · Phase 10 alongside each group
```
Smallest shippable slice: **Phases 1 + 2 + GRN UI** (serials can be received correctly). Next:
**3 + 4 + 5** (sell with serial/batch, warranty starts).
## 6. As built — differences from the plan
| Plan said | Built | Why |
|---|---|---|
| One stock-ledger row per serial | Ledger stays **one row per document line** (per batch for FEFO splits); new append-only **`SerialMovement`** table records every serial status/location change | `FifoCostingService.PostLedgerAsync` posts a GL journal per ledger row — per-serial rows would mean one GL call per unit |
| Warranty period read from the item at sale (serial items) | Snapshotted onto **`Serial.WarrantyPeriodMonths`** at GRN confirm | Same rule as `GrnLineWarrantyNumber` — later item edits don't change stock already received |
| `SalesInvoiceLineAllocation` / `SalesSlipLineAllocation` | Draft choices in **`SalesInvoiceLineSerial` / `SalesSlipLineSerial`**; what posting actually issued in one polymorphic **`SalesLineAllocation`** (doc type/id/line, batch, serial, qty, cost, qtyReturned) | Invoices, slips and bundles share one posting path; returns read one table |
| Transfer: serial allocations on the line | **`StockTransferLineSerial`** (+ per-serial cost/batch from dispatch) and new status **`InTransit`** | A serial must be in no warehouse between dispatch and receive |
| Batch item transfer with no batch | Server **splits the line FEFO into one line per batch at create** | Otherwise the batch is lost when the destination layer is created |
| Count: missing serial → `Damaged` | Missing → **`Scrapped`**, found → `InStock` | A unit that can't be found isn't "damaged" |
| Return reason → serial status | `DEF`/`QREJ` ⇒ `Damaged`, else `InStock`; request `markDamaged` overrides | Reason codes are user data — the flag keeps it explicit |
| — | Pickers live on **`api/v1/tracking`** (base ERP access); registers on `api/v1/serials` (stock), `api/v1/warranties` (sales), `api/v1/stock/batches` | Sales, GRN and stock screens all need the pickers |
**Bugs fixed on the way**: count posting dropped `BatchId` on batch lines; the count's serial snapshot
wasn't warehouse-scoped; the GRN form never sent a batch expiry date (FEFO had nothing to sort on).
**New error codes**: `SERIAL_DUPLICATE`, `SERIAL_NOT_AVAILABLE`, `TRACKING_REQUIRED`.
**Frontend**: GRN "Serial / warranty numbers" tab (serial items: scan/paste, no auto-generated
numbers) + batch expiry input; GRN detail shows batch/serials; `LineTrackingPicker` on sales invoice
(new + edit), transfer and adjustment forms; serial ticking on sales and purchase return forms; new
pages Stock → Serials, Stock → Batch Expiry, Sales → Warranties (sidebar reuses existing nav codes).
**Auto-generated serial numbers (GRN)**: the serial tab has *Prefix* (default `SKU-`) + *Auto-generate*.
`GET /tracking/serials/next-numbers?itemId&prefix&count` continues the highest number already used
under that prefix for the item (received serials and draft-GRN numbers), zero-padded to ≥ 4 digits;
the form also skips numbers already typed on it and fills **only empty units**, so scanned
manufacturer serials are never overwritten. Nothing is reserved — the GRN's own uniqueness check
on save and confirm remains the guard.
## 8. Serial numbers on/off switch
**Products → Configuration → Serial numbers** (`ProductConfig.SerialNumbersEnabled`) now governs
serial tracking at runtime, not just item creation. Read through `ISerialTrackingService.IsEnabledAsync`
(backend) and `GET /tracking/settings` + `useTrackingSettings()` (frontend; any logged-in user).
| | On | Off |
|---|---|---|
| GRN | one serial per unit required | no serials; sending some → `CONFIG_DISABLED`; serial+warranty items take warranty numbers again |
| Sales / returns / transfers / adjustments / counts | serials required (D6 exception) | serial items behave as untracked stock; serials refused with `CONFIG_DISABLED` |
| Stock issue (FIFO) | serial layers only leave by naming the serial | serial layers issued oldest-first like any stock; their serials become **`IssuedUntracked`** (purchase return of a rejected line → `ReturnedToVendor`) |
| Item edit | — | items already on Serial/Batch can still be edited (only switching *to* a disabled mode is refused) |
| UI | pickers, GRN serial tab, Serials page | hidden; Serials page (if opened) shows a notice; history kept |
No schema change (`IssuedUntracked` is a new string value). Switching back on resumes tracking for
stock received from then on; units issued while off are not re-identified.
## 7. Still open
- **Apply the migration** (next backend start, or `dotnet ef database update`) — the dev connection
string points at the shared remote MariaDB.
- **Bundle sales and free issues** have no serial picker: a serial item there can only post from
pre-tracking stock, otherwise it fails with `TRACKING_REQUIRED`.
- **Transfer receive UI** sends qty only: receiving *all* outstanding serials works; a *partial*
receive of a serial line needs `serialIds` (API supports it, the screen doesn't).
- **Non-serial warranty items**: warranty registered per sales line by quantity; choosing which GRN
warranty numbers leave (Phase 5 picker) is not built.
- **Expiring-batch notifications** (`NotificationModuleCatalog`) not wired; the Batch Expiry page covers it manually.
- **Tests**: FIFO/FEFO and posting paths use MariaDB-only SQL, so they are not unit-tested — needs an
e2e/integration pass (Testing/e2e) on a real database: GRN serials → sell → return → transfer → count.
- Production of tracked finished goods — unchanged (still blocked), out of scope.