serial numbers
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Stock;
|
||||
using ERPCore.System.Errors;
|
||||
using ERPCore.Tests.TestSupport;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO
|
||||
/// SQL: the sales-line check (<see cref="SalesDomainService.ValidateLineTrackingAsync"/>) and the
|
||||
/// shared stock-document rules (<see cref="SerialTrackingService"/>).
|
||||
/// </summary>
|
||||
public class ItemTrackingTests
|
||||
{
|
||||
private static readonly InMemoryDatabaseRoot Root = new();
|
||||
|
||||
private const int Wh1 = 1;
|
||||
private const int Wh2 = 2;
|
||||
private const int SerialItemId = 10;
|
||||
private const int BatchItemId = 20;
|
||||
private const int PlainItemId = 30;
|
||||
|
||||
// --- sales line validation --------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SerialItem_RequiresOneSerialPerUnit()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SerialItem_WithoutSerials_IsRejected_WhenNoPreTrackingStockIsLeft()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.TrackingRequired, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SerialItem_WithoutSerials_IsAllowed_FromPreTrackingStock()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
// D6: stock received before serial capture — a layer with no serial.
|
||||
db.StockLayers.Add(new StockLayer { ItemId = SerialItemId, WarehouseId = Wh1, QtyReceived = 3, QtyRemaining = 3, UnitCost = 10, ReceiptDate = DateTime.UtcNow });
|
||||
await db.SaveChangesAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, null, new HashSet<int>());
|
||||
|
||||
Assert.Empty(serials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_Serial_AtAnotherWarehouse_IsNotAvailable()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
// Serial 3 is in stock at warehouse 2.
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SoldSerial_IsNotAvailable()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_SameSerialOnTwoLines_IsADuplicate()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
var documentSerials = new HashSet<int>();
|
||||
|
||||
await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials);
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials));
|
||||
|
||||
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_ValidSerials_AreReturned()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1, 2 }, new HashSet<int>());
|
||||
|
||||
Assert.Equal(new[] { 1, 2 }, serials.Select(s => s.SerialId).OrderBy(i => i));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_UntrackedItem_RejectsSerials()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_ExpiredBatch_IsBlocked()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.ExpiredBatchBlocked, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SalesLine_BatchOfAnotherItem_IsRejected()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet<int>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
// --- stock documents (transfers / adjustments / purchase returns) ------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Outbound_DamagedSerial_OnlyWhenAllowed()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
// Serial 5 is Damaged at warehouse 1: a transfer (InStock only) refuses it...
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveOutboundAsync(
|
||||
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock }));
|
||||
Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code);
|
||||
|
||||
// ...a write-off or purchase return (InStock or Damaged) takes it.
|
||||
var serials = await tracking.ResolveOutboundAsync(
|
||||
item, Wh1, 1, new[] { 5 }, new HashSet<int>(), new[] { SerialStatus.InStock, SerialStatus.Damaged });
|
||||
Assert.Single(serials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inbound_SerialAlreadyInStock_IsADuplicate()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
|
||||
item, Wh1, 1, new[] { "SN-1" }, new HashSet<string>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inbound_FractionalQty_IsRejected_ForSerialItems()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.ResolveInboundAsync(
|
||||
item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet<string>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inbound_NewAndSoldSerials_AreCreatedOrBroughtBack()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
// SN-4 is Sold (out of stock) — it comes back; SN-NEW does not exist — it is created.
|
||||
var serials = await tracking.ResolveInboundAsync(item, Wh1, 2, new[] { "SN-4", "SN-NEW" }, new HashSet<string>());
|
||||
await tracking.MoveAsync(serials, SerialStatus.InStock, Wh1, null, "ADJ", 1, DateTime.UtcNow);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var all = await db.Serials.Where(s => s.ItemId == SerialItemId && (s.SerialNo == "SN-4" || s.SerialNo == "SN-NEW")).ToListAsync();
|
||||
Assert.All(all, s => Assert.Equal(SerialStatus.InStock, s.Status));
|
||||
Assert.All(all, s => Assert.Equal(Wh1, s.WarehouseId));
|
||||
Assert.Equal(2, await db.SerialMovements.CountAsync(m => m.SourceDocType == "ADJ"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Move_RecordsHistory_WithPreviousWarehouse()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var serial = await db.Serials.FirstAsync(s => s.SerialId == 1);
|
||||
|
||||
await tracking.MoveAsync(new[] { serial }, SerialStatus.InTransit, null, null, "TRF", 7, DateTime.UtcNow);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var movement = await db.SerialMovements.SingleAsync(m => m.SerialId == 1);
|
||||
Assert.Equal(SerialStatus.InTransit, movement.Status);
|
||||
Assert.Equal(Wh1, movement.FromWarehouseId);
|
||||
Assert.Null(movement.WarehouseId);
|
||||
Assert.Null(serial.WarehouseId);
|
||||
}
|
||||
|
||||
// --- auto-generated serial numbers -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task NextNumbers_ContinueAfterHighestUsed_AndSkipDraftGrnNumbers()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Serials.Add(new Serial { SerialId = 50, ItemId = SerialItemId, SerialNo = "PH-0041", Status = SerialStatus.Sold });
|
||||
// A draft GRN line already holds PH-0042 (not yet a Serial).
|
||||
db.GrnLines.Add(new GrnLine { GrnLineId = 900, GrnId = 1, ItemId = SerialItemId, Qty = 1,
|
||||
Serials = { new GrnLineSerial { SerialNo = "PH-0042" } } });
|
||||
await db.SaveChangesAsync();
|
||||
var tracking = new ItemTrackingService(db, null!);
|
||||
|
||||
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "PH-", 3);
|
||||
|
||||
Assert.Equal(new[] { "PH-0043", "PH-0044", "PH-0045" }, next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NextNumbers_StartAtOne_ForANewPrefix_AndIgnoreOtherItems()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
db.Serials.Add(new Serial { SerialId = 51, ItemId = PlainItemId, SerialNo = "NEW-0099", Status = SerialStatus.InStock });
|
||||
await db.SaveChangesAsync();
|
||||
var tracking = new ItemTrackingService(db, null!);
|
||||
|
||||
var next = await tracking.NextSerialNumbersAsync(SerialItemId, "NEW-", 2);
|
||||
|
||||
Assert.Equal(new[] { "NEW-0001", "NEW-0002" }, next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NextNumbers_RejectsTooMany()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
var tracking = new ItemTrackingService(db, null!);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<DomainException>(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000));
|
||||
|
||||
Assert.Equal(ErrorCodes.Validation, ex.Code);
|
||||
}
|
||||
|
||||
// --- fixtures ---------------------------------------------------------------------------
|
||||
|
||||
private static async Task<ErpDbContext> SeedAsync()
|
||||
{
|
||||
var db = CreateContext();
|
||||
db.Items.AddRange(
|
||||
new Item { ItemId = SerialItemId, Sku = "SER-1", Name = "Phone", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Serial },
|
||||
new Item { ItemId = BatchItemId, Sku = "BAT-1", Name = "Milk", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Batch },
|
||||
new Item { ItemId = PlainItemId, Sku = "PLN-1", Name = "Bolt", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None });
|
||||
db.Batches.AddRange(
|
||||
new Batch { BatchId = 100, ItemId = BatchItemId, BatchNo = "B-OK", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30) },
|
||||
new Batch { BatchId = 101, ItemId = BatchItemId, BatchNo = "B-EXP", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1) });
|
||||
db.Serials.AddRange(
|
||||
new Serial { SerialId = 1, ItemId = SerialItemId, SerialNo = "SN-1", Status = SerialStatus.InStock, WarehouseId = Wh1 },
|
||||
new Serial { SerialId = 2, ItemId = SerialItemId, SerialNo = "SN-2", Status = SerialStatus.InStock, WarehouseId = Wh1 },
|
||||
new Serial { SerialId = 3, ItemId = SerialItemId, SerialNo = "SN-3", Status = SerialStatus.InStock, WarehouseId = Wh2 },
|
||||
new Serial { SerialId = 4, ItemId = SerialItemId, SerialNo = "SN-4", Status = SerialStatus.Sold, WarehouseId = null },
|
||||
new Serial { SerialId = 5, ItemId = SerialItemId, SerialNo = "SN-5", Status = SerialStatus.Damaged, WarehouseId = Wh1 });
|
||||
await db.SaveChangesAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static ErpDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ErpDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root)
|
||||
.Options;
|
||||
var currentUser = new TestCurrentUser();
|
||||
return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser));
|
||||
}
|
||||
|
||||
// Pricing is never touched by the tracking check, so it is not wired.
|
||||
private static SalesDomainService CreateSalesDomain(ErpDbContext db) => new(
|
||||
new Repository<Customer>(db), new Repository<Warehouse>(db), new Repository<User>(db),
|
||||
new Repository<Item>(db), new Repository<TaxRate>(db), null!, new Repository<SalesConfig>(db),
|
||||
new Repository<Batch>(db), new Repository<Serial>(db), new Repository<StockLayer>(db),
|
||||
CreateSerialTracking(db));
|
||||
|
||||
private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new(
|
||||
new Repository<Serial>(db), new Repository<SerialMovement>(db), new Repository<StockLayer>(db),
|
||||
new Repository<ProductConfig>(db), new TestCurrentUser());
|
||||
|
||||
private static async Task SwitchSerialNumbersAsync(ErpDbContext db, bool enabled)
|
||||
{
|
||||
db.ProductConfig.Add(new ProductConfig { ConfigId = ProductConfig.SingletonId, SerialNumbersEnabled = enabled });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// --- Product Configuration "Serial numbers" switch ---------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOff_SerialItemSells_WithoutSerials()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
await SwitchSerialNumbersAsync(db, enabled: false);
|
||||
var sales = CreateSalesDomain(db);
|
||||
|
||||
// No pre-tracking stock exists, yet nothing is asked: the item is plain stock now.
|
||||
var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 5, null, null, new HashSet<int>());
|
||||
|
||||
Assert.Empty(serials);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOff_SerialsSent_AreRefused()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
await SwitchSerialNumbersAsync(db, enabled: false);
|
||||
var sales = CreateSalesDomain(db);
|
||||
var tracking = CreateSerialTracking(db);
|
||||
var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId);
|
||||
|
||||
var sale = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet<int>()));
|
||||
var adjustment = await Assert.ThrowsAsync<DomainException>(() =>
|
||||
tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet<string>()));
|
||||
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, sale.Code);
|
||||
Assert.Equal(ErrorCodes.ConfigDisabled, adjustment.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SwitchOn_IsTheDefault_WhenConfigRowIsMissing()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
Assert.True(await CreateSerialTracking(db).IsEnabledAsync());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,26 @@ public sealed class StockController : ApiControllerBase
|
||||
{
|
||||
private readonly IStockService _stock;
|
||||
private readonly IReorderService _reorder;
|
||||
private readonly IItemTrackingService _tracking;
|
||||
|
||||
public StockController(IStockService stock, IReorderService reorder)
|
||||
public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking)
|
||||
{
|
||||
_stock = stock;
|
||||
_reorder = reorder;
|
||||
_tracking = tracking;
|
||||
}
|
||||
|
||||
/// <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")]
|
||||
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
|
||||
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.
|
||||
/// </summary>
|
||||
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 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 BaseCost { get; set; }
|
||||
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>
|
||||
/// 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>
|
||||
public class SalesReturnLine
|
||||
{
|
||||
@@ -18,4 +20,21 @@ public class SalesReturnLine
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
public int? BatchId { get; set; }
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
public ICollection<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 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 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 BaseCost { get; set; }
|
||||
public string PriceSource { get; set; } = string.Empty;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public class Serial
|
||||
{
|
||||
@@ -12,8 +17,33 @@ public class Serial
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string SerialNo { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "InStock";
|
||||
public SerialStatus Status { get; set; } = SerialStatus.InStock;
|
||||
|
||||
/// <summary>Optional scan code for barcode/RFID lookup (unique when set).</summary>
|
||||
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? BatchId { get; set; }
|
||||
public int? SerialId { get; set; }
|
||||
public Serial? Serial { 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>
|
||||
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
|
||||
}
|
||||
@@ -7,12 +7,16 @@ namespace ERPCore.Dtos.Grn;
|
||||
|
||||
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(
|
||||
int GrnLineId, int? PoLineId, int ItemId, int? BinId,
|
||||
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
|
||||
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
|
||||
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
|
||||
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers);
|
||||
HoldStatus HoldStatus, int? BatchId, IReadOnlyList<GrnLineWarrantyNumberDto> WarrantyNumbers,
|
||||
string? BatchNo, DateOnly? BatchExpiryDate, IReadOnlyList<GrnLineSerialDto> Serials);
|
||||
|
||||
public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status,
|
||||
@@ -70,9 +74,15 @@ public sealed class CreateGrnLineInput
|
||||
public BatchInput? Batch { get; set; }
|
||||
/// <summary>
|
||||
/// 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>
|
||||
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
|
||||
|
||||
@@ -25,6 +25,12 @@ public sealed class CreatePurchaseReturnLineInput
|
||||
public int? GrnLineId { get; set; }
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
/// <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
|
||||
|
||||
@@ -7,7 +7,11 @@ public sealed record SalesInvoiceLineDto(
|
||||
int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty,
|
||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId);
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId,
|
||||
int? BatchId, IReadOnlyList<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(
|
||||
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
|
||||
@@ -95,6 +99,13 @@ public sealed class CreateSalesInvoiceLineRequest
|
||||
[Range(0, 100)] public decimal? TaxPct { get; set; }
|
||||
public bool IsFreeIssue { get; set; }
|
||||
public int? ParentLineId { get; set; }
|
||||
/// <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
|
||||
|
||||
@@ -5,7 +5,17 @@ namespace ERPCore.Dtos.Sales;
|
||||
|
||||
// Responses -----------------------------------------------------------------
|
||||
|
||||
public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty);
|
||||
public sealed record SalesReturnLineDto(
|
||||
int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty,
|
||||
int? BatchId, IReadOnlyList<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(
|
||||
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>
|
||||
/// is never mutated by a return, so this is computed on read from return history.
|
||||
/// </summary>
|
||||
public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty);
|
||||
public sealed record SalesInvoiceLineRemainingDto(
|
||||
int SalesInvoiceLineId, decimal RemainingQty, IReadOnlyList<SalesLineAllocationDto> Allocations);
|
||||
|
||||
// Requests --------------------------------------------------------------------
|
||||
|
||||
@@ -32,6 +43,16 @@ public sealed class CreateSalesReturnLineInput
|
||||
public int? SalesInvoiceLineId { get; set; }
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
/// <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
|
||||
@@ -40,5 +61,10 @@ public sealed class CreateSalesReturnRequest
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ public sealed record SalesSlipLineDto(
|
||||
int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct,
|
||||
decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal,
|
||||
decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId,
|
||||
int? BatchId, int? SerialId);
|
||||
int? BatchId, int? SerialId, IReadOnlyList<SalesLineSerialDto> Serials);
|
||||
|
||||
public sealed record SalesSlipTotalsDto(
|
||||
decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal,
|
||||
@@ -62,8 +62,13 @@ public sealed class CreateSalesSlipLineRequest
|
||||
public int? ParentLineId { get; set; }
|
||||
/// <summary>Pins the line to a specific batch (must belong to <see cref="ItemId"/>), consumed via FIFO.</summary>
|
||||
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; }
|
||||
/// <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
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Responses (docs/11 §5.5) ------------------------------------------------------
|
||||
|
||||
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
|
||||
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta, int? SerialId);
|
||||
|
||||
public sealed record AdjustmentDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
@@ -26,6 +26,16 @@ public sealed class CreateAdjustmentLineInput
|
||||
public int? BatchId { get; set; }
|
||||
/// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary>
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
@@ -6,7 +6,11 @@ namespace ERPCore.Dtos.Stock;
|
||||
// Responses (docs/11 §5.4) ------------------------------------------------------
|
||||
|
||||
public sealed record TransferLineDto(
|
||||
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
|
||||
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived,
|
||||
IReadOnlyList<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(
|
||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId,
|
||||
@@ -34,8 +38,11 @@ public sealed class CreateTransferLineInput
|
||||
[Required] public int ItemId { get; set; }
|
||||
public int? SrcBinId { get; set; }
|
||||
public int? DestBinId { get; set; }
|
||||
/// <summary>Batch to move. Omitted for a batch-tracked item, the line is split FEFO across batches.</summary>
|
||||
public int? BatchId { 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
|
||||
@@ -52,6 +59,11 @@ public sealed class ReceiveTransferLineInput
|
||||
{
|
||||
[Required] public int TransferLineId { 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
|
||||
|
||||
@@ -34,7 +34,9 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
|
||||
builder.HasKey(s => s.SerialId);
|
||||
|
||||
builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100);
|
||||
builder.Property(s => s.Status).IsRequired().HasMaxLength(20);
|
||||
// String-stored enum: rows written as "InStock"/"Sold" before SerialStatus existed
|
||||
// read back unchanged.
|
||||
builder.Property(s => s.Status).HasConversion<string>().IsRequired().HasMaxLength(20);
|
||||
|
||||
builder.Property(s => s.Barcode).HasMaxLength(100);
|
||||
builder.HasIndex(s => s.Barcode).IsUnique();
|
||||
@@ -43,7 +45,12 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration<Serial>
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.ItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(s => s.Warehouse).WithMany().HasForeignKey(s => s.WarehouseId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(s => s.Bin).WithMany().HasForeignKey(s => s.BinId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(s => s.GrnLine).WithMany().HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique();
|
||||
builder.HasIndex(s => new { s.ItemId, s.Status });
|
||||
builder.HasIndex(s => new { s.WarehouseId, s.ItemId, s.Status });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,3 +74,23 @@ public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguratio
|
||||
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)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(x => x.Batch)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.BatchId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
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.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SalesReturnLineSerialConfiguration : IEntityTypeConfiguration<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<Bin>().WithMany().HasForeignKey(l => l.BinId).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) ---
|
||||
public DbSet<Batch> Batches => Set<Batch>();
|
||||
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) ---
|
||||
public DbSet<StockLayer> StockLayers => Set<StockLayer>();
|
||||
|
||||
+9481
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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("GrnLineWarrantyNumberId")
|
||||
@@ -4740,6 +4769,9 @@ namespace ERPCore.Migrations
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("decimal(18,4)");
|
||||
|
||||
b.Property<int?>("BatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -4810,6 +4842,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("SalesInvoiceLineId");
|
||||
|
||||
b.HasIndex("BatchId");
|
||||
|
||||
b.HasIndex("ItemId");
|
||||
|
||||
b.HasIndex("SalesInvoiceId");
|
||||
@@ -4819,6 +4853,30 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("sales_invoice_lines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b =>
|
||||
{
|
||||
b.Property<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 =>
|
||||
{
|
||||
b.Property<int>("SalesInvoicePaymentId")
|
||||
@@ -4868,6 +4926,63 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("ReturnId")
|
||||
@@ -4932,6 +5047,9 @@ namespace ERPCore.Migrations
|
||||
|
||||
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("ReturnLineId"));
|
||||
|
||||
b.Property<int?>("BatchId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("ItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4947,6 +5065,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
b.HasKey("ReturnLineId");
|
||||
|
||||
b.HasIndex("BatchId");
|
||||
|
||||
b.HasIndex("ItemId");
|
||||
|
||||
b.HasIndex("ReturnId");
|
||||
@@ -4956,6 +5076,30 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("sales_return_lines", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b =>
|
||||
{
|
||||
b.Property<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 =>
|
||||
{
|
||||
b.Property<int>("SalesSlipId")
|
||||
@@ -5151,6 +5295,30 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("SerialId")
|
||||
@@ -5163,9 +5331,25 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(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")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("ReceivedAt")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<uint>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("int unsigned");
|
||||
|
||||
b.Property<string>("SerialNo")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@@ -5176,17 +5360,79 @@ namespace ERPCore.Migrations
|
||||
.HasMaxLength(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.HasIndex("Barcode")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("BinId");
|
||||
|
||||
b.HasIndex("GrnLineId");
|
||||
|
||||
b.HasIndex("ItemId", "SerialNo")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ItemId", "Status");
|
||||
|
||||
b.HasIndex("WarehouseId", "ItemId", "Status");
|
||||
|
||||
b.ToTable("serials", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b =>
|
||||
{
|
||||
b.Property<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 =>
|
||||
{
|
||||
b.Property<int>("EdgeId")
|
||||
@@ -5744,6 +5990,42 @@ namespace ERPCore.Migrations
|
||||
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 =>
|
||||
{
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("WorkShiftId")
|
||||
@@ -7306,6 +7659,24 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("PoLine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
|
||||
.WithMany("Serials")
|
||||
.HasForeignKey("GrnLineId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("GrnLine");
|
||||
|
||||
b.Navigation("Serial");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
|
||||
@@ -8143,6 +8514,11 @@ namespace ERPCore.Migrations
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
||||
.WithMany()
|
||||
.HasForeignKey("BatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
@@ -8161,6 +8537,8 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Batch");
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("SalesInvoice");
|
||||
@@ -8168,6 +8546,25 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine")
|
||||
.WithMany("Serials")
|
||||
.HasForeignKey("SalesInvoiceLineId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SalesInvoiceLine");
|
||||
|
||||
b.Navigation("Serial");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
@@ -8187,6 +8584,23 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("SalesInvoice");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
||||
.WithMany()
|
||||
.HasForeignKey("BatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Batch");
|
||||
|
||||
b.Navigation("Serial");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "Creator")
|
||||
@@ -8224,6 +8638,11 @@ namespace ERPCore.Migrations
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Batch", "Batch")
|
||||
.WithMany()
|
||||
.HasForeignKey("BatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
@@ -8241,6 +8660,8 @@ namespace ERPCore.Migrations
|
||||
.HasForeignKey("SalesInvoiceLineId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Batch");
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("Return");
|
||||
@@ -8248,6 +8669,25 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("SalesInvoiceLine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesReturnLine", "ReturnLine")
|
||||
.WithMany("Serials")
|
||||
.HasForeignKey("ReturnLineId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ReturnLine");
|
||||
|
||||
b.Navigation("Serial");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "CashierUser")
|
||||
@@ -8323,15 +8763,66 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.SalesSlipLine", "SalesSlipLine")
|
||||
.WithMany("Serials")
|
||||
.HasForeignKey("SalesSlipLineId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SalesSlipLine");
|
||||
|
||||
b.Navigation("Serial");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Bin", "Bin")
|
||||
.WithMany()
|
||||
.HasForeignKey("BinId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine")
|
||||
.WithMany()
|
||||
.HasForeignKey("GrnLineId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse")
|
||||
.WithMany()
|
||||
.HasForeignKey("WarehouseId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Bin");
|
||||
|
||||
b.Navigation("GrnLine");
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("Warehouse");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Serial");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b =>
|
||||
@@ -8470,7 +8961,7 @@ namespace ERPCore.Migrations
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", null)
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
@@ -8478,6 +8969,8 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Adjustment");
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("Serial");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b =>
|
||||
@@ -8695,6 +9188,30 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Transfer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Batch", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BatchId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.StockTransferLine", "TransferLine")
|
||||
.WithMany("Serials")
|
||||
.HasForeignKey("TransferLineId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Serial");
|
||||
|
||||
b.Navigation("TransferLine");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Category", "Category")
|
||||
@@ -8809,6 +9326,32 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Branch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Item", "Item")
|
||||
.WithMany()
|
||||
.HasForeignKey("ItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ERPCore.Domain.Entities.Serial", "Serial")
|
||||
.WithMany()
|
||||
.HasForeignKey("SerialId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("Item");
|
||||
|
||||
b.Navigation("Serial");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
@@ -8841,6 +9384,8 @@ namespace ERPCore.Migrations
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
|
||||
{
|
||||
b.Navigation("Serials");
|
||||
|
||||
b.Navigation("WarrantyNumbers");
|
||||
});
|
||||
|
||||
@@ -8944,16 +9489,31 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b =>
|
||||
{
|
||||
b.Navigation("Serials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b =>
|
||||
{
|
||||
b.Navigation("Serials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b =>
|
||||
{
|
||||
b.Navigation("Serials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
@@ -8969,6 +9529,11 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b =>
|
||||
{
|
||||
b.Navigation("Serials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
|
||||
@@ -166,6 +166,8 @@ builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
|
||||
|
||||
// Stock transactions + reference data (docs/11 §5–6)
|
||||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||||
builder.Services.AddScoped<ISerialTrackingService, SerialTrackingService>();
|
||||
builder.Services.AddScoped<IItemTrackingService, ItemTrackingService>();
|
||||
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
||||
builder.Services.AddScoped<IAdjustmentService, AdjustmentService>();
|
||||
builder.Services.AddScoped<ITransferService, TransferService>();
|
||||
|
||||
@@ -36,6 +36,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly ISerialTrackingService _serialTracking;
|
||||
private readonly IGeneralLedgerService _gl;
|
||||
private readonly IGlAccountConfigService _glAccounts;
|
||||
|
||||
@@ -45,6 +46,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
|
||||
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_serialTracking = serialTracking;
|
||||
_adjustments = adjustments;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
@@ -155,17 +157,24 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
if (reason.Context != ReasonContext.Adjustment)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
|
||||
|
||||
// Serials per line: outbound ones are validated as on hand; inbound ones are created (or
|
||||
// re-found) in the context now and get their ids on the flush below.
|
||||
var serialsByLine = new List<IReadOnlyList<Serial>>();
|
||||
var documentSerials = new HashSet<int>();
|
||||
var documentSerialNos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (line.QtyDelta == 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422);
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
serialsByLine.Add(line.QtyDelta < 0
|
||||
? await _serialTracking.ResolveOutboundAsync(item, request.WarehouseId, -line.QtyDelta, line.SerialIds,
|
||||
documentSerials, new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct)
|
||||
: await _serialTracking.ResolveInboundAsync(item, request.WarehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct));
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var deltas = request.Lines
|
||||
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList();
|
||||
|
||||
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
@@ -179,19 +188,31 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
Status = AdjustmentStatus.Posted,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = now,
|
||||
Lines = request.Lines.Select(l => new StockAdjustmentLine
|
||||
Lines = request.Lines.Select((l, i) => new StockAdjustmentLine
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
BinId = l.BinId,
|
||||
BatchId = l.BatchId,
|
||||
QtyDelta = l.QtyDelta
|
||||
QtyDelta = l.QtyDelta,
|
||||
Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null
|
||||
}).ToList()
|
||||
};
|
||||
await _adjustments.AddAsync(entity, token);
|
||||
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
|
||||
await _uow.SaveChangesAsync(token); // flush so AdjustmentId (and new SerialIds) are valid
|
||||
|
||||
var deltas = request.Lines.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta,
|
||||
serialsByLine[i].Select(s => s.SerialId).ToList())).ToList();
|
||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
|
||||
|
||||
for (var i = 0; i < request.Lines.Count; i++)
|
||||
{
|
||||
var line = request.Lines[i];
|
||||
await _serialTracking.MoveAsync(serialsByLine[i],
|
||||
line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock,
|
||||
line.QtyDelta < 0 ? null : request.WarehouseId, line.QtyDelta < 0 ? null : line.BinId,
|
||||
DocumentTypes.Adjustment, entity.AdjustmentId, now, token);
|
||||
}
|
||||
|
||||
entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
|
||||
if (entity.GlJournalNo is not null) entity.GlPostedAt = now;
|
||||
|
||||
@@ -207,6 +228,6 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
|
||||
a.GlJournalNo, a.GlPostedAt,
|
||||
a.Lines.OrderBy(l => l.AdjLineId)
|
||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
|
||||
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId)).ToList(),
|
||||
ledgerRefs);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ public sealed class CountService : ICountService
|
||||
IGeneralLedgerService gl, IRepository<StockLayer> stockLayers, IRepository<Batch> batches,
|
||||
IRepository<Serial> serials, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_serialTracking = serialTracking;
|
||||
_counts = counts;
|
||||
_adjustments = adjustments;
|
||||
_warehouses = warehouses;
|
||||
@@ -171,17 +172,32 @@ public sealed class CountService : ICountService
|
||||
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty });
|
||||
}
|
||||
// Stock that reached the item without a batch (e.g. an adjustment) is counted on its own line.
|
||||
var unbatched = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == null)
|
||||
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
if (unbatched != 0)
|
||||
lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = unbatched });
|
||||
}
|
||||
else if (item.TrackingMode == TrackingMode.Serial)
|
||||
else if (await _serialTracking.IsSerialTrackedAsync(item, ct)) // off: counted as one aggregate line
|
||||
{
|
||||
// Serials that should be at this warehouse (one line each, counted 0 or 1), plus
|
||||
// one aggregate line for any pre-tracking serial-less stock still here (D6).
|
||||
var serials = await _serials.Query().AsNoTracking()
|
||||
.Where(s => s.ItemId == id && s.Status == "InStock").ToListAsync(ct);
|
||||
.Where(s => s.ItemId == id && s.WarehouseId == request.WarehouseId
|
||||
&& (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged))
|
||||
.OrderBy(s => s.SerialNo).ToListAsync(ct);
|
||||
foreach (var serial in serials)
|
||||
{
|
||||
var open = await _stockLayers.Query().AsNoTracking().AnyAsync(
|
||||
l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct);
|
||||
lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 });
|
||||
}
|
||||
var untracked = await _stockLayers.Query().AsNoTracking()
|
||||
.Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == null)
|
||||
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
if (untracked != 0 || serials.Count == 0)
|
||||
lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked });
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -225,6 +241,8 @@ public sealed class CountService : ICountService
|
||||
{
|
||||
var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422);
|
||||
if (line.SerialId is not null && input.CountedQty is not (0m or 1m))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422);
|
||||
line.CountedQty = input.CountedQty;
|
||||
line.Variance = input.CountedQty - line.SystemQty;
|
||||
}
|
||||
@@ -246,9 +264,11 @@ public sealed class CountService : ICountService
|
||||
if (count.Status != CountStatus.Counted)
|
||||
throw new ConflictException($"Count {countId} has no entered counts to post.");
|
||||
|
||||
var deltas = count.Lines
|
||||
.Where(l => l.Variance.HasValue && l.Variance.Value != 0)
|
||||
.Select(l => new StockDelta(l.ItemId, l.BinId, null, l.Variance!.Value))
|
||||
// Batch and serial lines adjust exactly their own lot/unit.
|
||||
var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList();
|
||||
var deltas = varianceLines
|
||||
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.Variance!.Value,
|
||||
l.SerialId is int serialId ? new List<int> { serialId } : null))
|
||||
.ToList();
|
||||
|
||||
// No variances → just close the count, no adjustment.
|
||||
@@ -280,6 +300,8 @@ public sealed class CountService : ICountService
|
||||
{
|
||||
ItemId = d.ItemId,
|
||||
BinId = d.BinId,
|
||||
BatchId = d.BatchId,
|
||||
SerialId = d.SerialIds?.FirstOrDefault(),
|
||||
QtyDelta = d.QtyDelta
|
||||
}).ToList()
|
||||
};
|
||||
@@ -288,6 +310,17 @@ public sealed class CountService : ICountService
|
||||
|
||||
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
|
||||
|
||||
// A serial counted missing is written off (Scrapped); one found keeps/regains InStock here.
|
||||
foreach (var line in varianceLines.Where(l => l.SerialId is not null))
|
||||
{
|
||||
var serial = await _serials.Query().FirstAsync(s => s.SerialId == line.SerialId, token);
|
||||
var found = line.Variance > 0;
|
||||
await _serialTracking.MoveAsync(new[] { serial },
|
||||
found ? SerialStatus.InStock : SerialStatus.Scrapped,
|
||||
found ? count.WarehouseId : null, found ? line.BinId : null,
|
||||
DocumentTypes.Count, count.CountId, now, token);
|
||||
}
|
||||
|
||||
adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token);
|
||||
if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now;
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ public sealed class GrnService : IGrnService
|
||||
private readonly IRepository<Bin> _bins;
|
||||
private readonly IRepository<Vendor> _vendors;
|
||||
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<StockLedger> _ledger;
|
||||
private readonly IRepository<TaxRate> _taxRates;
|
||||
@@ -50,7 +53,7 @@ public sealed class GrnService : IGrnService
|
||||
public GrnService(
|
||||
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
|
||||
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,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts)
|
||||
@@ -63,6 +66,9 @@ public sealed class GrnService : IGrnService
|
||||
_bins = bins;
|
||||
_vendors = vendors;
|
||||
_batches = batches;
|
||||
_serials = serials;
|
||||
_serialMovements = serialMovements;
|
||||
_serialTracking = serialTracking;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_taxRates = taxRates;
|
||||
@@ -110,6 +116,8 @@ public sealed class GrnService : IGrnService
|
||||
{
|
||||
var grn = await _grns.Query().AsNoTracking()
|
||||
.Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.Serials)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.Batch)
|
||||
.Include(g => g.PurchaseInvoice)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
|
||||
return grn is null ? null : Map(grn);
|
||||
@@ -155,6 +163,7 @@ public sealed class GrnService : IGrnService
|
||||
|
||||
var lines = new List<GrnLine>();
|
||||
var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>();
|
||||
var requestSerials = new HashSet<(int ItemId, string SerialNo)>();
|
||||
|
||||
foreach (var input in request.Lines)
|
||||
{
|
||||
@@ -203,7 +212,9 @@ public sealed class GrnService : IGrnService
|
||||
var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero);
|
||||
|
||||
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
|
||||
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty);
|
||||
var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty,
|
||||
serialTracked: await _serialTracking.IsSerialTrackedAsync(item, ct));
|
||||
var serials = await ResolveSerialNumbersAsync(item, input.SerialNumbers, input.Qty, requestSerials, ct);
|
||||
|
||||
lines.Add(new GrnLine
|
||||
{
|
||||
@@ -221,7 +232,8 @@ public sealed class GrnService : IGrnService
|
||||
ReceivedValue = receivedValue,
|
||||
LineTotal = receivedValue + vatAmount,
|
||||
HoldStatus = input.HoldStatus,
|
||||
WarrantyNumbers = warrantyNumbers
|
||||
WarrantyNumbers = warrantyNumbers,
|
||||
Serials = serials
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,7 +264,7 @@ public sealed class GrnService : IGrnService
|
||||
public async Task<GrnConfirmResultDto> ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default)
|
||||
{
|
||||
var grn = await _grns.Query()
|
||||
.Include(g => g.Lines)
|
||||
.Include(g => g.Lines).ThenInclude(l => l.Serials)
|
||||
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
|
||||
?? throw new NotFoundException($"GRN {grnId} was not found.");
|
||||
|
||||
@@ -278,10 +290,21 @@ public sealed class GrnService : IGrnService
|
||||
var qtyBase = line.Qty;
|
||||
var unitCostBase = line.NetUnitCost;
|
||||
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||
qtyBase, unitCostBase, now, token);
|
||||
createdLayers.Add(layer);
|
||||
if (line.Serials.Count > 0)
|
||||
{
|
||||
// Serial-tracked: one Serial and one qty-1 layer per unit, so a sale can later
|
||||
// consume exactly the serial it names. The ledger stays one row per line
|
||||
// (below): every ledger row also posts a GL entry, and per-unit rows would mean
|
||||
// one GL call per serial — serial history is read from the document tables.
|
||||
createdLayers.AddRange(await ReceiveSerialsAsync(grn, line, unitCostBase, now, token));
|
||||
}
|
||||
else
|
||||
{
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
|
||||
qtyBase, unitCostBase, now, token);
|
||||
createdLayers.Add(layer);
|
||||
}
|
||||
|
||||
var key = (line.ItemId, grn.WarehouseId);
|
||||
if (!runningBalance.TryGetValue(key, out var bal))
|
||||
@@ -411,12 +434,16 @@ public sealed class GrnService : IGrnService
|
||||
/// period is not entered at receipt; it is snapshotted from <see cref="Item.WarrantyPeriodMonths"/>,
|
||||
/// which the item must have been given at creation (<see cref="ItemService"/> enforces that).
|
||||
/// </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.WarrantyPeriodMonths is null)
|
||||
throw new DomainException(
|
||||
ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422);
|
||||
// A serial item's serial number is its warranty identity (docs/ITEM_TRACKING_PLAN.md D1);
|
||||
// its coverage is snapshotted onto each Serial at confirm instead. With serial numbers
|
||||
// switched off the item has no serials, so it takes warranty numbers like any other.
|
||||
if (serialTracked) return new List<GrnLineWarrantyNumber>();
|
||||
|
||||
var trimmed = (numbers ?? new List<string>()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList();
|
||||
if (trimmed.Count != qty)
|
||||
@@ -429,6 +456,88 @@ public sealed class GrnService : IGrnService
|
||||
return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList();
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (poId is null) return;
|
||||
@@ -472,5 +581,7 @@ public sealed class GrnService : IGrnService
|
||||
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
|
||||
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
|
||||
l.HoldStatus, l.BatchId,
|
||||
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList());
|
||||
l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList(),
|
||||
l.Batch?.BatchNo, l.Batch?.ExpiryDate,
|
||||
l.Serials.OrderBy(s => s.GrnLineSerialId).Select(s => new GrnLineSerialDto(s.SerialNo, s.SerialId)).ToList())).ToList());
|
||||
}
|
||||
|
||||
@@ -31,9 +31,26 @@ public interface IFifoCostingService
|
||||
/// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the
|
||||
/// quantity stock can't cover is recorded as a negative layer at the last known unit cost.
|
||||
/// </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(
|
||||
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>
|
||||
/// 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>
|
||||
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.Dtos.Sales;
|
||||
|
||||
@@ -63,6 +64,18 @@ public interface ISalesDomainService
|
||||
bool isFreeIssue);
|
||||
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A signed base-UOM change to an item's stock at one warehouse.</summary>
|
||||
public sealed record StockDelta(int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// Shared poster for stock-affecting documents (adjustments, count variances,
|
||||
|
||||
@@ -298,7 +298,7 @@ public sealed class ItemService : IItemService
|
||||
await ValidateReferencesAsync(
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct);
|
||||
ValidateFeatureConfiguration(request, config);
|
||||
ValidateFeatureConfiguration(request, config, item.TrackingMode);
|
||||
|
||||
// The base UOM is the sole meaning of every quantity recorded against this item —
|
||||
// stock layers, ledger rows and document lines are all plain counts of it. Once any
|
||||
@@ -490,15 +490,20 @@ public sealed class ItemService : IItemService
|
||||
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
|
||||
request.TrackingMode, request.Warranty, config);
|
||||
|
||||
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config)
|
||||
/// <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(
|
||||
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
|
||||
request.TrackingMode, request.Warranty, config);
|
||||
request.TrackingMode, request.Warranty, config, currentTrackingMode);
|
||||
|
||||
private static void ValidateFeatureConfiguration(
|
||||
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
|
||||
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config)
|
||||
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config, TrackingMode? currentTrackingMode = null)
|
||||
{
|
||||
var trackingUnchanged = trackingMode == currentTrackingMode;
|
||||
if (!config.SellingPricesEnabled &&
|
||||
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
|
||||
throw new DomainException(
|
||||
@@ -506,13 +511,13 @@ public sealed class ItemService : IItemService
|
||||
"Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.",
|
||||
422);
|
||||
|
||||
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial)
|
||||
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial && !trackingUnchanged)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.",
|
||||
422);
|
||||
|
||||
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch)
|
||||
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch && !trackingUnchanged)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Batch lists are disabled in the product configuration; trackingMode cannot be Batch.",
|
||||
@@ -620,7 +625,7 @@ public sealed class ItemService : IItemService
|
||||
|
||||
private static ItemScanResultDto ToScanResult(Serial s) => new(
|
||||
s.Item!.ItemId, s.Item.Sku, s.Item.Name, s.Item.TrackingMode, s.Item.BaseUomId, "Serial",
|
||||
null, null, null, s.SerialId, s.SerialNo, s.Status);
|
||||
null, null, null, s.SerialId, s.SerialNo, s.Status.ToString());
|
||||
|
||||
private static ItemDetailDto ToDetail(Item i) => new(
|
||||
i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId,
|
||||
|
||||
@@ -38,6 +38,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IStockMutator _mutator;
|
||||
private readonly IFifoCostingService _fifo;
|
||||
private readonly IRepository<Serial> _serials;
|
||||
private readonly ISerialTrackingService _serialTracking;
|
||||
private readonly INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly INotificationService _notifications;
|
||||
@@ -49,9 +51,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
|
||||
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IStockMutator mutator, IFifoCostingService fifo,
|
||||
IRepository<Serial> serials, ISerialTrackingService serialTracking,
|
||||
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
|
||||
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
_serials = serials;
|
||||
_serialTracking = serialTracking;
|
||||
_returns = returns;
|
||||
_vendors = vendors;
|
||||
_warehouses = warehouses;
|
||||
@@ -130,11 +135,24 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
// layer(s) below, not the generic mutator — track which by GrnLineId so the two
|
||||
// paths don't double-handle the same line.
|
||||
var rejectedGrnLineIds = new HashSet<int>();
|
||||
var documentSerials = new HashSet<int>();
|
||||
var serialsByLine = new Dictionary<CreatePurchaseReturnLineInput, IReadOnlyList<Serial>>();
|
||||
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
|
||||
// Rejected-line serials are checked against that line's own layers below; anything
|
||||
// else must name serials actually on hand (D6: or leave only pre-tracking stock).
|
||||
var isRejectedLine = line.GrnLineId is not null && await _grnLines.Query().AnyAsync(g => g.GrnLineId == line.GrnLineId && g.HoldStatus == HoldStatus.Rejected, ct);
|
||||
if (!isRejectedLine || line.SerialIds is { Count: > 0 })
|
||||
serialsByLine[line] = await _serialTracking.ResolveOutboundAsync(
|
||||
item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials,
|
||||
new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct);
|
||||
if (isRejectedLine && serialsByLine.TryGetValue(line, out var named) && named.Any(s => s.GrnLineId != line.GrnLineId))
|
||||
throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials were not received on GRN line {line.GrnLineId}.", 422);
|
||||
|
||||
if (line.GrnLineId is not null)
|
||||
{
|
||||
var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct)
|
||||
@@ -158,7 +176,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var deltas = request.Lines
|
||||
.Where(l => l.GrnLineId is null || !rejectedGrnLineIds.Contains(l.GrnLineId.Value))
|
||||
.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList();
|
||||
.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty,
|
||||
serialsByLine.TryGetValue(l, out var s) ? s.Select(x => x.SerialId).ToList() : null)).ToList();
|
||||
var rejectedLines = request.Lines.Where(l => l.GrnLineId is not null && rejectedGrnLineIds.Contains(l.GrnLineId.Value)).ToList();
|
||||
|
||||
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
@@ -196,10 +215,31 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||
.ToListAsync(token);
|
||||
|
||||
// A serial line's layers are one per unit: named serials take exactly theirs;
|
||||
// with none named, the whole remaining line may go back (every serial on it).
|
||||
var named = serialsByLine.TryGetValue(line, out var lineSerials) ? lineSerials : Array.Empty<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))
|
||||
bal = await _fifo.GetOnHandAsync(line.ItemId, request.WarehouseId, token);
|
||||
|
||||
var remaining = line.Qty;
|
||||
var takenSerialIds = new List<int>();
|
||||
foreach (var layer in layers)
|
||||
{
|
||||
if (remaining <= 0) break;
|
||||
@@ -207,6 +247,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
layer.QtyRemaining -= take;
|
||||
remaining -= take;
|
||||
bal -= take;
|
||||
if (layer.SerialId is int takenSerial && layer.QtyRemaining == 0) takenSerialIds.Add(takenSerial);
|
||||
|
||||
var entry = await _fifo.PostLedgerAsync(
|
||||
layer.ItemId, layer.WarehouseId, null, layer.BatchId, null, actor,
|
||||
@@ -214,8 +255,17 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
|
||||
refs.Add(entry);
|
||||
}
|
||||
balances[line.ItemId] = bal;
|
||||
|
||||
// Serial numbers switched off: serial units went back oldest-first without being
|
||||
// named — still record them as returned so the register doesn't show them in stock.
|
||||
if (named.Count == 0 && takenSerialIds.Count > 0)
|
||||
serialsByLine[line] = await _serials.Query().Where(s => takenSerialIds.Contains(s.SerialId)).ToListAsync(token);
|
||||
}
|
||||
|
||||
foreach (var serials in serialsByLine.Values)
|
||||
await _serialTracking.MoveAsync(serials, SerialStatus.ReturnedToVendor, null, null,
|
||||
DocumentTypes.PurchaseReturn, ret.ReturnId, now, token);
|
||||
|
||||
var totalValue = refs.Sum(r => r.Value);
|
||||
if (totalValue > 0)
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService
|
||||
|
||||
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)
|
||||
?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found.");
|
||||
if (invoice.RowVersion != expectedRowVersion)
|
||||
@@ -32,7 +32,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService
|
||||
|
||||
public async Task<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)
|
||||
?? throw new NotFoundException($"Sales slip {salesSlipId} was not found.");
|
||||
if (slip.RowVersion != expectedRowVersion)
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
private readonly IRepository<TaxRate> _taxRates;
|
||||
private readonly ISalesPricingService _pricing;
|
||||
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(
|
||||
IRepository<Customer> customers,
|
||||
@@ -24,8 +28,16 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
IRepository<Item> items,
|
||||
IRepository<TaxRate> taxRates,
|
||||
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;
|
||||
_warehouses = warehouses;
|
||||
_users = users;
|
||||
@@ -154,6 +166,66 @@ public sealed class SalesDomainService : ISalesDomainService
|
||||
.Select(x => x.StockNature == StockNature.Stocked)
|
||||
.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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
|
||||
.FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
|
||||
return invoice is null ? null : new ETagged<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)
|
||||
{
|
||||
await _posting.PostInvoiceAsync(salesInvoiceId, ct);
|
||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines)
|
||||
var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
|
||||
.FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct);
|
||||
return _mapping.MapInvoice(invoice);
|
||||
}
|
||||
|
||||
public async Task<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.");
|
||||
if (invoice.Status != SalesInvoiceStatus.Draft)
|
||||
throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled.");
|
||||
@@ -188,10 +188,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422);
|
||||
if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct))
|
||||
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422);
|
||||
var documentSerials = new HashSet<int>();
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, r.SerialIds, documentSerials, ct);
|
||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct);
|
||||
var unitPrice = resolved.UnitPrice;
|
||||
var priceSource = resolved.PriceSource;
|
||||
@@ -217,7 +219,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
TaxPct = taxPct,
|
||||
TaxAmount = calc.TaxAmount,
|
||||
IsFreeIssue = r.IsFreeIssue,
|
||||
ParentLineId = r.ParentLineId
|
||||
ParentLineId = r.ParentLineId,
|
||||
BatchId = r.BatchId,
|
||||
Serials = serials.Select(s => new SalesInvoiceLineSerial { SerialId = s.SerialId, Serial = s }).ToList()
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
|
||||
@@ -38,7 +38,8 @@ public sealed class SalesMappingService : ISalesMappingService
|
||||
invoice.Lines.Select(l => new SalesInvoiceLineDto(
|
||||
l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
||||
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList());
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId,
|
||||
l.BatchId, l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList());
|
||||
|
||||
public SalesSlipDto MapSlip(SalesSlip slip)
|
||||
=> new(
|
||||
@@ -49,5 +50,6 @@ public sealed class SalesMappingService : ISalesMappingService
|
||||
l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId,
|
||||
l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode,
|
||||
l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId,
|
||||
l.BatchId, l.SerialId)).ToList());
|
||||
l.BatchId, l.SerialId,
|
||||
l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList());
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
private readonly IRepository<BundleSale> _bundles;
|
||||
private readonly IRepository<Item> _items;
|
||||
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 ISalesDomainService _sales;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -34,6 +38,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
IRepository<BundleSale> bundles,
|
||||
IRepository<Item> items,
|
||||
IRepository<Serial> serials,
|
||||
IRepository<SalesLineAllocation> allocations,
|
||||
IRepository<WarrantyRegistration> warranties,
|
||||
IRepository<SerialMovement> serialMovements,
|
||||
ISerialTrackingService serialTracking,
|
||||
IFifoCostingService fifo,
|
||||
ISalesDomainService sales,
|
||||
ICurrentUser currentUser,
|
||||
@@ -46,6 +54,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
_bundles = bundles;
|
||||
_items = items;
|
||||
_serials = serials;
|
||||
_allocations = allocations;
|
||||
_warranties = warranties;
|
||||
_serialMovements = serialMovements;
|
||||
_serialTracking = serialTracking;
|
||||
_fifo = fifo;
|
||||
_sales = sales;
|
||||
_currentUser = currentUser;
|
||||
@@ -150,15 +162,17 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
|
||||
public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default)
|
||||
=> PostAsync(
|
||||
load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
|
||||
load: () => _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct),
|
||||
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, null, null)),
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.SalesInvoiceLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty,
|
||||
l.BatchId, l.Serials.Select(s => s.SerialId).ToList())),
|
||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.SalesInvoice,
|
||||
getDocId: x => x.SalesInvoiceId,
|
||||
getCustomerId: x => x.CustomerId,
|
||||
ct: ct,
|
||||
afterConsumption: PostInvoiceRevenueAsync);
|
||||
|
||||
@@ -208,15 +222,19 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
|
||||
public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default)
|
||||
=> PostAsync(
|
||||
load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
|
||||
load: () => _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct),
|
||||
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId, l.SerialId)),
|
||||
// An old draft may still carry only the legacy single SerialId.
|
||||
getLines: x => x.Lines.Select(l => new PostingLine(l.SalesSlipLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId,
|
||||
l.Serials.Count > 0 ? l.Serials.Select(s => s.SerialId).ToList()
|
||||
: l.SerialId is int single ? new List<int> { single } : new List<int>())),
|
||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.SalesSlip,
|
||||
getDocId: x => x.SalesSlipId,
|
||||
getCustomerId: x => x.CustomerId,
|
||||
ct: ct);
|
||||
|
||||
public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default)
|
||||
@@ -225,11 +243,12 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
||||
statusSelector: x => x.Status,
|
||||
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, null)),
|
||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.BundleSaleLineId, l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, new List<int>())),
|
||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||
sourceDocType: DocumentTypes.BundleSale,
|
||||
getDocId: x => x.BundleSaleId,
|
||||
getCustomerId: x => x.CustomerId,
|
||||
ct: ct);
|
||||
|
||||
private async Task PostAsync<T>(
|
||||
@@ -242,6 +261,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
Action<T> setUpdated,
|
||||
string sourceDocType,
|
||||
Func<T, int> getDocId,
|
||||
Func<T, int> getCustomerId,
|
||||
CancellationToken ct,
|
||||
/// <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.
|
||||
@@ -262,27 +282,65 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
var totalCogs = 0m;
|
||||
var now = DateTime.UtcNow;
|
||||
var docId = getDocId(doc);
|
||||
foreach (var line in getLines(doc))
|
||||
{
|
||||
if (line.Qty <= 0) continue;
|
||||
if (!await _sales.IsStockedItemAsync(line.ItemId, token))
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
|
||||
if (item.StockNature != StockNature.Stocked)
|
||||
continue;
|
||||
|
||||
var serials = await LoadSerialsForPostingAsync(item, line, token);
|
||||
|
||||
// Line quantities are already a count of the item's base UOM — sales documents
|
||||
// carry no unit of their own — so this is the quantity FIFO consumes verbatim.
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall);
|
||||
var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty);
|
||||
totalCogs += cost * line.Qty;
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId,
|
||||
Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token);
|
||||
// Serial lines consume exactly their serials' layers and can never go short; batch
|
||||
// lines with no pinned batch are picked FEFO (docs/ITEM_TRACKING_PLAN.md Phase 3).
|
||||
var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token,
|
||||
// With serial numbers switched off, a serial-mode item is plain stock and may go short.
|
||||
allowShortfall && serials.Count == 0 && !await _serialTracking.IsSerialTrackedAsync(item, token),
|
||||
serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null);
|
||||
totalCogs += consumed.Sum(x => x.Qty * x.UnitCost);
|
||||
|
||||
// ConsumeAsync has no serial parameter — closing the loop on a serial-pinned
|
||||
// line means flipping the serial's own status once its stock is actually gone.
|
||||
if (line.SerialId.HasValue)
|
||||
// One ledger row per batch actually issued (usually one) — a FEFO pick can span
|
||||
// several, and each ledger row must carry the batch it moved.
|
||||
foreach (var group in consumed.GroupBy(x => x.BatchId))
|
||||
{
|
||||
var serial = await _serials.Query().FirstOrDefaultAsync(s => s.SerialId == line.SerialId, token);
|
||||
if (serial is not null) serial.Status = "Sold";
|
||||
var qty = group.Sum(x => x.Qty);
|
||||
var cost = qty == 0 ? 0m : group.Sum(x => x.Qty * x.UnitCost) / qty;
|
||||
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, group.Key,
|
||||
serials.Count == 1 ? serials[0].SerialId : null, _currentUser.AuditUserId,
|
||||
Direction.Out, qty, cost, 0m, sourceDocType, docId, now, token);
|
||||
}
|
||||
|
||||
foreach (var segment in consumed)
|
||||
{
|
||||
await _allocations.AddAsync(new SalesLineAllocation
|
||||
{
|
||||
SourceDocType = sourceDocType,
|
||||
SourceDocId = docId,
|
||||
SourceLineId = line.LineId,
|
||||
ItemId = line.ItemId,
|
||||
WarehouseId = line.WarehouseId,
|
||||
BatchId = segment.BatchId,
|
||||
SerialId = segment.SerialId,
|
||||
Qty = segment.Qty,
|
||||
UnitCost = segment.UnitCost,
|
||||
CreatedAt = now
|
||||
}, token);
|
||||
}
|
||||
|
||||
foreach (var serial in serials)
|
||||
{
|
||||
serial.Status = SerialStatus.Sold;
|
||||
serial.WarehouseId = null;
|
||||
serial.BinId = null;
|
||||
serial.UpdatedAt = now;
|
||||
await _serialMovements.AddAsync(SerialMovement.Of(serial, line.WarehouseId, sourceDocType, docId, _currentUser.AuditUserId, now), token);
|
||||
}
|
||||
|
||||
await RegisterWarrantyAsync(item, line, serials, getCustomerId(doc), sourceDocType, docId, now, token);
|
||||
}
|
||||
|
||||
setPosted(doc);
|
||||
@@ -292,5 +350,78 @@ public sealed class SalesPostingService : ISalesPostingService
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty, int? BatchId, int? SerialId);
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -17,15 +17,24 @@ namespace ERPCore.Services;
|
||||
/// <summary>
|
||||
/// Sales-return service. Auto-posts with a mandatory Return reason code and
|
||||
/// 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
|
||||
/// 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
|
||||
/// aren't part of it) — reuses the Sales Day End accounts since that's the module
|
||||
/// 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>
|
||||
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<Customer> _customers;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
@@ -34,6 +43,12 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
private readonly IRepository<SalesInvoiceLine> _salesInvoiceLines;
|
||||
private readonly IRepository<SalesReturnLine> _returnLines;
|
||||
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 INumberSequenceService _numbers;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
@@ -44,7 +59,9 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
public SalesReturnService(
|
||||
IRepository<SalesReturn> returns, IRepository<Customer> customers, IRepository<Warehouse> warehouses,
|
||||
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,
|
||||
IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
|
||||
{
|
||||
@@ -56,6 +73,12 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
_salesInvoiceLines = salesInvoiceLines;
|
||||
_returnLines = returnLines;
|
||||
_ledger = ledger;
|
||||
_batches = batches;
|
||||
_serials = serials;
|
||||
_allocations = allocations;
|
||||
_warranties = warranties;
|
||||
_serialMovements = serialMovements;
|
||||
_serialTracking = serialTracking;
|
||||
_mutator = mutator;
|
||||
_numbers = numbers;
|
||||
_currentUser = currentUser;
|
||||
@@ -91,7 +114,7 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
public async Task<SalesReturnDto?> GetAsync(int returnId, CancellationToken ct = default)
|
||||
{
|
||||
var ret = await _returns.Query().AsNoTracking()
|
||||
.Include(r => r.Lines)
|
||||
.Include(r => r.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
|
||||
.FirstOrDefaultAsync(r => r.ReturnId == returnId, ct);
|
||||
if (ret is null) return null;
|
||||
|
||||
@@ -119,15 +142,19 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
|
||||
if (reason.Context != ReasonContext.Return)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422);
|
||||
var damaged = request.MarkDamaged ?? DamagedReasonCodes.Contains(reason.Code);
|
||||
|
||||
// Original sold qty is never mutated — "remaining returnable" is computed from
|
||||
// return history instead, so the invoice keeps recording what was actually sold.
|
||||
var pendingByInvoiceLine = new Dictionary<int, decimal>();
|
||||
var requestSerials = new HashSet<int>();
|
||||
var resolved = new List<ResolvedReturnLine>();
|
||||
|
||||
foreach (var line in request.Lines)
|
||||
{
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
var allocations = new List<SalesLineAllocation>();
|
||||
if (line.SalesInvoiceLineId is not null)
|
||||
{
|
||||
var invoiceLineId = line.SalesInvoiceLineId.Value;
|
||||
@@ -145,11 +172,20 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
if (line.Qty > remaining)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422);
|
||||
pendingByInvoiceLine[invoiceLineId] = pending + line.Qty;
|
||||
|
||||
// Tracked: QtyReturned is updated below, inside the transaction.
|
||||
allocations = await _allocations.Query()
|
||||
.Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceLineId == invoiceLineId)
|
||||
.OrderBy(a => a.SalesLineAllocationId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
resolved.Add(await ResolveTrackingAsync(item, line, allocations, requestSerials, ct));
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, l.Qty)).ToList();
|
||||
var deltas = resolved.Select(r => new StockDelta(
|
||||
r.Input.ItemId, null, r.BatchId, r.Input.Qty, r.Serials.Select(s => s.SerialId).ToList(), r.UnitCost)).ToList();
|
||||
|
||||
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
{
|
||||
@@ -163,11 +199,13 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
Status = ReturnStatus.Posted,
|
||||
CreatedBy = _currentUser.AuditUserId,
|
||||
CreatedAt = now,
|
||||
Lines = request.Lines.Select(l => new SalesReturnLine
|
||||
Lines = resolved.Select(r => new SalesReturnLine
|
||||
{
|
||||
SalesInvoiceLineId = l.SalesInvoiceLineId,
|
||||
ItemId = l.ItemId,
|
||||
Qty = l.Qty
|
||||
SalesInvoiceLineId = r.Input.SalesInvoiceLineId,
|
||||
ItemId = r.Input.ItemId,
|
||||
Qty = r.Input.Qty,
|
||||
BatchId = r.BatchId,
|
||||
Serials = r.Serials.Select(s => new SalesReturnLineSerial { SerialId = s.SerialId, Serial = s }).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
await _returns.AddAsync(ret, token);
|
||||
@@ -175,6 +213,13 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
|
||||
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token);
|
||||
|
||||
foreach (var r in resolved)
|
||||
{
|
||||
await ApplyReturnEffectsAsync(r, request.WarehouseId, damaged, now, token);
|
||||
foreach (var serial in r.Serials)
|
||||
await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.SalesReturn, ret.ReturnId, _currentUser.AuditUserId, now), token);
|
||||
}
|
||||
|
||||
var totalValue = refs.Sum(r => r.Value);
|
||||
if (totalValue > 0)
|
||||
{
|
||||
@@ -218,17 +263,182 @@ public sealed class SalesReturnService : ISalesReturnService
|
||||
.Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) })
|
||||
.ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct);
|
||||
|
||||
var allocations = await _allocations.Query().AsNoTracking()
|
||||
.Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceDocId == salesInvoiceId)
|
||||
.Select(a => new
|
||||
{
|
||||
a.SourceLineId,
|
||||
Dto = new SalesLineAllocationDto(
|
||||
a.BatchId, a.Batch != null ? a.Batch.BatchNo : null, a.Batch != null ? a.Batch.ExpiryDate : null,
|
||||
a.SerialId, a.Serial != null ? a.Serial.SerialNo : null, a.Qty, a.QtyReturned)
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return lines
|
||||
.Select(l => new SalesInvoiceLineRemainingDto(
|
||||
l.SalesInvoiceLineId,
|
||||
l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m)))
|
||||
l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m),
|
||||
allocations.Where(a => a.SourceLineId == l.SalesInvoiceLineId).Select(a => a.Dto).ToList()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <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(
|
||||
r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt,
|
||||
r.GlJournalNo, r.GlPostedAt,
|
||||
r.Lines.OrderBy(l => l.ReturnLineId)
|
||||
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(),
|
||||
.Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty, l.BatchId,
|
||||
l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList()))
|
||||
.ToList(),
|
||||
ledgerRefs);
|
||||
|
||||
private sealed record ResolvedReturnLine(
|
||||
CreateSalesReturnLineInput Input, Item Item, int? BatchId, List<Serial> Serials,
|
||||
List<SalesLineAllocation> Allocations, decimal? UnitCost);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
|
||||
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);
|
||||
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)
|
||||
{
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
|
||||
.FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||
return slip is null ? null : new ETagged<FreeIssueDto>(MapFreeIssue(slip), slip.RowVersion);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
public async Task<SalesSlipDto> PostAsync(int salesSlipId, CancellationToken ct = default)
|
||||
{
|
||||
await _posting.PostSlipAsync(salesSlipId, ct);
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines)
|
||||
var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial)
|
||||
.FirstAsync(x => x.SalesSlipId == salesSlipId, ct);
|
||||
await _notifications.RaiseAsync("sale.posted", "Sale Posted", $"Sales slip {slip.SlipNo} was posted.", nameof(SalesSlip), slip.SalesSlipId, ct);
|
||||
return _mapping.MapSlip(slip);
|
||||
@@ -188,7 +188,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
|
||||
public async Task<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.");
|
||||
if (slip.Status != SalesSlipStatus.Draft)
|
||||
throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled.");
|
||||
@@ -201,10 +201,15 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
private async Task<List<SalesSlipLine>> BuildLinesAsync(int headerWarehouseId, List<CreateSalesSlipLineRequest> requests, CancellationToken ct)
|
||||
{
|
||||
var lines = new List<SalesSlipLine>();
|
||||
var documentSerials = new HashSet<int>();
|
||||
foreach (var r in requests)
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||
await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct);
|
||||
// The legacy single SerialId is a one-element SerialIds.
|
||||
var serialIds = r.SerialIds is { Count: > 0 } ? r.SerialIds
|
||||
: r.SerialId is int single ? new List<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 unitPrice = resolved.UnitPrice;
|
||||
var priceSource = resolved.PriceSource;
|
||||
@@ -212,11 +217,6 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
|
||||
var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, taxPct, r.IsFreeIssue);
|
||||
|
||||
if (r.BatchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == r.BatchId && b.ItemId == r.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Batch {r.BatchId} does not belong to item {r.ItemId}.", 422);
|
||||
if (r.SerialId is not null && !await _serials.Query().AnyAsync(s => s.SerialId == r.SerialId && s.ItemId == r.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Serial {r.SerialId} does not belong to item {r.ItemId}.", 422);
|
||||
|
||||
lines.Add(new SalesSlipLine
|
||||
{
|
||||
ItemId = r.ItemId,
|
||||
@@ -225,7 +225,8 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
FreeQty = r.FreeQty,
|
||||
WarehouseId = r.WarehouseId,
|
||||
BatchId = r.BatchId,
|
||||
SerialId = r.SerialId,
|
||||
SerialId = serials.Count == 1 ? serials[0].SerialId : null,
|
||||
Serials = serials.Select(s => new SalesSlipLineSerial { SerialId = s.SerialId, Serial = s }).ToList(),
|
||||
UnitPrice = unitPrice,
|
||||
BaseCost = unitPrice,
|
||||
PriceSource = priceSource,
|
||||
@@ -319,7 +320,7 @@ public sealed class SalesSlipService : ISalesSlipService
|
||||
x.CreatedAt,
|
||||
x.UpdatedAt,
|
||||
summary,
|
||||
x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, l.BatchId, l.SerialId)).ToList());
|
||||
_mapping.MapSlip(x).Lines);
|
||||
}
|
||||
|
||||
private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x);
|
||||
|
||||
@@ -63,11 +63,28 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
|
||||
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
|
||||
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 today = DateOnly.FromDateTime(now);
|
||||
|
||||
if (serialIds is not null)
|
||||
{
|
||||
if (serialIds.Distinct().Count() != serialIds.Count || serialIds.Count != qtyBase)
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
$"Issuing {qtyBase} of item {itemId} requires exactly {qtyBase} distinct serial(s), got {serialIds.Count}.", 422);
|
||||
allowShortfall = false; // a serial cannot be sold before it exists
|
||||
}
|
||||
|
||||
// Serial layers are held back from an issue that names no serials — but only while the
|
||||
// item is serial-tracked and Product Configuration has serial numbers switched on. With
|
||||
// the switch off (or the item moved off Serial), they are ordinary stock: issued
|
||||
// oldest-first, and their serial records closed out below.
|
||||
var holdSerialLayers = serialIds is null
|
||||
&& await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId && i.TrackingMode == TrackingMode.Serial, ct)
|
||||
&& (await _db.ProductConfig.AsNoTracking().Where(c => c.ConfigId == ProductConfig.SingletonId)
|
||||
.Select(c => (bool?)c.SerialNumbersEnabled).FirstOrDefaultAsync(ct) ?? true);
|
||||
|
||||
if (batchId is not null)
|
||||
{
|
||||
var expiry = await _db.Batches.AsNoTracking()
|
||||
@@ -107,6 +124,18 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
""")
|
||||
.ToListAsync(ct);
|
||||
|
||||
// A serial item holds one layer per unit, so don't lock them all: keep only the named
|
||||
// serials' layers (or, with none named, the untracked ones) plus shortfall layers.
|
||||
if (candidateIds.Count > 0 && (serialIds is not null || holdSerialLayers))
|
||||
{
|
||||
var ids = candidateIds;
|
||||
candidateIds = await _db.StockLayers.AsNoTracking()
|
||||
.Where(l => ids.Contains(l.LayerId) && (l.QtyRemaining < 0 || (serialIds == null
|
||||
? l.SerialId == null
|
||||
: l.SerialId != null && serialIds.Contains(l.SerialId.Value))))
|
||||
.Select(l => l.LayerId).ToListAsync(ct);
|
||||
}
|
||||
|
||||
// `IN ()` is a syntax error, so short-circuit to the normal insufficient-stock path.
|
||||
List<StockLayer> locked;
|
||||
if (candidateIds.Count == 0)
|
||||
@@ -167,11 +196,13 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
// Stock sold before it arrived is owed first: net shortfall layers off against issuable
|
||||
// stock (oldest-first) before anything new is issued, so it can't be sold twice. The
|
||||
// sale already posted its ledger Out, so settling writes no ledger entry of its own.
|
||||
// Serial layers are excluded from settlement: netting a shortfall against one would empty
|
||||
// a serial's layer while the serial itself still reads InStock.
|
||||
var owed = locked.Where(l => l.QtyRemaining < 0).ToList();
|
||||
var open = locked.Where(l => l.QtyRemaining > 0).ToList();
|
||||
foreach (var shortfall in owed)
|
||||
{
|
||||
foreach (var layer in open)
|
||||
foreach (var layer in open.Where(l => l.SerialId == null))
|
||||
{
|
||||
if (shortfall.QtyRemaining >= 0) break;
|
||||
var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining);
|
||||
@@ -182,7 +213,38 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
|
||||
// Only now apply the caller's batch filter: settlement above is deliberately
|
||||
// batch-agnostic (a shortfall layer has no batch), but the issue itself is not.
|
||||
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList();
|
||||
// Serial filter: named serials only, or — with none named — no serial layer while they
|
||||
// are held back (see holdSerialLayers).
|
||||
var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)
|
||||
&& (serialIds == null
|
||||
? !holdSerialLayers || l.SerialId == null
|
||||
: l.SerialId != null && serialIds.Contains(l.SerialId.Value)))
|
||||
.ToList();
|
||||
|
||||
if (serialIds is not null)
|
||||
{
|
||||
var found = layers.Select(l => l.SerialId!.Value).ToHashSet();
|
||||
var missing = serialIds.Where(id => !found.Contains(id)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
var numbers = await _db.Serials.AsNoTracking().Where(s => missing.Contains(s.SerialId))
|
||||
.Select(s => s.SerialNo).ToListAsync(ct);
|
||||
throw new DomainException(ErrorCodes.SerialNotAvailable,
|
||||
$"Serial(s) {string.Join(", ", numbers.Count > 0 ? numbers : missing.Select(m => m.ToString()))} are not available to issue from warehouse {warehouseId} (not in stock here, on hold, or expired).", 409);
|
||||
}
|
||||
}
|
||||
|
||||
// FEFO then FIFO: soonest-expiring batch first; unbatched/no-expiry stock after.
|
||||
var layerBatchIds = layers.Where(l => l.BatchId != null).Select(l => l.BatchId!.Value).Distinct().ToList();
|
||||
if (layerBatchIds.Count > 0)
|
||||
{
|
||||
var expiries = await _db.Batches.AsNoTracking().Where(b => layerBatchIds.Contains(b.BatchId))
|
||||
.ToDictionaryAsync(b => b.BatchId, b => b.ExpiryDate, ct);
|
||||
layers = layers
|
||||
.OrderBy(l => l.BatchId is int b && expiries.TryGetValue(b, out var e) && e is not null ? e.Value : DateOnly.MaxValue)
|
||||
.ThenBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var available = layers.Sum(l => l.QtyRemaining);
|
||||
if (available < qtyBase && !allowShortfall)
|
||||
@@ -190,6 +252,7 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
|
||||
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
|
||||
&& (batchId == null || l.BatchId == batchId)
|
||||
&& (serialIds == null ? l.SerialId == null : serialIds.Contains(l.SerialId ?? 0))
|
||||
&& l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected), ct);
|
||||
if (heldExists)
|
||||
throw new DomainException(ErrorCodes.OnHoldNotIssuable,
|
||||
@@ -207,7 +270,27 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
var take = Math.Min(remaining, layer.QtyRemaining);
|
||||
layer.QtyRemaining -= take;
|
||||
remaining -= take;
|
||||
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
|
||||
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost, layer.BatchId, layer.SerialId));
|
||||
}
|
||||
|
||||
// Serial units issued without being named (serial numbers switched off): close their
|
||||
// records so the register doesn't keep showing them in stock.
|
||||
if (serialIds is null)
|
||||
{
|
||||
var issuedSerialIds = layers.Where(l => l.SerialId != null && l.QtyRemaining == 0
|
||||
&& segments.Any(s => s.LayerId == l.LayerId))
|
||||
.Select(l => l.SerialId!.Value).ToList();
|
||||
if (issuedSerialIds.Count > 0)
|
||||
{
|
||||
var issued = await _db.Serials.Where(s => issuedSerialIds.Contains(s.SerialId)).ToListAsync(ct);
|
||||
foreach (var serial in issued)
|
||||
{
|
||||
serial.Status = SerialStatus.IssuedUntracked;
|
||||
serial.WarehouseId = null;
|
||||
serial.BinId = null;
|
||||
serial.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes
|
||||
@@ -232,6 +315,32 @@ public sealed class FifoCostingService : IFifoCostingService
|
||||
return segments;
|
||||
}
|
||||
|
||||
public async Task<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).
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ERPCore.Domain.Enums;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services.Stock;
|
||||
@@ -41,24 +42,39 @@ public sealed class StockMutator : IStockMutator
|
||||
if (!balances.TryGetValue(d.ItemId, out var bal))
|
||||
bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct);
|
||||
|
||||
var serialIds = d.SerialIds is { Count: > 0 } ? d.SerialIds : null;
|
||||
if (serialIds is not null && serialIds.Count != Math.Abs(d.QtyDelta))
|
||||
throw new DomainException(ErrorCodes.TrackingRequired,
|
||||
$"Item {d.ItemId}: {Math.Abs(d.QtyDelta)} unit(s) need exactly that many serials, got {serialIds.Count}.", 422);
|
||||
// The ledger carries a serial only when the movement is exactly one unit.
|
||||
int? ledgerSerialId = serialIds is { Count: 1 } ? serialIds[0] : null;
|
||||
|
||||
StockLedger entry;
|
||||
if (d.QtyDelta < 0)
|
||||
{
|
||||
var qty = -d.QtyDelta;
|
||||
var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct);
|
||||
var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct, serialIds: serialIds);
|
||||
var unitCost = segments.Sum(s => s.Qty * s.UnitCost) / qty;
|
||||
bal -= qty;
|
||||
entry = await _fifo.PostLedgerAsync(
|
||||
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
|
||||
d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor,
|
||||
Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var unitCost = await LastCostAsync(d.ItemId, warehouseId, ct);
|
||||
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct);
|
||||
var unitCost = d.UnitCost ?? await LastCostAsync(d.ItemId, warehouseId, ct);
|
||||
if (serialIds is not null)
|
||||
{
|
||||
foreach (var serialId in serialIds)
|
||||
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, serialId, null, 1m, unitCost, now, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct);
|
||||
}
|
||||
bal += d.QtyDelta;
|
||||
entry = await _fifo.PostLedgerAsync(
|
||||
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
|
||||
d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor,
|
||||
Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,12 +37,15 @@ public sealed class TransferService : ITransferService
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly INotificationService _notifications;
|
||||
private readonly IUnitOfWork _uow;
|
||||
private readonly ISerialTrackingService _serialTracking;
|
||||
|
||||
public TransferService(
|
||||
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
|
||||
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser,
|
||||
IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow)
|
||||
IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow,
|
||||
ISerialTrackingService serialTracking)
|
||||
{
|
||||
_serialTracking = serialTracking;
|
||||
_transfers = transfers;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
@@ -83,7 +86,8 @@ public sealed class TransferService : ITransferService
|
||||
|
||||
public async Task<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);
|
||||
return t is null ? null : Map(t);
|
||||
}
|
||||
@@ -95,9 +99,42 @@ public sealed class TransferService : ITransferService
|
||||
await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
|
||||
await EnsureWarehouseAsync(request.DestWarehouseId, ct);
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
|
||||
// Resolve tracking up front: serial lines carry their serials; a batch-tracked line with
|
||||
// no batch is split FEFO into one line per batch, so batch identity survives the move.
|
||||
var lines = new List<StockTransferLine>();
|
||||
var documentSerials = new HashSet<int>();
|
||||
foreach (var line in request.Lines)
|
||||
if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
{
|
||||
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct)
|
||||
?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
|
||||
var serials = await _serialTracking.ResolveOutboundAsync(
|
||||
item, request.SrcWarehouseId, line.Qty, line.SerialIds, documentSerials, new[] { SerialStatus.InStock }, ct);
|
||||
|
||||
StockTransferLine NewLine(int? batchId, decimal qty) => new()
|
||||
{
|
||||
ItemId = line.ItemId,
|
||||
SrcBinId = line.SrcBinId,
|
||||
DestBinId = line.DestBinId,
|
||||
BatchId = batchId,
|
||||
Qty = qty,
|
||||
SerialId = serials.Count == 1 ? serials[0].SerialId : null,
|
||||
Serials = serials.Select(s => new StockTransferLineSerial { SerialId = s.SerialId }).ToList()
|
||||
};
|
||||
|
||||
if (item.TrackingMode == TrackingMode.Batch && line.BatchId is null)
|
||||
{
|
||||
var picks = await _fifo.SuggestBatchesAsync(line.ItemId, request.SrcWarehouseId, line.Qty, ct);
|
||||
var picked = picks.Sum(p => p.Qty);
|
||||
foreach (var pick in picks) lines.Add(NewLine(pick.BatchId, pick.Qty));
|
||||
// Whatever FEFO could not place stays unbatched — dispatch then reports the shortfall.
|
||||
if (picked < line.Qty) lines.Add(NewLine(null, line.Qty - picked));
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add(NewLine(line.BatchId, line.Qty));
|
||||
}
|
||||
}
|
||||
|
||||
var actor = _currentUser.AuditUserId;
|
||||
var transfer = await _uow.ExecuteInTransactionAsync(async token =>
|
||||
@@ -112,14 +149,7 @@ public sealed class TransferService : ITransferService
|
||||
Status = TransferStatus.Draft,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = request.Lines.Select(l => new StockTransferLine
|
||||
{
|
||||
ItemId = l.ItemId,
|
||||
SrcBinId = l.SrcBinId,
|
||||
DestBinId = l.DestBinId,
|
||||
BatchId = l.BatchId,
|
||||
Qty = l.Qty
|
||||
}).ToList()
|
||||
Lines = lines
|
||||
};
|
||||
await _transfers.AddAsync(entity, token);
|
||||
return entity;
|
||||
@@ -132,7 +162,8 @@ public sealed class TransferService : ITransferService
|
||||
|
||||
public async Task<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)
|
||||
?? throw new NotFoundException($"Transfer {transferId} was not found.");
|
||||
if (transfer.Status != TransferStatus.Draft)
|
||||
@@ -148,10 +179,28 @@ public sealed class TransferService : ITransferService
|
||||
{
|
||||
foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId))
|
||||
{
|
||||
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token);
|
||||
// The draft didn't reserve its serials — re-check they are still here and in stock.
|
||||
var serials = line.Serials.Select(s => s.Serial!).ToList();
|
||||
var gone = serials.Where(s => s.Status != SerialStatus.InStock || s.WarehouseId != transfer.SrcWarehouseId).Select(s => s.SerialNo).ToList();
|
||||
if (gone.Count > 0)
|
||||
throw new DomainException(ErrorCodes.SerialNotAvailable,
|
||||
$"Serial(s) {string.Join(", ", gone)} are no longer in stock at the source warehouse.", 409);
|
||||
|
||||
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token,
|
||||
serialIds: serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null);
|
||||
var value = segments.Sum(s => s.Qty * s.UnitCost);
|
||||
line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive
|
||||
|
||||
// Each serial keeps its own cost and batch for its destination layer.
|
||||
foreach (var lineSerial in line.Serials)
|
||||
{
|
||||
var segment = segments.First(s => s.SerialId == lineSerial.SerialId);
|
||||
lineSerial.UnitCost = segment.UnitCost;
|
||||
lineSerial.BatchId = segment.BatchId;
|
||||
}
|
||||
await _serialTracking.MoveAsync(serials, SerialStatus.InTransit, null, null,
|
||||
DocumentTypes.Transfer, transfer.TransferId, now, token);
|
||||
|
||||
if (!balances.TryGetValue(line.ItemId, out var bal))
|
||||
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token);
|
||||
bal -= line.Qty;
|
||||
@@ -174,7 +223,8 @@ public sealed class TransferService : ITransferService
|
||||
|
||||
public async Task<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)
|
||||
?? throw new NotFoundException($"Transfer {transferId} was not found.");
|
||||
if (transfer.Status != TransferStatus.InTransit)
|
||||
@@ -199,9 +249,26 @@ public sealed class TransferService : ITransferService
|
||||
$"Receiving {input.Qty} exceeds the outstanding in-transit {outstanding} on line {input.TransferLineId}.", 422);
|
||||
|
||||
var unitCost = line.UnitCost ?? 0m;
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token);
|
||||
createdLayers.Add(layer);
|
||||
if (line.Serials.Count > 0)
|
||||
{
|
||||
var arriving = ResolveArrivingSerials(line, input);
|
||||
foreach (var lineSerial in arriving)
|
||||
{
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, transfer.DestWarehouseId, lineSerial.BatchId ?? line.BatchId, lineSerial.SerialId, null,
|
||||
1m, lineSerial.UnitCost ?? unitCost, now, token);
|
||||
createdLayers.Add(layer);
|
||||
lineSerial.ReceivedAt = now;
|
||||
}
|
||||
await _serialTracking.MoveAsync(arriving.Select(s => s.Serial!), SerialStatus.InStock,
|
||||
transfer.DestWarehouseId, line.DestBinId, DocumentTypes.Transfer, transfer.TransferId, now, token);
|
||||
}
|
||||
else
|
||||
{
|
||||
var layer = await _fifo.CreateInboundLayerAsync(
|
||||
line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token);
|
||||
createdLayers.Add(layer);
|
||||
}
|
||||
|
||||
if (!balances.TryGetValue(line.ItemId, out var bal))
|
||||
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.DestWarehouseId, token);
|
||||
@@ -234,8 +301,34 @@ public sealed class TransferService : ITransferService
|
||||
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
|
||||
}
|
||||
|
||||
/// <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(
|
||||
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt,
|
||||
t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto(
|
||||
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList());
|
||||
l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived,
|
||||
l.Serials.Select(s => new TransferLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty, s.ReceivedAt != null)).ToList())).ToList());
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ public static class ErrorCodes
|
||||
public const string ConfigDisabled = "CONFIG_DISABLED";
|
||||
public const string RoleInUse = "ROLE_IN_USE";
|
||||
|
||||
// Item tracking (docs/ITEM_TRACKING_PLAN.md).
|
||||
public const string SerialDuplicate = "SERIAL_DUPLICATE";
|
||||
public const string SerialNotAvailable = "SERIAL_NOT_AVAILABLE";
|
||||
public const string TrackingRequired = "TRACKING_REQUIRED";
|
||||
|
||||
// Auth proxy (AuthController → AuthHex, docs/11 §2.0)
|
||||
public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR";
|
||||
public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE";
|
||||
|
||||
@@ -18,6 +18,7 @@ import { CreatePurchaseReturnLineInput } from "@/types/procurement"
|
||||
import { Grn, GrnLine } from "@/types/grn"
|
||||
import { ItemListItem } from "@/types/master-data"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -39,6 +40,8 @@ const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background
|
||||
interface LineState {
|
||||
selected: boolean
|
||||
qty: string
|
||||
/** Serial line: the received serials going back to the vendor (qty follows the count). */
|
||||
serialIds?: number[]
|
||||
}
|
||||
|
||||
function NewPurchaseReturnContent() {
|
||||
@@ -62,6 +65,7 @@ function NewPurchaseReturnContent() {
|
||||
const [headerError, setHeaderError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")])
|
||||
@@ -121,6 +125,33 @@ function NewPurchaseReturnContent() {
|
||||
setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } }))
|
||||
}
|
||||
|
||||
function toggleSerial(grnLineId: number, serialId: number) {
|
||||
setLineState((prev) => {
|
||||
const current = prev[grnLineId]?.serialIds ?? []
|
||||
const next = current.includes(serialId) ? current.filter((id) => id !== serialId) : [...current, serialId]
|
||||
return { ...prev, [grnLineId]: { selected: next.length > 0, qty: next.length > 0 ? String(next.length) : "", serialIds: next } }
|
||||
})
|
||||
}
|
||||
|
||||
/** A serial line lists its received serials to tick — the server refuses any no longer in stock. */
|
||||
function renderSerials(line: GrnLine) {
|
||||
// Serial numbers switched off in Product Configuration: the line goes back as plain stock.
|
||||
if (serialNumbersEnabled === false) return null
|
||||
const serials = (line.serials ?? []).filter((s) => s.serialId !== null)
|
||||
if (serials.length === 0) return null
|
||||
const picked = lineState[line.grnLineId]?.serialIds ?? []
|
||||
return (
|
||||
<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() {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
@@ -143,6 +174,8 @@ function NewPurchaseReturnContent() {
|
||||
const nextErrors: Record<number, Record<string, string>> = {}
|
||||
for (const line of selectedLines) {
|
||||
const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty })
|
||||
if (serialNumbersEnabled !== false && (line.serials ?? []).some((s) => s.serialId !== null) && (lineState[line.grnLineId].serialIds?.length ?? 0) !== Number(lineState[line.grnLineId].qty))
|
||||
errors.qty = "Tick one serial per unit going back"
|
||||
if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors
|
||||
}
|
||||
setLineErrors(nextErrors)
|
||||
@@ -155,6 +188,7 @@ function NewPurchaseReturnContent() {
|
||||
grnLineId: l.grnLineId,
|
||||
itemId: l.itemId,
|
||||
qty: Number(lineState[l.grnLineId].qty),
|
||||
serialIds: lineState[l.grnLineId].serialIds?.length ? lineState[l.grnLineId].serialIds : null,
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
@@ -249,6 +283,7 @@ function NewPurchaseReturnContent() {
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</span>
|
||||
</label>
|
||||
{renderSerials(line)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
@@ -309,7 +344,10 @@ function NewPurchaseReturnContent() {
|
||||
aria-label={`Return ${item?.sku ?? `item #${line.itemId}`}`}
|
||||
/>
|
||||
</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">
|
||||
<HoldStatusBadge status={line.holdStatus} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { invalidateTrackingSettings } from "@/hooks/use-tracking-settings"
|
||||
|
||||
/**
|
||||
* Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate.
|
||||
@@ -61,6 +62,8 @@ export default function ProductSettingsPage() {
|
||||
)
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
// Screens read the serial switch through a cached hook — make the next read fresh.
|
||||
invalidateTrackingSettings()
|
||||
toast.success("Configuration saved", message)
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -152,7 +155,7 @@ export default function ProductSettingsPage() {
|
||||
|
||||
<ToggleRow
|
||||
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}
|
||||
busy={saving === "serialNumbersEnabled"}
|
||||
onChange={(v) => toggle("serialNumbersEnabled", v)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
||||
import { ConfirmGrnResponse, Grn, GrnLine } from "@/types/grn"
|
||||
import { Bin, ItemListItem, Uom } from "@/types/master-data"
|
||||
import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices"
|
||||
|
||||
@@ -27,6 +27,34 @@ import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badg
|
||||
import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint"
|
||||
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
||||
|
||||
/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */
|
||||
function LineTrackingInfo({ line }: { line: GrnLine }) {
|
||||
const serials = line.serials ?? []
|
||||
if (!line.batchNo && serials.length === 0) return null
|
||||
return (
|
||||
<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() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
@@ -235,6 +263,7 @@ export default function GrnDetailPage() {
|
||||
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
|
||||
</p>
|
||||
)}
|
||||
<LineTrackingInfo line={line} />
|
||||
</div>
|
||||
<HoldStatusBadge status={line.holdStatus} />
|
||||
</div>
|
||||
@@ -357,6 +386,7 @@ export default function GrnDetailPage() {
|
||||
{line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured
|
||||
</p>
|
||||
)}
|
||||
<LineTrackingInfo line={line} />
|
||||
</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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2, Wand2 } from "lucide-react"
|
||||
|
||||
import { ScanInput } from "@/components/scanning/ScanInput"
|
||||
import { ApiError } from "@/lib/api-client"
|
||||
@@ -15,6 +15,8 @@ import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { grnConfigApi } from "@/lib/api/grn-config"
|
||||
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
|
||||
import { trackingApi } from "@/lib/api/tracking"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateLine, grnHeaderSchema } from "@/lib/validations/grn"
|
||||
@@ -40,12 +42,25 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
type Mode = "po" | "direct"
|
||||
type LineTab = "lines" | "warranty"
|
||||
|
||||
/** Whole units a line's qty represents — one warranty number is captured per unit. */
|
||||
/** Whole units a line's qty represents — one warranty/serial number is captured per unit. */
|
||||
function unitCount(qty: string): number {
|
||||
const n = Math.floor(Number(qty))
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Items that need one number per received unit: serial-tracked items (their serial numbers —
|
||||
* which double as the warranty identity, docs/ITEM_TRACKING_PLAN.md D1) and other warranty items.
|
||||
*/
|
||||
function capturesUnitNumbers(item: ItemListItem | null | undefined): boolean {
|
||||
return item?.trackingMode === "Serial" || item?.warranty === "Warranty"
|
||||
}
|
||||
|
||||
/** "serial number" for a serial-tracked item, else "warranty number". */
|
||||
function unitNumberLabel(item: ItemListItem | null | undefined): string {
|
||||
return item?.trackingMode === "Serial" ? "serial number" : "warranty number"
|
||||
}
|
||||
|
||||
/** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */
|
||||
function WarrantyHintBubble() {
|
||||
const [visible, setVisible] = useState(true)
|
||||
@@ -83,10 +98,15 @@ interface DraftLine {
|
||||
discountPct: string
|
||||
vatPct: string
|
||||
holdStatus: HoldStatus
|
||||
/** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */
|
||||
/**
|
||||
* One entry per received unit, index-aligned; only meaningful when the item captures unit
|
||||
* numbers — sent as `serialNumbers` for a serial-tracked item, else as `warrantyNumbers`.
|
||||
*/
|
||||
warrantyNumbers: string[]
|
||||
/** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo }` on submit. */
|
||||
/** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo, expiryDate }` on submit. */
|
||||
batchNo: string
|
||||
/** yyyy-mm-dd, optional — drives FEFO picking and expiry blocking. */
|
||||
batchExpiry: string
|
||||
}
|
||||
|
||||
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
|
||||
@@ -155,10 +175,12 @@ function buildWarrantyRange(from: string, to: string): string[] | null {
|
||||
* with a generated number — existing (manually edited) values are left untouched.
|
||||
*/
|
||||
function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] {
|
||||
if (item?.warranty !== "Warranty") return l.warrantyNumbers
|
||||
if (!capturesUnitNumbers(item)) return l.warrantyNumbers
|
||||
const units = unitCount(l.qty)
|
||||
const next = l.warrantyNumbers.slice(0, units)
|
||||
while (next.length < units) next.push("")
|
||||
// Serial numbers are the manufacturer's — never invent them; leave the slots for scanning.
|
||||
if (item?.trackingMode === "Serial") return next
|
||||
for (let i = 0; i < units; i++) {
|
||||
if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber()
|
||||
}
|
||||
@@ -179,12 +201,14 @@ function emptyLine(): DraftLine {
|
||||
holdStatus: "Available",
|
||||
warrantyNumbers: [],
|
||||
batchNo: "",
|
||||
batchExpiry: "",
|
||||
}
|
||||
}
|
||||
|
||||
export default function NewGrnPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
|
||||
const [mode, setMode] = useState<Mode>("po")
|
||||
|
||||
@@ -339,6 +363,7 @@ export default function NewGrnPage() {
|
||||
holdStatus: "Available",
|
||||
warrantyNumbers: [],
|
||||
batchNo: "",
|
||||
batchExpiry: "",
|
||||
}
|
||||
return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
|
||||
})
|
||||
@@ -440,8 +465,16 @@ export default function NewGrnPage() {
|
||||
setLines((prev) => prev.filter((l) => l.key !== key))
|
||||
}
|
||||
|
||||
/**
|
||||
* The item as this GRN must treat it: with serial numbers switched off in Product
|
||||
* Configuration a Serial item is received as untracked stock (a warranty item then takes
|
||||
* warranty numbers again), so every serial rule below follows from this one place.
|
||||
*/
|
||||
function itemFor(itemId: number | null) {
|
||||
return items?.find((i) => i.itemId === itemId) ?? null
|
||||
const item = items?.find((i) => i.itemId === itemId) ?? null
|
||||
return item && item.trackingMode === "Serial" && serialNumbersEnabled === false
|
||||
? { ...item, trackingMode: "None" as const }
|
||||
: item
|
||||
}
|
||||
|
||||
const itemOptions = useMemo<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.
|
||||
}
|
||||
|
||||
// Lines whose item is warranty-tracked and carry at least one unit — these are the rows
|
||||
// the "Warranty numbers" tab needs to capture, one input per unit.
|
||||
// Lines whose item is serial- or warranty-tracked and carry at least one unit — these are
|
||||
// the rows the "Serial / warranty numbers" tab needs to capture, one input per unit.
|
||||
const warrantyLines = lines
|
||||
.map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) }))
|
||||
.filter((w) => w.item?.warranty === "Warranty" && w.units > 0)
|
||||
.filter((w) => capturesUnitNumbers(w.item) && w.units > 0)
|
||||
|
||||
// Bulk paste for a line's serial/warranty numbers — one per line (or comma-separated), filling
|
||||
// units from the first; extra pasted values beyond the unit count are reported, not dropped silently.
|
||||
const [pasteInputs, setPasteInputs] = useState<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() {
|
||||
setSubmitError(null)
|
||||
@@ -576,6 +666,8 @@ export default function NewGrnPage() {
|
||||
discountPct: allowDiscounts ? line.discountPct : "0",
|
||||
vatPct: allowVat ? line.vatPct : "0",
|
||||
warranty: item?.warranty ?? null,
|
||||
trackingMode: item?.trackingMode ?? null,
|
||||
batchNo: line.batchNo,
|
||||
warrantyNumbers: line.warrantyNumbers,
|
||||
})
|
||||
if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors
|
||||
@@ -593,6 +685,8 @@ export default function NewGrnPage() {
|
||||
|
||||
const payloadLines: CreateGrnLineInput[] = lines.map((l) => {
|
||||
const item = itemFor(l.itemId)
|
||||
const unitNumbers = l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
const serial = item?.trackingMode === "Serial"
|
||||
return {
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId as number,
|
||||
@@ -602,11 +696,13 @@ export default function NewGrnPage() {
|
||||
discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0,
|
||||
vatPct: allowVat ? Number(l.vatPct) || 0 : 0,
|
||||
holdStatus: l.holdStatus,
|
||||
warrantyNumbers:
|
||||
item?.warranty === "Warranty"
|
||||
? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
// A serial item's serials are its warranty identity — it sends no warranty numbers.
|
||||
serialNumbers: serial ? unitNumbers : null,
|
||||
warrantyNumbers: !serial && item?.warranty === "Warranty" ? unitNumbers : null,
|
||||
batch:
|
||||
item?.trackingMode === "Batch" && l.batchNo.trim()
|
||||
? { batchNo: l.batchNo.trim(), expiryDate: l.batchExpiry || null }
|
||||
: null,
|
||||
batch: item?.trackingMode === "Batch" && l.batchNo.trim() ? { batchNo: l.batchNo.trim() } : null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -861,7 +957,7 @@ export default function NewGrnPage() {
|
||||
className="rounded-full"
|
||||
onClick={() => setLineTab("warranty")}
|
||||
>
|
||||
Warranty numbers
|
||||
Serial / warranty numbers
|
||||
{warrantyLines.length > 0 && (
|
||||
<Badge variant="outline" className="ml-1.5 h-5 px-1.5 text-xs">
|
||||
{warrantyLines.reduce((sum, w) => sum + w.units, 0)}
|
||||
@@ -951,14 +1047,27 @@ export default function NewGrnPage() {
|
||||
</div>
|
||||
|
||||
{item?.trackingMode === "Batch" && (
|
||||
<div>
|
||||
<Label className="text-sm">Batch no</Label>
|
||||
<Input
|
||||
value={line.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
placeholder="Batch no"
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm">Batch no</Label>
|
||||
<Input
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -1050,10 +1159,10 @@ export default function NewGrnPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{item?.warranty === "Warranty" && (
|
||||
{capturesUnitNumbers(item) && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setLineTab("warranty")}>
|
||||
<ShieldCheck className="size-4" />
|
||||
Add warranty numbers
|
||||
Add {unitNumberLabel(item)}s
|
||||
</Button>
|
||||
)}
|
||||
{errors.warrantyNumbers && <FieldError errors={[{ message: errors.warrantyNumbers }]} />}
|
||||
@@ -1134,12 +1243,24 @@ export default function NewGrnPage() {
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
{item?.trackingMode === "Batch" ? (
|
||||
<Input
|
||||
value={line.batchNo}
|
||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||
placeholder="Batch no"
|
||||
className="h-11 text-sm"
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Input
|
||||
value={line.batchNo}
|
||||
aria-invalid={!!errors.batchNo}
|
||||
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>
|
||||
)}
|
||||
@@ -1237,7 +1358,7 @@ export default function NewGrnPage() {
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="py-3 pr-0 pl-1 align-top">
|
||||
{item?.warranty === "Warranty" ? (
|
||||
{capturesUnitNumbers(item) ? (
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -1247,13 +1368,13 @@ export default function NewGrnPage() {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setLineTab("warranty")}
|
||||
aria-label="Add warranty numbers"
|
||||
aria-label={`Add ${unitNumberLabel(item)}s`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add warranty numbers</TooltipContent>
|
||||
<TooltipContent>Add {unitNumberLabel(item)}s</TooltipContent>
|
||||
</Tooltip>
|
||||
{errors.warrantyNumbers && (
|
||||
<FieldError errors={[{ message: errors.warrantyNumbers }]} />
|
||||
@@ -1311,8 +1432,8 @@ export default function NewGrnPage() {
|
||||
|
||||
{warrantyLines.length === 0 ? (
|
||||
<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
|
||||
“Warranty” to capture its numbers here.
|
||||
No serial- or warranty-tracked items on this GRN yet — add a line for such an
|
||||
item to capture one number per unit here.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -1320,11 +1441,65 @@ export default function NewGrnPage() {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" }
|
||||
const addError = warrantyAddErrors[line.key]
|
||||
const serial = item?.trackingMode === "Serial"
|
||||
const label = unitNumberLabel(item)
|
||||
const entered = line.warrantyNumbers.filter((s) => s.trim().length > 0).length
|
||||
return (
|
||||
<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}`}
|
||||
<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>
|
||||
{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-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">From</Label>
|
||||
@@ -1365,8 +1540,10 @@ export default function NewGrnPage() {
|
||||
<TableRow>
|
||||
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
|
||||
<TableHead className="h-10 px-3 text-sm">
|
||||
Warranty number
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">(auto-generated — edit if needed)</span>
|
||||
{serial ? "Serial number" : "Warranty number"}
|
||||
<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>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -1381,7 +1558,8 @@ export default function NewGrnPage() {
|
||||
value={line.warrantyNumbers[i] ?? ""}
|
||||
aria-invalid={!!errors.warrantyNumbers}
|
||||
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"
|
||||
/>
|
||||
{i === units - 1 && (
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ArrowLeft, Pencil, Plus, Save, Trash2 } from "lucide-react"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { SuggestInput } from "@/components/ui/suggest-input"
|
||||
import { trackingTags } from "@/lib/tracking-tags"
|
||||
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -74,6 +76,7 @@ const blankLine = (key: string): Line => ({
|
||||
export default function NewFreeIssuePage() {
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [users, setUsers] = useState<ManagedUser[]>([])
|
||||
@@ -162,8 +165,8 @@ export default function NewFreeIssuePage() {
|
||||
}
|
||||
|
||||
const itemOptions = useMemo(
|
||||
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })),
|
||||
[items]
|
||||
() => items.map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}`, tags: trackingTags(i.trackingMode, serialNumbersEnabled) })),
|
||||
[items, serialNumbersEnabled]
|
||||
)
|
||||
|
||||
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 { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
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 { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||
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 { 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", {
|
||||
style: "currency",
|
||||
@@ -70,6 +75,8 @@ function draftSnapshot(customerId: number | null, warehouseId: number | null, in
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
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 [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
@@ -165,6 +173,9 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
taxPct: line.taxPct,
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
parentLineId: line.parentLineId,
|
||||
batchId: line.batchId ?? null,
|
||||
batchNo: null,
|
||||
serials: line.serials ?? [],
|
||||
}))
|
||||
setLines(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) => {
|
||||
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) {
|
||||
@@ -309,6 +325,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
taxPct: Number(line.taxPct),
|
||||
isFreeIssue: allowFreeItems && line.isFreeIssue,
|
||||
parentLineId: line.parentLineId || null,
|
||||
batchId: line.batchId ?? null,
|
||||
serialIds: line.serials && line.serials.length > 0 ? line.serials.map((s) => s.serialId) : null,
|
||||
})),
|
||||
},
|
||||
etag,
|
||||
@@ -534,6 +552,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<tr key={line.key} className="border-t border-border align-middle">
|
||||
<td className="min-w-64 px-4 py-3">
|
||||
<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 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">
|
||||
@@ -611,6 +637,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<td className="px-4 py-3">
|
||||
<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>
|
||||
{(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 className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -40,6 +41,9 @@ import {
|
||||
SalesStockAvailability,
|
||||
} from "@/types/sales"
|
||||
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"
|
||||
|
||||
// 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
|
||||
// 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).
|
||||
// batchId/serialId/batchNo/serialNo are frontend-only display metadata from a barcode/RFID scan
|
||||
// (see ScanInput below) — the live CreateSalesInvoiceLineRequest DTO has no batch/serial fields
|
||||
// yet (only CreateSalesSlipLineRequest does), so these are carried for display/traceability on
|
||||
// this draft line but deliberately NOT sent in submit()'s payload until the invoice line request
|
||||
// grows the same fields.
|
||||
// batchId/serials are sent (docs/ITEM_TRACKING_PLAN.md Phase 4): a batch item may pin a batch (else
|
||||
// posting picks FEFO), a serial item must carry one serial per unit of qty + free qty. They are
|
||||
// filled by a barcode/RFID scan (ScanInput below) or the per-line LineTrackingPicker.
|
||||
// batchNo/serialId/serialNo are display metadata from the scan.
|
||||
type Line = CreateSalesInvoiceLineRequest & {
|
||||
key: string
|
||||
bundleGroupId?: string
|
||||
@@ -62,6 +65,7 @@ type Line = CreateSalesInvoiceLineRequest & {
|
||||
batchNo?: string | null
|
||||
serialId?: number | null
|
||||
serialNo?: string | null
|
||||
serials?: SalesLineSerial[]
|
||||
}
|
||||
|
||||
type DisplayRow =
|
||||
@@ -103,6 +107,7 @@ const blankLine = (key: string): Line => ({
|
||||
batchNo: null,
|
||||
serialId: null,
|
||||
serialNo: null,
|
||||
serials: [],
|
||||
})
|
||||
|
||||
const lkr = new Intl.NumberFormat("en-LK", {
|
||||
@@ -120,6 +125,7 @@ function NewSalesInvoiceContent() {
|
||||
const appliedBundleRef = useRef<string | null>(null)
|
||||
const bundleGroupSeqRef = useRef(0)
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
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
|
||||
// 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
|
||||
// (see the Line type note above re: submit() not yet sending them).
|
||||
// unit, or appends a new line for it — a scanned serial becomes that line's chosen serial.
|
||||
// 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.
|
||||
function addItem(
|
||||
@@ -331,6 +336,7 @@ function NewSalesInvoiceContent() {
|
||||
batchNo: lot.batchNo ?? null,
|
||||
serialId: lot.serialId ?? 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 qty = stockByItem ? (stockByItem.get(i.itemId) ?? 0) : null
|
||||
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 = [
|
||||
uom,
|
||||
i.trackingMode !== "None" ? `${i.trackingMode} tracked` : null,
|
||||
i.warranty === "Warranty" && i.warrantyPeriodMonths ? `${i.warrantyPeriodMonths}m warranty` : null,
|
||||
i.taxRateName ? `Tax: ${i.taxRateName}` : null,
|
||||
]
|
||||
@@ -530,11 +537,12 @@ function NewSalesInvoiceContent() {
|
||||
label: `${i.sku} — ${i.name}`,
|
||||
hint: price !== null ? lkr.format(price) : undefined,
|
||||
detail: detail || undefined,
|
||||
tags,
|
||||
badge: !stocked ? "Non-stock" : qty === null ? "Stock: —" : `Stock: ${qtyFormat.format(qty)}`,
|
||||
badgeTone: (!stocked || qty === null ? "muted" : qty > 0 ? "success" : "danger") as SuggestBadgeTone,
|
||||
}
|
||||
}),
|
||||
[items, uoms, stockByItem],
|
||||
[items, uoms, stockByItem, serialNumbersEnabled],
|
||||
)
|
||||
|
||||
const bundleTemplateOptions = useMemo(
|
||||
@@ -617,6 +625,29 @@ function NewSalesInvoiceContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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) {
|
||||
const item = items.find((i) => i.itemId === 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(", ")
|
||||
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 = {
|
||||
customerId,
|
||||
@@ -713,6 +749,8 @@ function NewSalesInvoiceContent() {
|
||||
taxPct: Number(line.taxPct),
|
||||
isFreeIssue: allowFreeItems && line.isFreeIssue,
|
||||
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">
|
||||
<Label className="text-sm">Item {idx + 1}</Label>
|
||||
<p className="py-1 text-sm font-medium text-foreground">{itemLabel(line.itemId)}</p>
|
||||
{(line.batchNo || line.serialNo) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Lot: {line.batchNo ?? line.serialNo}</p>
|
||||
)}
|
||||
{renderTracking(line)}
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex min-h-11 items-center text-sm font-medium text-foreground">{itemLabel(line.itemId)}</div>
|
||||
{(line.batchNo || line.serialNo) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Lot: {line.batchNo ?? line.serialNo}
|
||||
</p>
|
||||
)}
|
||||
{renderTracking(line)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<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 { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
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 { Button, buttonVariants } from "@/components/ui/button"
|
||||
@@ -29,6 +32,14 @@ interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
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
|
||||
@@ -38,11 +49,12 @@ function newKey() {
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return { key: newKey(), itemId: null, qtyDelta: "" }
|
||||
return { key: newKey(), itemId: null, qtyDelta: "", serials: [], serialNos: "" }
|
||||
}
|
||||
|
||||
export default function NewAdjustmentPage() {
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | 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))
|
||||
}
|
||||
|
||||
// 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() {
|
||||
setSubmitError(null)
|
||||
|
||||
@@ -110,6 +157,13 @@ export default function NewAdjustmentPage() {
|
||||
for (const line of lines) {
|
||||
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 (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)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
@@ -120,6 +174,8 @@ export default function NewAdjustmentPage() {
|
||||
const payloadLines: CreateAdjustmentLineInput[] = lines.map((l) => ({
|
||||
itemId: l.itemId as number,
|
||||
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)
|
||||
@@ -290,6 +346,7 @@ export default function NewAdjustmentPage() {
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
|
||||
{renderSerials(line)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -333,6 +390,7 @@ export default function NewAdjustmentPage() {
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[lineErrors[line.key] ? { message: lineErrors[line.key] } : undefined]} />
|
||||
{renderSerials(line)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<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 { Bin, ItemListItem, Warehouse } from "@/types/master-data"
|
||||
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 { Input } from "@/components/ui/input"
|
||||
@@ -29,6 +32,11 @@ interface DraftLine {
|
||||
itemId: number | null
|
||||
destBinId: number | null
|
||||
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
|
||||
@@ -38,11 +46,12 @@ function newKey() {
|
||||
}
|
||||
|
||||
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() {
|
||||
const router = useRouter()
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | 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))
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
setSubmitError(null)
|
||||
|
||||
@@ -132,8 +161,11 @@ export default function NewTransferPage() {
|
||||
|
||||
const nextLineErrors: Record<string, string> = {}
|
||||
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"
|
||||
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)
|
||||
if (Object.keys(nextLineErrors).length > 0) {
|
||||
@@ -146,6 +178,8 @@ export default function NewTransferPage() {
|
||||
srcBinId,
|
||||
destBinId: l.destBinId,
|
||||
qty: Number(l.qty),
|
||||
batchId: l.batchId,
|
||||
serialIds: l.serials.length > 0 ? l.serials.map((s) => s.serialId) : null,
|
||||
}))
|
||||
|
||||
setSubmitting(true)
|
||||
@@ -269,7 +303,7 @@ export default function NewTransferPage() {
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
@@ -281,6 +315,7 @@ export default function NewTransferPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{renderTracking(line)}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="mt-6 shrink-0" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||
<Trash2 className="size-5" />
|
||||
@@ -337,7 +372,7 @@ export default function NewTransferPage() {
|
||||
{lines.map((line) => (
|
||||
<TableRow key={line.key}>
|
||||
<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">
|
||||
<SelectValue placeholder="Select item" />
|
||||
</SelectTrigger>
|
||||
@@ -349,6 +384,7 @@ export default function NewTransferPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{renderTracking(line)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 align-top">
|
||||
<Select<number | null> value={line.destBinId} onValueChange={(v) => updateLine(line.key, { destBinId: v })}>
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
Scale,
|
||||
ScanLine,
|
||||
ScrollText,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
@@ -60,6 +61,9 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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
|
||||
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
|
||||
@@ -148,6 +152,9 @@ const navItems: {
|
||||
children: [
|
||||
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
||||
{ 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: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
|
||||
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
|
||||
@@ -427,6 +434,8 @@ export function AppSidebar() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
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
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
@@ -452,9 +461,10 @@ export function AppSidebar() {
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: bypassCodes.has(item.code)
|
||||
children: (bypassCodes.has(item.code)
|
||||
? 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
|
||||
|
||||
@@ -30,6 +30,9 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
counts: "Counts",
|
||||
"reorder-alerts": "Reorder Alerts",
|
||||
wastage: "Wastage",
|
||||
serials: "Serials",
|
||||
batches: "Batch Expiry",
|
||||
warranties: "Warranties",
|
||||
procurement: "Procurement",
|
||||
requisitions: "Requisitions",
|
||||
rfqs: "RFQs",
|
||||
|
||||
@@ -88,6 +88,8 @@ const STOCK_TITLES: Record<string, string> = {
|
||||
"/dashboard/stock/reorder-alerts": "Reorder Alerts",
|
||||
"/dashboard/stock/wastage": "Wastage",
|
||||
"/dashboard/stock/wastage/new": "Record Wastage",
|
||||
"/dashboard/stock/serials": "Serials",
|
||||
"/dashboard/stock/batches": "Batch Expiry",
|
||||
}
|
||||
|
||||
const PRODUCTION_TITLES: Record<string, string> = {
|
||||
@@ -101,6 +103,7 @@ const PRODUCTION_TITLES: Record<string, string> = {
|
||||
function titleFromPath(pathname: string) {
|
||||
if (pathname === "/dashboard/sales/invoices") return "Sales"
|
||||
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/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 { errorMessage } from "@/lib/error-map"
|
||||
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 { ReasonCode } from "@/types/stock"
|
||||
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -25,6 +26,10 @@ import { toast } from "@/components/ui/toast"
|
||||
interface LineState {
|
||||
selected: boolean
|
||||
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 {
|
||||
@@ -43,7 +48,10 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
|
||||
const [reasonCodes, setReasonCodes] = useState<ReasonCode[]>([])
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [remainingByLine, setRemainingByLine] = useState<Record<number, number>>({})
|
||||
const [allocationsByLine, setAllocationsByLine] = useState<Record<number, SalesLineAllocation[]>>({})
|
||||
const [remainingLoading, setRemainingLoading] = useState(false)
|
||||
const [markDamaged, setMarkDamaged] = useState(false)
|
||||
const { serialNumbersEnabled } = useTrackingSettings()
|
||||
|
||||
const [invoiceId, setInvoiceId] = useState<number | null>(presetInvoiceId)
|
||||
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
|
||||
@@ -83,8 +91,13 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
|
||||
.getRemaining(selectedInvoice.salesInvoiceId)
|
||||
.then((rows) => {
|
||||
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)
|
||||
setAllocationsByLine(allocations)
|
||||
if (presetLineId) {
|
||||
const remaining = map[presetLineId] ?? 0
|
||||
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 } }))
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
setHeaderError(null)
|
||||
setSubmitError(null)
|
||||
@@ -141,6 +222,11 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
|
||||
qty: lineState[line.salesInvoiceLineId].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
|
||||
}
|
||||
setLineErrors(nextErrors)
|
||||
@@ -153,6 +239,8 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
|
||||
salesInvoiceLineId: l.salesInvoiceLineId,
|
||||
itemId: l.itemId,
|
||||
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)
|
||||
@@ -161,6 +249,7 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
|
||||
customerId: selectedInvoice.customerId,
|
||||
warehouseId: selectedInvoice.warehouseId,
|
||||
reasonCodeId,
|
||||
markDamaged: markDamaged || null,
|
||||
lines: payloadLines,
|
||||
})
|
||||
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>
|
||||
</Select>
|
||||
</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>
|
||||
|
||||
{headerError && (
|
||||
@@ -242,6 +335,7 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
|
||||
{item ? `${item.sku} — ${item.name}` : line.description}
|
||||
</span>
|
||||
</label>
|
||||
{renderLineTracking(line)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
@@ -305,7 +399,10 @@ export function SalesReturnForm({ presetInvoiceId = null, presetLineId = null, o
|
||||
<TableCell>
|
||||
<Checkbox checked={state.selected} disabled={fullyReturned} onCheckedChange={() => toggleLine(line)} />
|
||||
</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">
|
||||
{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). */
|
||||
badge?: string
|
||||
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"
|
||||
@@ -27,6 +29,29 @@ const badgeToneClass: Record<SuggestBadgeTone, string> = {
|
||||
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 {
|
||||
options: SuggestOption[]
|
||||
/** Called with the picked option's value; the box then clears for the next entry. */
|
||||
@@ -259,11 +284,11 @@ export function SuggestInput({
|
||||
>
|
||||
{option.detail ? (
|
||||
<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>
|
||||
) : (
|
||||
<span className="truncate">{option.label}</span>
|
||||
<OptionLabel option={option} />
|
||||
)}
|
||||
{(option.badge || option.hint) && (
|
||||
<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 }
|
||||
}
|
||||
@@ -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)}`)
|
||||
},
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// (over-receipt tolerance, referential existence, concurrency) are never
|
||||
// re-implemented here; the server's ProblemDetails is the final word (§3.3).
|
||||
import { z } from "zod"
|
||||
import { Warranty } from "@/types/master-data"
|
||||
import { TrackingMode, Warranty } from "@/types/master-data"
|
||||
|
||||
export const grnHeaderSchema = z.object({
|
||||
warehouseId: z.number({ error: "Select a warehouse" }).positive("Select a warehouse"),
|
||||
@@ -18,6 +18,9 @@ export function validateLine(input: {
|
||||
discountPct: string
|
||||
vatPct: string
|
||||
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[]
|
||||
}): 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))
|
||||
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)
|
||||
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) {
|
||||
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) {
|
||||
errors.warrantyNumbers = "Warranty numbers must be unique"
|
||||
errors.warrantyNumbers = `${serial ? "Serial" : "Warranty"} numbers must be unique`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// 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.
|
||||
//
|
||||
// KNOWN GAP — serial numbers: FR-GRN-04 requires capturing serial numbers on receipt for
|
||||
// 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.
|
||||
// Serial numbers (FR-GRN-04) are captured per unit for Serial-tracked items — see
|
||||
// docs/ITEM_TRACKING_PLAN.md Phase 2.
|
||||
|
||||
export type GrnStatus = "Draft" | "Confirmed" | "Closed"
|
||||
export type HoldStatus = "OnHold" | "Available" | "Rejected"
|
||||
@@ -51,9 +48,15 @@ export interface CreateGrnLineInput {
|
||||
batch?: BatchInput | null
|
||||
/**
|
||||
* 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
|
||||
/**
|
||||
* 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 {
|
||||
@@ -90,6 +93,15 @@ export interface GrnLine {
|
||||
holdStatus: HoldStatus
|
||||
batchId: number | null
|
||||
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 {
|
||||
|
||||
@@ -265,6 +265,8 @@ export interface CreatePurchaseReturnLineInput {
|
||||
grnLineId?: number | null
|
||||
itemId: number
|
||||
qty: number
|
||||
/** Serial-tracked item: the serials sent back (become ReturnedToVendor). */
|
||||
serialIds?: number[] | null
|
||||
}
|
||||
|
||||
export interface CreatePurchaseReturnRequest {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SalesLineSerial } from "@/types/tracking"
|
||||
import { EntityStatus } from "@/types/common"
|
||||
|
||||
export type SalesInvoiceType = "B2B" | "B2C" | "Cash" | "Credit"
|
||||
@@ -24,6 +25,8 @@ export interface SalesInvoiceLine {
|
||||
taxAmount: number
|
||||
isFreeIssue: boolean
|
||||
parentLineId: number | null
|
||||
batchId: number | null
|
||||
serials: SalesLineSerial[]
|
||||
}
|
||||
|
||||
export interface SalesInvoiceTotals {
|
||||
@@ -197,6 +200,10 @@ export interface CreateSalesInvoiceLineRequest {
|
||||
taxPct: number
|
||||
isFreeIssue: boolean
|
||||
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 {
|
||||
@@ -230,6 +237,9 @@ export interface SalesSlipLine {
|
||||
taxAmount: number
|
||||
isFreeIssue: boolean
|
||||
parentLineId: number | null
|
||||
batchId: number | null
|
||||
serialId: number | null
|
||||
serials: SalesLineSerial[]
|
||||
}
|
||||
|
||||
export interface SalesSlipTotals {
|
||||
@@ -283,6 +293,12 @@ export interface CreateSalesSlipLineRequest {
|
||||
taxPct: number
|
||||
isFreeIssue: boolean
|
||||
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 {
|
||||
@@ -409,6 +425,9 @@ export interface SalesReturnLine {
|
||||
salesInvoiceLineId: number | null
|
||||
itemId: number
|
||||
qty: number
|
||||
/** Batch restocked into — the batch the goods were sold from. */
|
||||
batchId: number | null
|
||||
serials: SalesLineSerial[]
|
||||
}
|
||||
|
||||
export interface SalesReturn {
|
||||
@@ -444,12 +463,28 @@ export interface SalesReturnSummary {
|
||||
export interface SalesInvoiceLineRemaining {
|
||||
salesInvoiceLineId: 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 {
|
||||
salesInvoiceLineId?: number | null
|
||||
itemId: 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 {
|
||||
@@ -457,6 +492,8 @@ export interface CreateSalesReturnRequest {
|
||||
warehouseId: number
|
||||
/** Mandatory; omitting it returns 400 REASON_CODE_REQUIRED. */
|
||||
reasonCodeId: number
|
||||
/** Returned serials become Damaged instead of InStock. Omit to derive from the reason (DEF/QREJ ⇒ damaged). */
|
||||
markDamaged?: boolean | null
|
||||
lines: CreateSalesReturnLineInput[]
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface TransferLine {
|
||||
qty: number
|
||||
/** Accrues on partial receive; qty - qtyReceived is what is still in transit. */
|
||||
qtyReceived: number
|
||||
/** Serial-tracked item: the units moved; `received` once each has arrived. */
|
||||
serials: { serialId: number; serialNo: string; received: boolean }[]
|
||||
}
|
||||
|
||||
export interface StockTransfer {
|
||||
@@ -106,8 +108,11 @@ export interface CreateTransferLineInput {
|
||||
itemId: number
|
||||
srcBinId?: number | null
|
||||
destBinId?: number | null
|
||||
/** Omit for a batch-tracked item to have the server split the line FEFO across batches. */
|
||||
batchId?: number | null
|
||||
qty: number
|
||||
/** Serial-tracked item: one in-stock serial (at the source) per unit. */
|
||||
serialIds?: number[] | null
|
||||
}
|
||||
|
||||
export interface CreateTransferRequest {
|
||||
@@ -136,6 +141,8 @@ export interface DispatchTransferResponse {
|
||||
export interface ReceiveTransferLineInput {
|
||||
transferLineId: number
|
||||
qty: number
|
||||
/** Serial line: which in-transit serials arrived; omit when receiving all outstanding. */
|
||||
serialIds?: number[] | null
|
||||
}
|
||||
|
||||
export interface ReceiveTransferRequest {
|
||||
@@ -169,6 +176,7 @@ export interface AdjustmentLine {
|
||||
batchId: number | null
|
||||
/** Signed: negative FIFO-consumes, positive creates a layer. */
|
||||
qtyDelta: number
|
||||
serialId: number | null
|
||||
}
|
||||
|
||||
export interface StockAdjustment {
|
||||
@@ -202,6 +210,10 @@ export interface CreateAdjustmentLineInput {
|
||||
binId?: number | null
|
||||
batchId?: number | null
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user