ce35973b58
- 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.
98 lines
4.9 KiB
C#
98 lines
4.9 KiB
C#
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);
|
|
}
|
|
}
|