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";
|
||||
|
||||
Reference in New Issue
Block a user