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:
Dhananjaya99
2026-10-18 14:49:26 +05:30
parent 7da8834ce9
commit ce35973b58
12 changed files with 347 additions and 91 deletions
@@ -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)]
+2 -1
View File
@@ -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);
}
+57 -7
View File
@@ -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)