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
@@ -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)