serial numbers

This commit is contained in:
2026-10-07 14:00:32 +05:30
committed by ImanThiyanga
parent 3abcfa6e6f
commit a788349138
89 changed files with 15320 additions and 216 deletions
+29 -8
View File
@@ -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);
}
+38 -5
View File
@@ -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;
+121 -10
View File
@@ -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,
+12 -7
View File
@@ -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)
+73 -1
View File
@@ -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());
}
+148 -17
View File
@@ -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);
}
+222 -12
View File
@@ -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);
}
+12 -11
View File
@@ -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);
}
}
}
+21 -5
View File
@@ -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);
}
+112 -19
View File
@@ -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());
}