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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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.1–5.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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user