feat: Enhance Stock Ledger functionality with item details and filters
- Updated StockService to compute running balance on read and include item SKU, name, category, and brand in StockLedgerRowDto. - Modified GetLedgerAsync method to accept categoryId and brandId parameters for filtering. - Enhanced StockController to support new query parameters for ledger retrieval. - Updated StockDtos to include additional item information in StockLedgerRowDto. - Adjusted frontend Stock Ledger page to display item details and implement searchable item picker. - Added tests for StockLedger to verify running balance calculations and filtering by category and brand. - Updated appsettings for service URLs and modified login page navigation logic.
This commit is contained in:
@@ -58,8 +58,9 @@ public sealed class StockController : ApiControllerBase
|
||||
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
||||
[FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct));
|
||||
[FromQuery] PageQuery query, CancellationToken ct,
|
||||
[FromQuery] int? categoryId = null, [FromQuery] int? brandId = null)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, branchId, from, to, sourceDocType, sourceDocId, query, ct, categoryId, brandId));
|
||||
|
||||
[HttpGet("valuation")]
|
||||
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -11,7 +11,8 @@ public sealed record StockOnHandDto(
|
||||
public sealed record StockLedgerRowDto(
|
||||
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
|
||||
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
|
||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt,
|
||||
string? ItemSku = null, string? ItemName = null, string? CategoryName = null, string? BrandName = null);
|
||||
|
||||
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
||||
|
||||
@@ -17,7 +17,8 @@ public interface IStockService
|
||||
|
||||
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default,
|
||||
int? categoryId = null, int? brandId = null);
|
||||
|
||||
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -15,16 +15,19 @@ public sealed class StockService : IStockService
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<StockTransferLine> _transferLines;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Item> _items;
|
||||
|
||||
public StockService(
|
||||
IFifoCostingService fifo, IRepository<StockLayer> layers,
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses)
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses,
|
||||
IRepository<Item> items)
|
||||
{
|
||||
_fifo = fifo;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_transferLines = transferLines;
|
||||
_warehouses = warehouses;
|
||||
_items = items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -146,10 +149,19 @@ public sealed class StockService : IStockService
|
||||
|
||||
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default,
|
||||
int? categoryId = null, int? brandId = null)
|
||||
{
|
||||
var q = _ledger.Query().AsNoTracking();
|
||||
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
|
||||
// Category / brand narrow the ledger to the items in them.
|
||||
if (categoryId is not null || brandId is not null)
|
||||
{
|
||||
var itemIds = _items.Query().Where(i => (categoryId == null || i.CategoryId == categoryId)
|
||||
&& (brandId == null || i.BrandId == brandId))
|
||||
.Select(i => i.ItemId);
|
||||
q = q.Where(l => itemIds.Contains(l.ItemId));
|
||||
}
|
||||
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
|
||||
var branchWarehouseIds = await ResolveBranchWarehouseIdsAsync(branchId, ct);
|
||||
if (branchWarehouseIds is not null) q = q.Where(l => branchWarehouseIds.Contains(l.WarehouseId));
|
||||
@@ -164,13 +176,51 @@ public sealed class StockService : IStockService
|
||||
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);
|
||||
// The running balance is the item's balance in that warehouse right after the movement: the sum of every
|
||||
// movement of the same (item, warehouse) up to and including this one (In adds, Out subtracts). It is worked out
|
||||
// here from the movements themselves rather than read from the stored column, which some postings (sales
|
||||
// issues) never filled in correctly - so it is right for old rows too and whatever filters are applied.
|
||||
var balances = new Dictionary<int, decimal>();
|
||||
foreach (var pair in rows.GroupBy(r => (r.ItemId, r.WarehouseId)))
|
||||
{
|
||||
var (itemKey, whKey) = pair.Key;
|
||||
var minId = pair.Min(r => r.LedgerId);
|
||||
var maxId = pair.Max(r => r.LedgerId);
|
||||
// Everything before the earliest row on this page, then step through the movements up to the latest one.
|
||||
var running = await _ledger.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == itemKey && x.WarehouseId == whKey && x.LedgerId < minId)
|
||||
.SumAsync(x => (decimal?)(x.Direction == Direction.In ? x.QtyBase : -x.QtyBase), ct) ?? 0m;
|
||||
var movements = await _ledger.Query().AsNoTracking()
|
||||
.Where(x => x.ItemId == itemKey && x.WarehouseId == whKey && x.LedgerId >= minId && x.LedgerId <= maxId)
|
||||
.OrderBy(x => x.LedgerId)
|
||||
.Select(x => new { x.LedgerId, Signed = x.Direction == Direction.In ? x.QtyBase : -x.QtyBase })
|
||||
.ToListAsync(ct);
|
||||
foreach (var m in movements)
|
||||
{
|
||||
running += m.Signed;
|
||||
balances[m.LedgerId] = running;
|
||||
}
|
||||
}
|
||||
|
||||
var ids = rows.Select(r => r.ItemId).Distinct().ToList();
|
||||
var items = await _items.Query().AsNoTracking()
|
||||
.Where(i => ids.Contains(i.ItemId))
|
||||
.Select(i => new { i.ItemId, i.Sku, i.Name, CategoryName = i.Category != null ? i.Category.Name : null, BrandName = i.Brand != null ? i.Brand.Name : null })
|
||||
.ToDictionaryAsync(i => i.ItemId, ct);
|
||||
|
||||
var dtos = rows.Select(l =>
|
||||
{
|
||||
items.TryGetValue(l.ItemId, out var item);
|
||||
return new StockLedgerRowDto(
|
||||
l.LedgerId, l.ItemId, l.WarehouseId, l.BinId, l.BatchId, l.SerialId,
|
||||
l.Direction, l.QtyBase, l.UnitCost, l.Value, balances[l.LedgerId],
|
||||
l.SourceDocType, l.SourceDocId, l.UserId, l.CreatedAt,
|
||||
item?.Sku, item?.Name, item?.CategoryName, item?.BrandName);
|
||||
}).ToList();
|
||||
|
||||
return PagedResponse<StockLedgerRowDto>.Create(dtos, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
|
||||
Reference in New Issue
Block a user