feat: Add new services and interfaces for GRN, Purchase Return, Reason Code, Reorder, Stock, and Transfer functionalities

- Implemented IGrnService for managing goods receipts including retrieval, creation, and confirmation.
- Created IPurchaseReturnService for handling purchase return operations.
- Added IReasonCodeService for managing reason codes with listing and creation capabilities.
- Developed IReorderService for fetching reorder alerts and creating suggested requisitions.
- Introduced IStockMutator for applying stock changes and posting ledger entries.
- Established IStockService for stock inquiries, ledger retrieval, and valuation.
- Created ITransferService for managing inter-warehouse transfers including dispatch and receiving operations.
- Implemented PurchaseReturnService to handle purchase return logic and stock adjustments.
- Developed ReasonCodeService for listing and creating reason codes.
- Created ReorderService for fetching reorder alerts and generating requisitions.
- Implemented FifoCostingService for FIFO cost-layer management and ledger writing.
- Developed StockMutator for applying stock deltas and posting ledger entries.
- Created StockService for stock inquiries and ledger management.
- Implemented TransferService for managing inter-warehouse transfers with dispatch and receive functionalities.
This commit is contained in:
2026-07-14 10:20:38 +05:30
parent 4e84a15db7
commit 22f86451e3
90 changed files with 13496 additions and 19 deletions
@@ -0,0 +1,104 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Stock-adjustment service — the highest-risk feature (02-SECURITY C.5). Auto-posts
/// with a mandatory reason code and user stamp. Line application (FIFO consume on a
/// decrease, layer create on an increase) + ledger posting is delegated to
/// <see cref="IStockMutator"/>. Runs in a single UoW transaction (NFR-02/05).
/// </summary>
public sealed class AdjustmentService : IAdjustmentService
{
private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for an adjustment.", 400);
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422);
if (reason.Context != ReasonContext.Adjustment)
throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422);
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 now = DateTime.UtcNow;
var deltas = request.Lines
.Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList();
var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var entity = new StockAdjustment
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select(l => new StockAdjustmentLine
{
ItemId = l.ItemId,
BinId = l.BinId,
BatchId = l.BatchId,
QtyDelta = l.QtyDelta
}).ToList()
};
await _adjustments.AddAsync(entity, token);
await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token);
return (entity, refs);
}, ct);
return new AdjustmentDto(
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
adjustment.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList());
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class AuditService : IAuditService
{
private readonly IRepository<AuditLog> _logs;
private readonly IRepository<JournalEntryStub> _journal;
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal)
{
_logs = logs;
_journal = journal;
}
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
string? entityType, long? entityId, long? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
{
var q = _logs.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(entityType)) q = q.Where(l => l.EntityType == entityType);
if (entityId is not null) q = q.Where(l => l.EntityId == entityId);
if (userId is not null) q = q.Where(l => l.UserId == userId);
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.AuditId)
.Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
var dtos = rows.Select(l => new AuditLogDto(
l.AuditId, l.UserId, l.EntityType, l.EntityId, l.Action,
JsonSerializer.Deserialize<JsonElement>(l.ChangeSet), l.CreatedAt)).ToList();
return PagedResponse<AuditLogDto>.Create(dtos, query.Page, query.PageSize, total);
}
public async Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
string? sourceDocType, long? sourceDocId, PageQuery query, CancellationToken ct = default)
{
var q = _journal.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(sourceDocType)) q = q.Where(j => j.SourceDocType == sourceDocType);
if (sourceDocId is not null) q = q.Where(j => j.SourceDocId == sourceDocId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(j => j.JournalId)
.Skip(query.Skip).Take(query.PageSize)
.Select(j => new JournalEntryStubDto(j.JournalId, j.SourceDocType, j.SourceDocId, j.DebitAccount, j.CreditAccount, j.Amount))
.ToListAsync(ct);
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
}
}
+181
View File
@@ -0,0 +1,181 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Stock-count service (FR-STK-08). Create snapshots system quantities (immutable,
/// 02-SECURITY C.7); posting emits a variance <see cref="StockAdjustment"/> via the
/// shared <see cref="IStockMutator"/> (a variance is an adjustment in disguise, C.7)
/// and closes the count — all in one UoW transaction.
/// </summary>
public sealed class CountService : ICountService
{
private const string VarianceReasonCode = "VAR";
private readonly IRepository<StockCount> _counts;
private readonly IRepository<StockAdjustment> _adjustments;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IFifoCostingService _fifo;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public CountService(
IRepository<StockCount> counts, IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IFifoCostingService fifo, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_counts = counts;
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_fifo = fifo;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<CountDto?> GetAsync(long countId, CancellationToken ct = default)
{
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
.FirstOrDefaultAsync(c => c.CountId == countId, ct);
return count is null ? null : Map(count);
}
public async Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default)
{
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var itemIds = request.ItemIds.Distinct().ToList();
foreach (var id in itemIds)
if (!await _items.Query().AnyAsync(i => i.ItemId == id, ct))
throw new DomainException(ErrorCodes.Validation, $"Item {id} does not exist.", 422);
// Snapshot system quantities now (immutable once opened).
var lines = new List<StockCountLine>();
foreach (var id in itemIds)
{
var systemQty = await _fifo.GetOnHandAsync(id, request.WarehouseId, ct);
lines.Add(new StockCountLine { ItemId = id, SystemQty = systemQty });
}
var count = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Count, token);
var entity = new StockCount
{
DocNo = docNo,
WarehouseId = request.WarehouseId,
CountType = request.CountType,
Status = CountStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
Lines = lines
};
await _counts.AddAsync(entity, token);
return entity;
}, ct);
return Map(count);
}
public async Task<CountDto> EnterCountsAsync(long countId, EnterCountsRequest request, CancellationToken ct = default)
{
var count = await _counts.Query().Include(c => c.Lines)
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
?? throw new NotFoundException($"Count {countId} was not found.");
if (count.Status == CountStatus.Posted)
throw new ConflictException($"Count {countId} is already posted.");
foreach (var input in request.Lines)
{
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);
line.CountedQty = input.CountedQty;
line.Variance = input.CountedQty - line.SystemQty;
}
count.Status = CountStatus.Counted;
await _uow.SaveChangesAsync(ct);
return Map(count);
}
public async Task<CountPostResultDto> PostAsync(long countId, CancellationToken ct = default)
{
var count = await _counts.Query().Include(c => c.Lines)
.FirstOrDefaultAsync(c => c.CountId == countId, ct)
?? throw new NotFoundException($"Count {countId} was not found.");
if (count.Status == CountStatus.Posted)
throw new ConflictException($"Count {countId} is already posted.");
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))
.ToList();
// No variances → just close the count, no adjustment.
if (deltas.Count == 0)
{
count.Status = CountStatus.Posted;
await _uow.SaveChangesAsync(ct);
return new CountPostResultDto(count.CountId, count.Status, null, Array.Empty<long>());
}
var reason = await _reasonCodes.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Context == ReasonContext.Adjustment && r.Code == VarianceReasonCode, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Reason code '{VarianceReasonCode}' (Count Variance) is not configured.", 422);
var now = DateTime.UtcNow;
var (adjustmentId, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Adjustment, token);
var adjustment = new StockAdjustment
{
DocNo = docNo,
WarehouseId = count.WarehouseId,
ReasonCodeId = reason.ReasonCodeId,
Status = AdjustmentStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = deltas.Select(d => new StockAdjustmentLine
{
ItemId = d.ItemId,
BinId = d.BinId,
QtyDelta = d.QtyDelta
}).ToList()
};
await _adjustments.AddAsync(adjustment, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token);
count.Status = CountStatus.Posted;
return (adjustment.AdjustmentId, refs);
}, ct);
// Map ledger ids after commit so they are populated.
return new CountPostResultDto(count.CountId, count.Status, adjustmentId, ledgerEntries.Select(r => r.LedgerId).ToList());
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+357
View File
@@ -0,0 +1,357 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Goods-receipt service. Create builds a Draft GRN (cost derived from the PO line
/// for PO-based receipts — 02-SECURITY C.3; over-receipt tolerance enforced).
/// Confirm posts FIFO layers + inbound ledger and updates PO receipts inside one
/// UoW transaction (NFR-02/05). Quantities are converted to the item's base UOM
/// for the ledger/layers (FR-MD-03).
/// </summary>
public sealed class GrnService : IGrnService
{
// Phase 1: block any receipt beyond the PO line's open quantity (configurable later, NFR-10).
private const decimal OverReceiptTolerance = 0m;
private readonly IRepository<Grn> _grns;
private readonly IRepository<PurchaseOrder> _pos;
private readonly IRepository<PoLine> _poLines;
private readonly IRepository<Item> _items;
private readonly IRepository<Uom> _uoms;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Bin> _bins;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Batch> _batches;
private readonly IRepository<UomConversion> _conversions;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches,
IRepository<UomConversion> conversions, IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_grns = grns;
_pos = pos;
_poLines = poLines;
_items = items;
_uoms = uoms;
_warehouses = warehouses;
_bins = bins;
_vendors = vendors;
_batches = batches;
_conversions = conversions;
_layers = layers;
_ledger = ledger;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<GrnDto?> GetAsync(long grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
.Include(g => g.Lines)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct);
return grn is null ? null : Map(grn);
}
public async Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default)
{
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
// Resolve vendor + PO context.
PurchaseOrder? po = null;
long vendorId;
if (request.PoId is not null)
{
po = await _pos.Query().AsNoTracking().Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == request.PoId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Purchase order {request.PoId} does not exist.", 422);
if (po.Status is not (PurchaseOrderStatus.Approved or PurchaseOrderStatus.PartiallyReceived))
throw new ConflictException($"Purchase order {po.PoId} is {po.Status} and cannot be received against.");
vendorId = po.VendorId;
}
else
{
if (request.VendorId is null)
throw new DomainException(ErrorCodes.Validation, "vendorId is required for a direct (no-PO) receipt.", 422);
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
vendorId = request.VendorId.Value;
}
var lines = new List<GrnLine>();
var batchCache = new Dictionary<(long ItemId, string BatchNo), Batch>();
foreach (var input in request.Lines)
{
var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == input.ItemId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"Item {input.ItemId} does not exist.", 422);
if (!await _uoms.Query().AnyAsync(u => u.UomId == input.UomId, ct))
throw new DomainException(ErrorCodes.Validation, $"UOM {input.UomId} does not exist.", 422);
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
// Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct.
decimal unitCost;
if (input.PoLineId is not null)
{
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
?? throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is not on purchase order {request.PoId}.", 422);
if (poLine.ItemId != input.ItemId)
throw new DomainException(ErrorCodes.Validation, $"PO line {input.PoLineId} is for a different item.", 422);
var openQty = poLine.Qty - poLine.QtyReceived;
if (input.Qty > openQty * (1 + OverReceiptTolerance))
throw new DomainException(ErrorCodes.OverReceiptTolerance,
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
unitCost = poLine.UnitPrice;
}
else
{
unitCost = input.UnitCost;
}
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
lines.Add(new GrnLine
{
PoLineId = input.PoLineId,
ItemId = input.ItemId,
UomId = input.UomId,
BinId = input.BinId,
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty,
UnitCost = unitCost,
ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero),
HoldStatus = input.HoldStatus
});
}
var grn = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Grn, token);
var entity = new Grn
{
DocNo = docNo,
PoId = request.PoId,
VendorId = vendorId,
WarehouseId = request.WarehouseId,
Status = GrnStatus.Draft,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = DateTime.UtcNow,
Lines = lines
};
await _grns.AddAsync(entity, token);
return entity;
}, ct);
return Map(grn);
}
public async Task<GrnConfirmResultDto> ConfirmAsync(long grnId, string? idempotencyKey, CancellationToken ct = default)
{
var grn = await _grns.Query()
.Include(g => g.Lines)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
// Idempotent replay: an already-confirmed GRN returns its existing result.
if (grn.Status == GrnStatus.Confirmed)
return await BuildConfirmResultAsync(grn, ct);
if (grn.Status == GrnStatus.Closed)
throw new ConflictException($"GRN {grnId} is closed.");
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var runningBalance = new Dictionary<(long, long), decimal>();
var createdLayers = new List<StockLayer>();
var ledgerRefs = new List<StockLedger>();
await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token);
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))
bal = await _fifo.GetOnHandAsync(line.ItemId, grn.WarehouseId, token);
bal += qtyBase;
runningBalance[key] = bal;
var entry = await _fifo.PostLedgerAsync(
line.ItemId, grn.WarehouseId, line.BinId, line.BatchId, null, actor,
Direction.In, qtyBase, unitCostBase, bal, DocumentTypes.Grn, grn.GrnId, now, token);
ledgerRefs.Add(entry);
if (line.PoLineId is not null)
{
var poLine = await _poLines.GetByIdAsync(line.PoLineId.Value, token);
if (poLine is not null) poLine.QtyReceived += line.Qty;
}
}
grn.Status = GrnStatus.Confirmed;
grn.PostedAt = now;
await UpdatePoStatusAsync(grn.PoId, token);
return 0;
}, ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, now,
createdLayers.Select(ToCreatedLayer).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList(),
await GetPoStatusAsync(grn.PoId, ct));
}
public async Task<ReleaseLineResultDto> ReleaseLineAsync(long grnId, long grnLineId, string action, CancellationToken ct = default)
{
var grn = await _grns.Query()
.Include(g => g.Lines)
.FirstOrDefaultAsync(g => g.GrnId == grnId, ct)
?? throw new NotFoundException($"GRN {grnId} was not found.");
var line = grn.Lines.FirstOrDefault(l => l.GrnLineId == grnLineId)
?? throw new NotFoundException($"GRN line {grnLineId} was not found on GRN {grnId}.");
if (grn.Status != GrnStatus.Confirmed)
throw new ConflictException($"GRN {grnId} must be confirmed before releasing holds.");
if (line.HoldStatus != HoldStatus.OnHold)
throw new ConflictException($"GRN line {grnLineId} is {line.HoldStatus}, not OnHold.");
if (string.Equals(action, "Release", StringComparison.OrdinalIgnoreCase))
{
line.HoldStatus = HoldStatus.Available;
await _uow.SaveChangesAsync(ct);
return new ReleaseLineResultDto(grnLineId, HoldStatus.Available);
}
// Reject: remove the held stock from on-hand and post a reversing ledger entry.
// Linking rejected stock to a formal purchase return is deferred (§3.4).
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
await _uow.ExecuteInTransactionAsync(async token =>
{
line.HoldStatus = HoldStatus.Rejected;
var layers = await _layers.Query()
.Where(l => l.GrnLineId == grnLineId && l.QtyRemaining > 0).ToListAsync(token);
foreach (var layer in layers)
{
var bal = await _fifo.GetOnHandAsync(layer.ItemId, layer.WarehouseId, token) - layer.QtyRemaining;
await _fifo.PostLedgerAsync(
layer.ItemId, layer.WarehouseId, line.BinId, layer.BatchId, null, actor,
Direction.Out, layer.QtyRemaining, layer.UnitCost, bal, DocumentTypes.Grn, grn.GrnId, now, token);
layer.QtyRemaining = 0;
}
return 0;
}, ct);
return new ReleaseLineResultDto(grnLineId, HoldStatus.Rejected);
}
private async Task<Batch?> ResolveBatchAsync(
Item item, BatchInput? batch, Dictionary<(long, string), Batch> cache, CancellationToken ct)
{
if (item.TrackingMode == TrackingMode.Batch && batch is null)
throw new DomainException(ErrorCodes.Validation, $"Item {item.ItemId} is batch-tracked; a batch is required.", 422);
if (batch is null) return null;
var key = (item.ItemId, batch.BatchNo.Trim());
if (cache.TryGetValue(key, out var cached)) return cached;
var existing = await _batches.Query().FirstOrDefaultAsync(b => b.ItemId == item.ItemId && b.BatchNo == key.Item2, ct);
if (existing is not null)
{
cache[key] = existing;
return existing;
}
var created = new Batch { ItemId = item.ItemId, BatchNo = key.Item2, ExpiryDate = batch.ExpiryDate };
await _batches.AddAsync(created, ct);
cache[key] = created;
return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges
}
private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync(
Item item, long uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct)
{
if (uomId == item.BaseUomId)
return (qty, unitCostPerUom);
var conv = await _conversions.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422);
return (qty * conv.Factor, unitCostPerUom / conv.Factor);
}
private async Task UpdatePoStatusAsync(long? poId, CancellationToken ct)
{
if (poId is null) return;
var po = await _pos.Query().Include(p => p.Lines).FirstOrDefaultAsync(p => p.PoId == poId, ct);
if (po is null) return;
po.Status = po.Lines.All(l => l.QtyReceived >= l.Qty)
? PurchaseOrderStatus.FullyReceived
: PurchaseOrderStatus.PartiallyReceived;
po.UpdatedAt = DateTime.UtcNow;
}
private async Task<PurchaseOrderStatus?> GetPoStatusAsync(long? poId, CancellationToken ct)
{
if (poId is null) return null;
return await _pos.Query().AsNoTracking().Where(p => p.PoId == poId).Select(p => (PurchaseOrderStatus?)p.Status).FirstOrDefaultAsync(ct);
}
private async Task<GrnConfirmResultDto> BuildConfirmResultAsync(Grn grn, CancellationToken ct)
{
var lineIds = grn.Lines.Select(l => l.GrnLineId).ToList();
var layers = await _layers.Query().AsNoTracking()
.Where(l => l.GrnLineId != null && lineIds.Contains(l.GrnLineId.Value)).ToListAsync(ct);
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Grn && l.SourceDocId == grn.GrnId && l.Direction == Direction.In)
.Select(l => l.LedgerId).ToListAsync(ct);
return new GrnConfirmResultDto(
grn.GrnId, grn.Status, grn.PostedAt ?? grn.CreatedAt,
layers.Select(ToCreatedLayer).ToList(), ledgerRefs, await GetPoStatusAsync(grn.PoId, ct));
}
private static CreatedLayerDto ToCreatedLayer(StockLayer l) => new(
l.LayerId, l.ItemId, l.WarehouseId, l.BatchId, l.QtyReceived, l.QtyRemaining, l.UnitCost, l.ReceiptDate);
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList());
}
@@ -0,0 +1,9 @@
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
public interface IAdjustmentService
{
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,17 @@
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
/// 02-SECURITY B.2.3). Both are append-only; no write API.
/// </summary>
public interface IAuditService
{
Task<PagedResponse<AuditLogDto>> ListLogsAsync(
string? entityType, long? entityId, long? userId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
string? sourceDocType, long? sourceDocId, PageQuery query, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Cycle/full stock-count business logic (docs/11 §5.6; FR-STK-08).</summary>
public interface ICountService
{
Task<CountDto?> GetAsync(long countId, CancellationToken ct = default);
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
Task<CountDto> EnterCountsAsync(long countId, EnterCountsRequest request, CancellationToken ct = default);
Task<CountPostResultDto> PostAsync(long countId, CancellationToken ct = default);
}
@@ -0,0 +1,46 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// FIFO cost-layer and ledger domain service (00-CORE §4, docs/10 Part A.2).
/// Invoked by stock services **inside** a UoW transaction — never from a
/// controller/repository. This turn covers inbound layer creation, ledger posting
/// and valuation; oldest-first consumption (issues/transfers/adjustments) arrives
/// with §5 and must row-lock the layers it consumes (NFR-02).
/// </summary>
public interface IFifoCostingService
{
/// <summary>Create an inbound FIFO layer (qty and unit cost in the item's base UOM).</summary>
Task<StockLayer> CreateInboundLayerAsync(
long itemId, long warehouseId, long? batchId, long? serialId, long? grnLineId,
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default);
/// <summary>
/// Consume <paramref name="qtyBase"/> from open layers oldest-first, row-locking
/// the affected layers for the transaction (NFR-02). Skips on-hold and expired
/// stock. Throws <c>STOCK_NEGATIVE_BLOCKED</c> if issuable stock is insufficient,
/// <c>EXPIRED_BATCH_BLOCKED</c>/<c>ONHOLD_NOT_ISSUABLE</c> for an explicit batch
/// that is expired/held. Returns the consumed segments (for cost-preserving moves
/// and ledger costing). Must run inside a UoW transaction.
/// </summary>
Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
long itemId, long warehouseId, long? batchId, decimal qtyBase, CancellationToken ct = default);
/// <summary>Append an immutable ledger entry (value = qtyBase × unitCost).</summary>
Task<StockLedger> PostLedgerAsync(
long itemId, long warehouseId, long? binId, long? batchId, long? serialId, long userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, long sourceDocId, DateTime createdAt, CancellationToken ct = default);
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
Task<decimal> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default);
/// <summary>Valuation over open layers: Σ(qtyRemaining × unitCost) (FR-STK-04).</summary>
Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default);
}
/// <summary>A quantity consumed from one FIFO layer at that layer's unit cost.</summary>
public sealed record ConsumedSegment(long LayerId, decimal Qty, decimal UnitCost);
@@ -0,0 +1,15 @@
using ERPCore.Dtos.Grn;
namespace ERPCore.Services.Interfaces;
/// <summary>Goods-receipt business logic (docs/11 §4; FR-GRN-01..08).</summary>
public interface IGrnService
{
Task<GrnDto?> GetAsync(long grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts, atomically.</summary>
Task<GrnConfirmResultDto> ConfirmAsync(long grnId, string? idempotencyKey, CancellationToken ct = default);
Task<ReleaseLineResultDto> ReleaseLineAsync(long grnId, long grnLineId, string action, CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using ERPCore.Dtos.Procurement;
namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-return business logic (docs/11 §3.4; FR-PROC-08).</summary>
public interface IPurchaseReturnService
{
Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference;
namespace ERPCore.Services.Interfaces;
/// <summary>Reason-code reference data (docs/11 §6; FR-X-04).</summary>
public interface IReasonCodeService
{
Task<PagedResponse<ReasonCodeDto>> ListAsync(ReasonContext? context, PageQuery query, CancellationToken ct = default);
Task<ReasonCodeDto> CreateAsync(CreateReasonCodeRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary>
public interface IReorderService
{
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(long? warehouseId, PageQuery query, CancellationToken ct = default);
Task<RequisitionDto> CreateSuggestedRequisitionAsync(long itemId, long warehouseId, CancellationToken ct = default);
}
@@ -0,0 +1,21 @@
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(long ItemId, long? BinId, long? BatchId, decimal QtyDelta);
/// <summary>
/// Shared poster for stock-affecting documents (adjustments, count variances,
/// purchase returns). Applies signed deltas — negative consumes FIFO layers
/// oldest-first (row-locked, negative-stock blocked), positive creates a layer at
/// last cost — and appends the ledger entries. Runs inside the caller's UoW
/// transaction (the caller owns numbering, the header, and the commit); the
/// document must already be saved so its id is a valid ledger <c>sourceDocId</c>.
/// </summary>
public interface IStockMutator
{
Task<IReadOnlyList<StockLedger>> ApplyAsync(
long warehouseId, string sourceDocType, long sourceDocId, DateTime now,
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Read-side stock enquiry, ledger and valuation (docs/11 §5.15.3).</summary>
public interface IStockService
{
Task<StockOnHandDto> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default);
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
long? itemId, long? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default);
Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
/// <summary>Inter-warehouse transfer business logic (docs/11 §5.4; FR-STK-05/06).</summary>
public interface ITransferService
{
Task<TransferDto?> GetAsync(long transferId, CancellationToken ct = default);
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
/// <summary>Dispatch: consume source FIFO layers into in-transit (row-locked).</summary>
Task<DispatchResultDto> DispatchAsync(long transferId, CancellationToken ct = default);
/// <summary>Receive: create the destination layer at the inherited (cost-preserving) cost.</summary>
Task<ReceiveResultDto> ReceiveAsync(long transferId, ReceiveTransferRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,115 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Procurement;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Purchase-return service (FR-PROC-08). Auto-posts with a mandatory Return reason
/// code and generates an outbound stock movement via the shared
/// <see cref="IStockMutator"/> (FIFO consume, row-locked; over-return beyond
/// available → STOCK_NEGATIVE_BLOCKED). Single UoW transaction.
/// </summary>
public sealed class PurchaseReturnService : IPurchaseReturnService
{
private readonly IRepository<PurchaseReturn> _returns;
private readonly IRepository<Vendor> _vendors;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<GrnLine> _grnLines;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public PurchaseReturnService(
IRepository<PurchaseReturn> returns, IRepository<Vendor> vendors, IRepository<Warehouse> warehouses,
IRepository<Item> items, IRepository<ReasonCode> reasonCodes, IRepository<GrnLine> grnLines,
IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_returns = returns;
_vendors = vendors;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_grnLines = grnLines;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PurchaseReturnDto> CreateAsync(CreatePurchaseReturnRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired, "A reason code is required for a purchase return.", 400);
if (!await _vendors.Query().AnyAsync(v => v.VendorId == request.VendorId, ct))
throw new DomainException(ErrorCodes.Validation, $"Vendor {request.VendorId} does not exist.", 422);
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var reason = await _reasonCodes.Query().AsNoTracking().FirstOrDefaultAsync(r => r.ReasonCodeId == request.ReasonCodeId, ct)
?? 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);
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);
if (line.GrnLineId is not null)
{
var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct)
?? throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} does not exist.", 422);
if (grnLine.ItemId != line.ItemId)
throw new DomainException(ErrorCodes.Validation, $"GRN line {line.GrnLineId} is for a different item.", 422);
}
}
var now = DateTime.UtcNow;
var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList();
var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.PurchaseReturn, token);
var ret = new PurchaseReturn
{
DocNo = docNo,
VendorId = request.VendorId,
WarehouseId = request.WarehouseId,
ReasonCodeId = request.ReasonCodeId.Value,
Status = ReturnStatus.Posted,
CreatedBy = _currentUser.AuditUserId,
CreatedAt = now,
Lines = request.Lines.Select(l => new PurchaseReturnLine
{
GrnLineId = l.GrnLineId,
ItemId = l.ItemId,
Qty = l.Qty
}).ToList()
};
await _returns.AddAsync(ret, token);
await _uow.SaveChangesAsync(token); // flush for a valid ledger sourceDocId
var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.PurchaseReturn, ret.ReturnId, now, deltas, token);
return (ret, refs);
}, ct);
// Map ledger ids after commit so they are populated.
return new PurchaseReturnDto(
entity.ReturnId, entity.DocNo, entity.VendorId, entity.WarehouseId, entity.ReasonCodeId, entity.Status,
entity.CreatedBy,
entity.Lines.OrderBy(l => l.ReturnLineId)
.Select(l => new PurchaseReturnLineDto(l.ReturnLineId, l.GrnLineId, l.ItemId, l.Qty)).ToList(),
ledgerEntries.Select(r => r.LedgerId).ToList());
}
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Reference;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
public sealed class ReasonCodeService : IReasonCodeService
{
private readonly IRepository<ReasonCode> _codes;
private readonly IUnitOfWork _uow;
public ReasonCodeService(IRepository<ReasonCode> codes, IUnitOfWork uow)
{
_codes = codes;
_uow = uow;
}
public async Task<PagedResponse<ReasonCodeDto>> ListAsync(ReasonContext? context, PageQuery query, CancellationToken ct = default)
{
var q = _codes.Query().AsNoTracking();
if (context is not null) q = q.Where(r => r.Context == context);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(r => r.Context).ThenBy(r => r.Code)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => new ReasonCodeDto(r.ReasonCodeId, r.Code, r.Description, r.Context))
.ToListAsync(ct);
return PagedResponse<ReasonCodeDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ReasonCodeDto> CreateAsync(CreateReasonCodeRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim().ToUpperInvariant();
if (await _codes.Query().AnyAsync(r => r.Context == request.Context && r.Code == code, ct))
throw new ConflictException($"Reason code '{code}' already exists in context {request.Context}.");
var entity = new ReasonCode { Code = code, Description = request.Description.Trim(), Context = request.Context };
await _codes.AddAsync(entity, ct);
await _uow.SaveChangesAsync(ct);
return new ReasonCodeDto(entity.ReasonCodeId, entity.Code, entity.Description, entity.Context);
}
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Dtos.Stock;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Reorder alerts are a query, not a stored entity (docs/10 C.9): available stock
/// (from the FIFO layers) is compared to <see cref="ItemReorder"/> policy on read.
/// </summary>
public sealed class ReorderService : IReorderService
{
private readonly IRepository<ItemReorder> _reorders;
private readonly IStockService _stock;
private readonly IRequisitionService _requisitions;
public ReorderService(IRepository<ItemReorder> reorders, IStockService stock, IRequisitionService requisitions)
{
_reorders = reorders;
_stock = stock;
_requisitions = requisitions;
}
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(long? warehouseId, PageQuery query, CancellationToken ct = default)
{
var q = _reorders.Query().AsNoTracking();
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
var policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct);
var alerts = new List<ReorderAlertDto>();
foreach (var p in policies)
{
var available = (await _stock.GetOnHandAsync(p.ItemId, p.WarehouseId, ct)).Available;
if (available <= p.ReorderPoint)
alerts.Add(new ReorderAlertDto(p.ItemId, p.WarehouseId, available, p.ReorderPoint, p.ReorderQty, p.ReorderQty));
}
var page = alerts.Skip(query.Skip).Take(query.PageSize).ToList();
return PagedResponse<ReorderAlertDto>.Create(page, query.Page, query.PageSize, alerts.Count);
}
public async Task<RequisitionDto> CreateSuggestedRequisitionAsync(long itemId, long warehouseId, CancellationToken ct = default)
{
var policy = await _reorders.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.ItemId == itemId && r.WarehouseId == warehouseId, ct)
?? throw new DomainException(ErrorCodes.Validation,
$"No reorder policy for item {itemId} at warehouse {warehouseId}.", 422);
return await _requisitions.CreateAsync(new CreateRequisitionRequest
{
Lines = [new CreateRequisitionLineInput { ItemId = itemId, Qty = policy.ReorderQty }]
}, ct);
}
}
@@ -0,0 +1,183 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Persistence;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <summary>
/// FIFO cost-layer + ledger writer and valuation reader (docs/10 Part A.2).
/// Writes (layer/ledger creation) are added to the tracked context and persisted
/// by the caller's UoW transaction; they are never saved here.
/// </summary>
public sealed class FifoCostingService : IFifoCostingService
{
public const string BaseCurrency = "LKR";
public const string Method = "FIFO";
// Phase-1 placeholder GL accounts (data only, no posting — FR-STK-13).
private const string InventoryAccount = "1300";
private const string ClearingAccount = "2100";
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<JournalEntryStub> _journal;
private readonly ErpDbContext _db;
public FifoCostingService(
IRepository<StockLayer> layers, IRepository<StockLedger> ledger,
IRepository<JournalEntryStub> journal, ErpDbContext db)
{
_layers = layers;
_ledger = ledger;
_journal = journal;
_db = db;
}
public async Task<StockLayer> CreateInboundLayerAsync(
long itemId, long warehouseId, long? batchId, long? serialId, long? grnLineId,
decimal qtyBase, decimal unitCost, DateTime receiptDate, CancellationToken ct = default)
{
var layer = new StockLayer
{
ItemId = itemId,
WarehouseId = warehouseId,
BatchId = batchId,
SerialId = serialId,
GrnLineId = grnLineId,
QtyReceived = qtyBase,
QtyRemaining = qtyBase,
UnitCost = unitCost,
ReceiptDate = receiptDate
};
await _layers.AddAsync(layer, ct);
return layer;
}
public async Task<IReadOnlyList<ConsumedSegment>> ConsumeAsync(
long itemId, long warehouseId, long? batchId, decimal qtyBase, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (batchId is not null)
{
var expiry = await _db.Batches.AsNoTracking()
.Where(b => b.BatchId == batchId).Select(b => b.ExpiryDate).FirstOrDefaultAsync(ct);
if (expiry is not null && expiry < today)
throw new DomainException(ErrorCodes.ExpiredBatchBlocked,
$"Batch {batchId} expired on {expiry:yyyy-MM-dd} and cannot be issued.", 409);
}
// Row-lock the issuable open layers oldest-first (SELECT … FOR UPDATE, NFR-02).
// Excludes on-hold (grn line) and expired-batch stock. No LINQ is composed on
// top of the raw SQL so the FOR UPDATE reaches the database intact.
// {batchId}::bigint casts give Npgsql an explicit type for the (possibly null)
// parameter — without it a null batch filter fails with 42P18.
var layers = await _db.StockLayers
.FromSqlInterpolated($"""
SELECT * FROM stock_layers sl
WHERE sl."ItemId" = {itemId} AND sl."WarehouseId" = {warehouseId} AND sl."QtyRemaining" > 0
AND ({batchId}::bigint IS NULL OR sl."BatchId" = {batchId}::bigint)
AND NOT EXISTS (SELECT 1 FROM grn_lines gl WHERE gl."GrnLineId" = sl."GrnLineId" AND gl."HoldStatus" = 'OnHold')
AND NOT EXISTS (SELECT 1 FROM batches b WHERE b."BatchId" = sl."BatchId" AND b."ExpiryDate" < {today})
ORDER BY sl."ReceiptDate", sl."LayerId"
FOR UPDATE
""")
.ToListAsync(ct);
var available = layers.Sum(l => l.QtyRemaining);
if (available < qtyBase)
{
var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l =>
l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0
&& (batchId == null || l.BatchId == batchId)
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold, ct);
if (heldExists)
throw new DomainException(ErrorCodes.OnHoldNotIssuable,
$"Stock for item {itemId} at warehouse {warehouseId} is on inspection hold and cannot be issued.", 409);
throw new DomainException(ErrorCodes.StockNegativeBlocked,
$"Available {available} < requested {qtyBase} for item {itemId} at warehouse {warehouseId}.", 409);
}
var segments = new List<ConsumedSegment>();
var remaining = qtyBase;
foreach (var layer in layers)
{
if (remaining <= 0) break;
var take = Math.Min(remaining, layer.QtyRemaining);
layer.QtyRemaining -= take;
remaining -= take;
segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost));
}
return segments;
}
public async Task<StockLedger> PostLedgerAsync(
long itemId, long warehouseId, long? binId, long? batchId, long? serialId, long userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, long sourceDocId, DateTime createdAt, CancellationToken ct = default)
{
var entry = new StockLedger
{
ItemId = itemId,
WarehouseId = warehouseId,
BinId = binId,
BatchId = batchId,
SerialId = serialId,
UserId = userId,
Direction = direction,
QtyBase = qtyBase,
UnitCost = unitCost,
Value = Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero),
RunningBalance = runningBalance,
SourceDocType = sourceDocType,
SourceDocId = sourceDocId,
CreatedAt = createdAt
};
await _ledger.AddAsync(entry, ct);
// GL-ready journal entry per movement (FR-STK-13; data only, no posting).
// Inbound debits Inventory / credits Clearing; outbound reverses.
var (debit, credit) = direction == Direction.In
? (InventoryAccount, ClearingAccount)
: (ClearingAccount, InventoryAccount);
await _journal.AddAsync(new JournalEntryStub
{
SourceDocType = sourceDocType,
SourceDocId = sourceDocId,
DebitAccount = debit,
CreditAccount = credit,
Amount = entry.Value
}, ct);
return entry;
}
public async Task<decimal> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default)
=> await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
public async Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default)
{
var open = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0)
.OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId)
.Select(l => new { l.LayerId, l.QtyRemaining, l.UnitCost, l.ReceiptDate })
.ToListAsync(ct);
var layers = open.Select(l => new StockValuationLayerDto(
l.LayerId, l.QtyRemaining, l.UnitCost,
Math.Round(l.QtyRemaining * l.UnitCost, 4, MidpointRounding.AwayFromZero), l.ReceiptDate)).ToList();
return new StockValuationDto(
itemId, warehouseId, layers,
layers.Sum(l => l.QtyRemaining), layers.Sum(l => l.Value), BaseCurrency, Method);
}
}
@@ -0,0 +1,77 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
/// <summary>
/// Applies signed stock deltas for a document and posts the ledger (see
/// <see cref="IStockMutator"/>). Running balances are tracked in-memory per
/// (item, warehouse), seeded from current on-hand, so multiple lines for the same
/// item chain correctly within the transaction.
/// </summary>
public sealed class StockMutator : IStockMutator
{
private readonly IFifoCostingService _fifo;
private readonly IRepository<StockLayer> _layers;
private readonly ICurrentUser _currentUser;
public StockMutator(IFifoCostingService fifo, IRepository<StockLayer> layers, ICurrentUser currentUser)
{
_fifo = fifo;
_layers = layers;
_currentUser = currentUser;
}
public async Task<IReadOnlyList<StockLedger>> ApplyAsync(
long warehouseId, string sourceDocType, long sourceDocId, DateTime now,
IReadOnlyList<StockDelta> deltas, CancellationToken ct = default)
{
var actor = _currentUser.AuditUserId;
var balances = new Dictionary<long, decimal>();
var entries = new List<StockLedger>();
foreach (var d in deltas)
{
if (d.QtyDelta == 0) continue;
if (!balances.TryGetValue(d.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct);
StockLedger entry;
if (d.QtyDelta < 0)
{
var qty = -d.QtyDelta;
var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct);
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,
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);
bal += d.QtyDelta;
entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor,
Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct);
}
balances[d.ItemId] = bal;
entries.Add(entry);
}
return entries;
}
private async Task<decimal> LastCostAsync(long itemId, long warehouseId, CancellationToken ct)
=> await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId)
.OrderByDescending(l => l.ReceiptDate).ThenByDescending(l => l.LayerId)
.Select(l => (decimal?)l.UnitCost).FirstOrDefaultAsync(ct) ?? 0m;
}
@@ -0,0 +1,76 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Stock;
public sealed class StockService : IStockService
{
private readonly IFifoCostingService _fifo;
private readonly IRepository<StockLayer> _layers;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<StockTransferLine> _transferLines;
public StockService(
IFifoCostingService fifo, IRepository<StockLayer> layers,
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
{
_fifo = fifo;
_layers = layers;
_ledger = ledger;
_transferLines = transferLines;
}
public async Task<StockOnHandDto> GetOnHandAsync(long itemId, long warehouseId, CancellationToken ct = default)
{
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
// On-hold stock is on-hand but not issuable — sourced from GRN lines still OnHold.
var onHold = await _layers.Query().AsNoTracking()
.Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId
&& l.GrnLine != null && l.GrnLine.HoldStatus == HoldStatus.OnHold)
.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
// Outbound in-transit: dispatched from this warehouse, not yet received at the
// destination. Dispatch already consumed the source layers, so this stock has
// left onHand — it is reported for visibility (AR-05) but is NOT re-subtracted
// from available (that would double-count). reserved (sales) stays a stub.
var inTransit = await _transferLines.Query().AsNoTracking()
.Where(l => l.ItemId == itemId
&& l.Transfer!.SrcWarehouseId == warehouseId
&& l.Transfer.Status == TransferStatus.InTransit)
.SumAsync(l => (decimal?)(l.Qty - l.QtyReceived), ct) ?? 0m;
const decimal reserved = 0m;
var available = onHand - onHold - reserved;
return new StockOnHandDto(itemId, warehouseId, onHand, available, onHold, inTransit, reserved, DateTime.UtcNow);
}
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
long? itemId, long? warehouseId, DateOnly? from, DateOnly? to, PageQuery query, CancellationToken ct = default)
{
var q = _ledger.Query().AsNoTracking();
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LedgerId)
.Skip(query.Skip).Take(query.PageSize)
.Select(l => new StockLedgerRowDto(
l.LedgerId, l.ItemId, l.WarehouseId, l.BinId, l.BatchId, l.SerialId,
l.Direction, l.QtyBase, l.UnitCost, l.Value, l.RunningBalance,
l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt))
.ToListAsync(ct);
return PagedResponse<StockLedgerRowDto>.Create(rows, query.Page, query.PageSize, total);
}
public Task<StockValuationDto> GetValuationAsync(long itemId, long warehouseId, CancellationToken ct = default)
=> _fifo.GetValuationAsync(itemId, warehouseId, ct);
}
+196
View File
@@ -0,0 +1,196 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Inter-warehouse transfer service (FR-STK-05/06). Dispatch consumes source FIFO
/// layers (row-locked; negative-stock blocked) and records the value-weighted cost
/// on the line; receive recreates the destination layer at that cost
/// (cost-preserving — no revaluation). Both run in a single UoW transaction.
/// </summary>
public sealed class TransferService : ITransferService
{
private readonly IRepository<StockTransfer> _transfers;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IFifoCostingService _fifo;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public TransferService(
IRepository<StockTransfer> transfers, IRepository<Warehouse> warehouses, IRepository<Item> items,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_transfers = transfers;
_warehouses = warehouses;
_items = items;
_fifo = fifo;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<TransferDto?> GetAsync(long transferId, CancellationToken ct = default)
{
var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines)
.FirstOrDefaultAsync(x => x.TransferId == transferId, ct);
return t is null ? null : Map(t);
}
public async Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default)
{
if (request.SrcWarehouseId == request.DestWarehouseId)
throw new DomainException(ErrorCodes.Validation, "destWarehouseId must differ from srcWarehouseId.", 422);
await EnsureWarehouseAsync(request.SrcWarehouseId, ct);
await EnsureWarehouseAsync(request.DestWarehouseId, ct);
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 actor = _currentUser.AuditUserId;
var transfer = await _uow.ExecuteInTransactionAsync(async token =>
{
var docNo = await _numbers.NextAsync(DocumentTypes.Transfer, token);
var entity = new StockTransfer
{
DocNo = docNo,
SrcWarehouseId = request.SrcWarehouseId,
DestWarehouseId = request.DestWarehouseId,
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()
};
await _transfers.AddAsync(entity, token);
return entity;
}, ct);
return Map(transfer);
}
public async Task<DispatchResultDto> DispatchAsync(long transferId, CancellationToken ct = default)
{
var transfer = await _transfers.Query().Include(t => t.Lines)
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
?? throw new NotFoundException($"Transfer {transferId} was not found.");
if (transfer.Status != TransferStatus.Draft)
throw new ConflictException($"Transfer {transferId} is {transfer.Status}; only a Draft can be dispatched.");
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var consumed = new List<ConsumedLayerDto>();
var ledgerRefs = new List<StockLedger>();
var balances = new Dictionary<long, decimal>();
await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId))
{
var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token);
var value = segments.Sum(s => s.Qty * s.UnitCost);
line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive
if (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token);
bal -= line.Qty;
balances[line.ItemId] = bal;
var entry = await _fifo.PostLedgerAsync(
line.ItemId, transfer.SrcWarehouseId, line.SrcBinId, line.BatchId, null, actor,
Direction.Out, line.Qty, line.UnitCost.Value, bal, DocumentTypes.Transfer, transfer.TransferId, now, token);
ledgerRefs.Add(entry);
consumed.AddRange(segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)));
}
transfer.Status = TransferStatus.InTransit;
return 0;
}, ct);
return new DispatchResultDto(transfer.TransferId, transfer.Status, consumed, ledgerRefs.Select(l => l.LedgerId).ToList());
}
public async Task<ReceiveResultDto> ReceiveAsync(long transferId, ReceiveTransferRequest request, CancellationToken ct = default)
{
var transfer = await _transfers.Query().Include(t => t.Lines)
.FirstOrDefaultAsync(t => t.TransferId == transferId, ct)
?? throw new NotFoundException($"Transfer {transferId} was not found.");
if (transfer.Status != TransferStatus.InTransit)
throw new ConflictException($"Transfer {transferId} is {transfer.Status}; only an InTransit transfer can be received.");
var actor = _currentUser.AuditUserId;
var now = DateTime.UtcNow;
var createdLayers = new List<StockLayer>();
var ledgerRefs = new List<StockLedger>();
var balances = new Dictionary<long, decimal>();
await _uow.ExecuteInTransactionAsync(async token =>
{
foreach (var input in request.Lines)
{
var line = transfer.Lines.FirstOrDefault(l => l.TransferLineId == input.TransferLineId)
?? throw new DomainException(ErrorCodes.Validation, $"Transfer line {input.TransferLineId} is not on transfer {transferId}.", 422);
var outstanding = line.Qty - line.QtyReceived;
if (input.Qty > outstanding)
throw new DomainException(ErrorCodes.Validation,
$"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 (!balances.TryGetValue(line.ItemId, out var bal))
bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.DestWarehouseId, token);
bal += input.Qty;
balances[line.ItemId] = bal;
var entry = await _fifo.PostLedgerAsync(
line.ItemId, transfer.DestWarehouseId, line.DestBinId, line.BatchId, null, actor,
Direction.In, input.Qty, unitCost, bal, DocumentTypes.Transfer, transfer.TransferId, now, token);
ledgerRefs.Add(entry);
line.QtyReceived += input.Qty;
}
if (transfer.Lines.All(l => l.QtyReceived >= l.Qty))
transfer.Status = TransferStatus.Received;
return 0;
}, ct);
// Map after commit so layer ids are populated.
var created = createdLayers
.Select(l => new TransferCreatedLayerDto(l.LayerId, l.WarehouseId, l.QtyReceived, l.UnitCost))
.ToList();
return new ReceiveResultDto(transfer.TransferId, transfer.Status, created, ledgerRefs.Select(l => l.LedgerId).ToList());
}
private async Task EnsureWarehouseAsync(long warehouseId, CancellationToken ct)
{
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == warehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422);
}
private static TransferDto Map(StockTransfer t) => new(
t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.Status,
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());
}