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:
@@ -91,7 +91,8 @@ public class LowStockAlertServiceTests
|
||||
public Task<IReadOnlyDictionary<int, decimal>> GetAvailableByItemAsync(int warehouseId, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default,
|
||||
int? categoryId = null, int? brandId = null) => throw new NotSupportedException();
|
||||
public Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Services.Stock;
|
||||
using static ERPCore.Tests.TestSupport.BankDepositTestSupport;
|
||||
|
||||
namespace ERPCore.Tests;
|
||||
|
||||
/// <summary>The Stock Ledger screen: item name/SKU/category/brand on every row, category and brand filters, and a running
|
||||
/// balance that is the real balance after each movement (not whatever a posting stored).</summary>
|
||||
public class StockLedgerTests
|
||||
{
|
||||
private static StockService Svc(ErpDbContext db) =>
|
||||
new(null!, new Repository<StockLayer>(db), new Repository<StockLedger>(db), new Repository<StockTransferLine>(db),
|
||||
new Repository<Warehouse>(db), new Repository<Item>(db));
|
||||
|
||||
private static StockLedger Row(int id, int item, int warehouse, Direction dir, decimal qty, decimal unitCost, decimal storedBalance = 0m) => new()
|
||||
{
|
||||
LedgerId = id, ItemId = item, WarehouseId = warehouse, Direction = dir, QtyBase = qty, UnitCost = unitCost,
|
||||
Value = qty * unitCost, RunningBalance = storedBalance, SourceDocType = "T", SourceDocId = id, UserId = 1,
|
||||
CreatedAt = new DateTime(2026, 10, 18, 4, 0, 0, DateTimeKind.Utc).AddMinutes(id)
|
||||
};
|
||||
|
||||
private static async Task<ErpDbContext> SeedAsync()
|
||||
{
|
||||
var db = CreateContext();
|
||||
db.Categories.AddRange(new Category { CategoryId = 1, Name = "Beverages" }, new Category { CategoryId = 2, Name = "Snacks" });
|
||||
db.Brands.AddRange(new Brand { BrandId = 1, Name = "Milo" }, new Brand { BrandId = 2, Name = "Munchee" });
|
||||
db.Items.AddRange(
|
||||
new Item { ItemId = 1, Sku = "MILO-240", Name = "Milo 240ml", CategoryId = 1, BrandId = 1, BaseUomId = 1 },
|
||||
new Item { ItemId = 2, Sku = "BISC-1", Name = "Biscuit", CategoryId = 2, BrandId = 2, BaseUomId = 1 });
|
||||
db.StockLedger.AddRange(
|
||||
Row(1, 1, Warehouse1, Direction.In, 100m, 200m, storedBalance: 100m),
|
||||
Row(2, 1, Warehouse1, Direction.Out, 30m, 200m, storedBalance: 0m), // a sales issue: stored balance was never filled in
|
||||
Row(3, 1, Warehouse1, Direction.In, 50m, 210m, storedBalance: 120m),
|
||||
Row(4, 1, Warehouse2, Direction.In, 10m, 200m, storedBalance: 10m),
|
||||
Row(5, 2, Warehouse1, Direction.In, 5m, 40m, storedBalance: 5m));
|
||||
await db.SaveChangesAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static Task<PagedResponse<ERPCore.Dtos.Stock.StockLedgerRowDto>> Ledger(
|
||||
ErpDbContext db, int page = 1, int pageSize = 50, int? itemId = null, int? warehouseId = null, int? categoryId = null, int? brandId = null) =>
|
||||
Svc(db).GetLedgerAsync(itemId, warehouseId, null, null, null, null, null,
|
||||
new PageQuery { Page = page, PageSize = pageSize }, CancellationToken.None, categoryId, brandId);
|
||||
|
||||
[Fact]
|
||||
public async Task RunningBalance_IsTheRealBalanceAfterEachMovement_PerItemAndWarehouse()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
|
||||
var rows = (await Ledger(db)).Items.ToDictionary(r => r.LedgerId);
|
||||
|
||||
Assert.Equal(100m, rows[1].RunningBalance);
|
||||
Assert.Equal(70m, rows[2].RunningBalance); // the stored 0 is ignored
|
||||
Assert.Equal(120m, rows[3].RunningBalance);
|
||||
Assert.Equal(10m, rows[4].RunningBalance); // another warehouse starts from its own zero
|
||||
Assert.Equal(5m, rows[5].RunningBalance); // another item too
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunningBalance_IsStillRight_OnALaterPage_AndWithFiltersApplied()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
|
||||
// Newest first, 2 per page: page 2 holds ledger 3 and 2, whose balances depend on rows before and after it.
|
||||
var page2 = (await Ledger(db, page: 2, pageSize: 2)).Items;
|
||||
Assert.Equal(new long[] { 3, 2 }, page2.Select(r => (long)r.LedgerId));
|
||||
Assert.Equal(new[] { 120m, 70m }, page2.Select(r => r.RunningBalance));
|
||||
|
||||
// Filtering to one warehouse and item changes which rows show, not what the balance is.
|
||||
var only = (await Ledger(db, itemId: 1, warehouseId: Warehouse1)).Items.OrderBy(r => r.LedgerId).ToList();
|
||||
Assert.Equal(new[] { 100m, 70m, 120m }, only.Select(r => r.RunningBalance));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryRow_CarriesTheItemsSkuNameCategoryAndBrand()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
|
||||
var row = (await Ledger(db, itemId: 1)).Items.First();
|
||||
|
||||
Assert.Equal(("MILO-240", "Milo 240ml", "Beverages", "Milo"), (row.ItemSku, row.ItemName, row.CategoryName, row.BrandName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CategoryAndBrandFilters_NarrowTheLedgerToThoseItems()
|
||||
{
|
||||
await using var db = await SeedAsync();
|
||||
|
||||
Assert.All((await Ledger(db, categoryId: 2)).Items, r => Assert.Equal(2, r.ItemId));
|
||||
Assert.Equal(4, (await Ledger(db, brandId: 1)).Items.Count);
|
||||
Assert.Empty((await Ledger(db, categoryId: 2, brandId: 1)).Items);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -643,3 +643,8 @@ Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike
|
||||
- **Server rules (`CustomerService`):** a new value for a switched-off field is refused (`422 CONFIG_DISABLED`); on update a switched-off field **keeps its stored value** (a blank/zero from a form that no longer has the field can't wipe it). Tax registration and display name are not configurable.
|
||||
- **Where stored:** seven `bool` columns on `sales_config` (migration `AddCustomerFieldSwitches`, additive, default true, **not applied**). `useCustomerConfig` reads them from `GET /sales-config`; until it loads, the UI assumes all on.
|
||||
- **Tests:** `CustomerFieldSwitchTests` (12 cases). Suite 214/214.
|
||||
|
||||
### 2026-10-18 (5) — Stock Ledger: item columns, filters, running balance
|
||||
- **Running balance is now computed on read** (`StockService.GetLedgerAsync`): the sum of every movement of the same item + warehouse up to that row (In adds, Out subtracts). The stored `RunningBalance` column was wrong for sales issues (`SalesPostingService` posts `0`), so those rows showed 0; the read-time value is right for old rows, later pages and any filter. The column is no longer read.
|
||||
- **Rows carry** `itemSku`, `itemName`, `categoryName`, `brandName`; the endpoint takes `categoryId` and `brandId` (items in them). The page shows the item NAME with SKU, category and brand underneath, signed quantities (+In / −Out), unit cost with up to 4 decimals, Out values negative, and Category / Brand / searchable Item (SKU — name) / Warehouse filters; the item picker follows the category and brand.
|
||||
- **Tests:** `StockLedgerTests` (4). Suite 218/218. Not changed: `SalesPostingService` still writes `RunningBalance = 0` to the stored column (nothing reads it).
|
||||
|
||||
Reference in New Issue
Block a user