Merge pull request 'Dev' (#116) from Dev into production

Reviewed-on: #116
This commit was merged in pull request #116.
This commit is contained in:
2026-10-18 16:23:26 +05:30
41 changed files with 23530 additions and 183 deletions
+95 -6
View File
@@ -2,6 +2,7 @@ using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Approvals;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Notifications;
using ERPCore.Dtos.Products;
using ERPCore.Dtos.Stock;
@@ -28,7 +29,8 @@ public class DualApprovalTests
// Requester, two approvers, and a clerk who can approve but isn't a manager.
private const int Requester = 10, ApproverA = 11, ApproverB = 12, Clerk = 13;
private const int ApproverRoleId = 2, ClerkRoleId = 3;
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1;
// ReasonId is an ordinary adjustment reason (VAR); WastageReasonId is a wastage one (DMG), which has its own approval switch.
private const int ItemId = 1, WarehouseId = 1, ReasonId = 1, WastageReasonId = 2;
// --- Stock adjustments ------------------------------------------------------------------
@@ -226,11 +228,95 @@ public class DualApprovalTests
public void RequiredLevels_FollowTheSettings(int configured, double threshold, double measure, int expected)
=> Assert.Equal(expected, ApprovalService.Levels(configured, (decimal)threshold, (decimal)measure));
// --- Wastage: its own approval switch ------------------------------------------------------
[Fact]
public async Task Wastage_WithTheSwitchOff_ReducesStockAtOnce_EvenWhenAdjustmentsNeedTwoApprovals()
{
var h = await Harness.CreateAsync(stockLevels: 2, wastageLevels: 0);
var dto = await h.Stock.CreateAsync(Request(-3, WastageReasonId));
Assert.Equal(AdjustmentStatus.Posted, dto.Status);
Assert.Equal(0, dto.RequiredApprovals);
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task Wastage_WithTheSwitchOn_IsARequest_AndMovesNothingUntilApproved()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 2);
var dto = await h.Stock.CreateAsync(Request(-3, WastageReasonId));
Assert.Equal(AdjustmentStatus.PendingApproval, dto.Status);
Assert.Equal(2, dto.RequiredApprovals);
Assert.Empty(h.Mutator.Calls);
h.User.Be(ApproverA);
await h.Stock.ApproveAsync(dto.AdjustmentId, null);
Assert.Empty(h.Mutator.Calls); // one of two: still nothing
h.User.Be(ApproverB);
var posted = await h.Stock.ApproveAsync(dto.AdjustmentId, null);
Assert.Equal(AdjustmentStatus.Posted, posted.Status);
Assert.Single(h.Mutator.Calls);
}
[Fact]
public async Task TheWastageSwitch_DoesNotLoosenOrdinaryAdjustments_AndTheirLevelsDoNotTouchWastage()
{
var h = await Harness.CreateAsync(stockLevels: 2, wastageLevels: 0);
// An ordinary adjustment (not a wastage reason) still needs two approvals.
var ordinary = await h.Stock.CreateAsync(Request(-3));
Assert.Equal(AdjustmentStatus.PendingApproval, ordinary.Status);
// A stock GAIN with a wastage reason is not wastage, so it follows the adjustment levels too.
var gain = await h.Stock.CreateAsync(Request(+3, WastageReasonId));
Assert.Equal(AdjustmentStatus.PendingApproval, gain.Status);
}
[Fact]
public async Task WastageList_ReturnsOnlyAdjustmentsWithAWastageReason()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 0);
await h.Stock.CreateAsync(Request(-1, WastageReasonId));
await h.Stock.CreateAsync(Request(-2));
var all = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null);
var wastage = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true);
Assert.Equal(2, all.Items.Count);
var only = Assert.Single(wastage.Items);
Assert.Equal(WastageReasonId, only.ReasonCodeId);
}
[Fact]
public async Task WastageList_CanBeNarrowedToOneItem()
{
var h = await Harness.CreateAsync(stockLevels: 0, wastageLevels: 0);
await h.Stock.CreateAsync(Request(-1, WastageReasonId));
var forItem = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true, itemId: ItemId);
var otherItem = await h.Stock.ListAsync(new PageQuery { Page = 1, PageSize = 50 }, null, null, null, null, CancellationToken.None, wastageOnly: true, itemId: 999);
Assert.Single(forItem.Items);
Assert.Empty(otherItem.Items);
}
[Fact]
public void WastageApproval_IsOffByDefault_AndTheSettingIsReadBack()
{
Assert.Equal(0, new ApprovalConfig().WastageLevels);
Assert.True(ERPCore.Domain.WastageReasonCodes.IsWastage("dmg"));
Assert.False(ERPCore.Domain.WastageReasonCodes.IsWastage("VAR"));
}
// --- helpers ------------------------------------------------------------------------------
private static CreateAdjustmentRequest Request(decimal qty) => new()
private static CreateAdjustmentRequest Request(decimal qty, int reasonId = ReasonId) => new()
{
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = ReasonId,
WarehouseId = WarehouseId, BranchId = 1, ReasonCodeId = reasonId,
Lines = [new CreateAdjustmentLineInput { ItemId = ItemId, QtyDelta = qty }]
};
@@ -250,7 +336,7 @@ public class DualApprovalTests
public required AdjustmentService Stock { get; init; }
public required PriceAdjustmentService Prices { get; init; }
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null)
public static async Task<Harness> CreateAsync(int stockLevels = 2, decimal stockThreshold = 0, string? level2RoleIds = null, int wastageLevels = 0)
{
var db = new ErpDbContext(
new DbContextOptionsBuilder<ErpDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root).Options,
@@ -279,11 +365,14 @@ public class DualApprovalTests
db.Uoms.Add(new Uom { UomId = 1, Name = "pcs" });
db.Items.Add(new Item { ItemId = ItemId, Sku = "SKU-1", Name = "Cola", BaseUomId = 1, CategoryId = 1,
SellingMinPrice = 90m, SellingMaxPrice = 150m, SellingFlatPrice = 100m });
db.ReasonCodes.Add(new ReasonCode { ReasonCodeId = ReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
db.ReasonCodes.AddRange(
new ReasonCode { ReasonCodeId = ReasonId, Code = "VAR", Description = "Variance", Context = ReasonContext.Adjustment },
new ReasonCode { ReasonCodeId = WastageReasonId, Code = "DMG", Description = "Damage", Context = ReasonContext.Adjustment });
db.ApprovalConfig.Add(new ApprovalConfig
{
ApprovalConfigId = ApprovalConfig.SingletonId, StockAdjustmentLevels = stockLevels,
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds
StockAdjustmentLevel2ThresholdValue = stockThreshold, PriceAdjustmentLevels = 2, Level2RoleIds = level2RoleIds,
WastageLevels = wastageLevels
});
await db.SaveChangesAsync();
@@ -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();
}
+97
View File
@@ -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);
}
}
@@ -29,8 +29,8 @@ public sealed class StockAdjustmentsController : ApiControllerBase
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? branchId, [FromQuery] int? reasonCodeId,
[FromQuery] AdjustmentStatus? status, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct));
[FromQuery] AdjustmentStatus? status, CancellationToken ct, [FromQuery] bool wastage = false, [FromQuery] int? itemId = null)
=> Ok(await _adjustments.ListAsync(query, warehouseId, branchId, reasonCodeId, status, ct, wastage, itemId));
/// <summary>Get one adjustment with its lines, approval trail and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
@@ -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)]
@@ -35,6 +35,10 @@ public class ApprovalConfig
/// <summary>With 2 levels: an adjustment whose stock value is below this needs only one approval. 0 = always two.</summary>
public decimal StockAdjustmentLevel2ThresholdValue { get; set; }
/// <summary>Approvals wastage (damage / theft / expiry write-offs) needs: 0 = none, stock reduces on save (default);
/// 2 = two different approvers first. Separate from <see cref="StockAdjustmentLevels"/>, which governs every other adjustment.</summary>
public int WastageLevels { get; set; }
public int PriceAdjustmentLevels { get; set; } = 2;
/// <summary>With 2 levels: a change smaller than this percent (largest across its lines) needs only one approval. 0 = always two.</summary>
public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
@@ -60,6 +60,7 @@ public class GlAccountConfig
/// <summary>Cash takings at day-end (was <c>SalesDayEnd:GlCashAccountCode</c>).</summary>
public string? CashAccountCode { get; set; }
public string? AccruedExpensesCode { get; set; }
public DateTime? UpdatedAt { get; set; }
@@ -0,0 +1,13 @@
namespace ERPCore.Domain;
/// <summary>
/// Which Adjustment reason codes make a stock adjustment "wastage" (damage, theft, expiry). Wastage is stored as a stock
/// adjustment; this is what tells the two apart. Matches the frontend's <c>isWastageReasonCode</c> (lib/api/wastage.ts).
/// </summary>
public static class WastageReasonCodes
{
public static readonly IReadOnlyCollection<string> Codes = new[] { "DMG", "THEFT", "EXP" };
public static bool IsWastage(string? reasonCode) =>
reasonCode is not null && Codes.Contains(reasonCode.Trim(), StringComparer.OrdinalIgnoreCase);
}
@@ -10,7 +10,7 @@ public sealed record ApprovalStepDto(int Level, ApprovalDecision Decision, int D
public sealed record ApprovalConfigDto(
int StockAdjustmentLevels, decimal StockAdjustmentLevel2ThresholdValue,
int PriceAdjustmentLevels, decimal PriceAdjustmentLevel2ThresholdPercent,
IReadOnlyList<int> Level2RoleIds, DateTime? UpdatedAt);
IReadOnlyList<int> Level2RoleIds, DateTime? UpdatedAt, int WastageLevels = 0);
public sealed class UpdateApprovalConfigRequest
{
@@ -20,6 +20,9 @@ public sealed class UpdateApprovalConfigRequest
[Range(0, 1000)] public decimal PriceAdjustmentLevel2ThresholdPercent { get; set; }
/// <summary>Roles allowed to give the second approval; empty = anyone with the Approve action.</summary>
public List<int> Level2RoleIds { get; set; } = new();
/// <summary>0 = wastage posts on save, 2 = two approvals first. Null keeps the stored value (an older client that doesn't know the setting).</summary>
[Range(0, 2)] public int? WastageLevels { get; set; }
}
public sealed class ApproveRequest
@@ -47,4 +47,6 @@ public sealed class UpdateGlAccountConfigRequest
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
/// <summary>Optional so an older client that doesn't know this role can't blank it out: null keeps the stored code.</summary>
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccruedExpensesCode { get; set; }
}
+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);
@@ -35,7 +35,8 @@ public sealed class GlAccountConfigConfiguration : IEntityTypeConfiguration<GlAc
nameof(GlAccountConfig.InventoryGainAccountCode),
nameof(GlAccountConfig.CogsAccountCode),
nameof(GlAccountConfig.InventoryLossAccountCode),
nameof(GlAccountConfig.CashAccountCode)
nameof(GlAccountConfig.CashAccountCode),
nameof(GlAccountConfig.AccruedExpensesCode)
})
{
builder.Property(code).HasMaxLength(GlAccountConfig.CodeMaxLength);
@@ -304,7 +304,8 @@ public static class DataSeeder
InventoryGainAccountCode = Legacy("Adjustment:GlGainAccountCode"),
CogsAccountCode = Legacy("SalesDayEnd:GlCogsAccountCode"),
InventoryLossAccountCode = Legacy("Adjustment:GlLossAccountCode"),
CashAccountCode = Legacy("SalesDayEnd:GlCashAccountCode")
CashAccountCode = Legacy("SalesDayEnd:GlCashAccountCode"),
AccruedExpensesCode = Legacy("Expense:GlAccruedExpensesCode")
});
return true;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddWastageApprovalSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "WastageLevels",
table: "approval_config",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.UpdateData(
table: "approval_config",
keyColumn: "ApprovalConfigId",
keyValue: 1,
column: "WastageLevels",
value: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "WastageLevels",
table: "approval_config");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddAccruedExpensesGlRole : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AccruedExpensesCode",
table: "gl_account_config",
type: "varchar(20)",
maxLength: 20,
nullable: true,
collation: "utf8mb4_unicode_ci");
// Until now the accrued-expenses account lived only in appsettings (Expense:GlAccruedExpensesCode, "2300"); carry it
// over so an existing deployment keeps working and the code now shows - and is checked - on Settings -> GL Accounts.
migrationBuilder.Sql("UPDATE `gl_account_config` SET `AccruedExpensesCode` = '2300' WHERE `ConfigId` = 1 AND `AccruedExpensesCode` IS NULL;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AccruedExpensesCode",
table: "gl_account_config");
}
}
}
@@ -86,6 +86,9 @@ namespace ERPCore.Migrations
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.Property<int>("WastageLevels")
.HasColumnType("int");
b.HasKey("ApprovalConfigId");
b.ToTable("approval_config", null, t =>
@@ -101,7 +104,8 @@ namespace ERPCore.Migrations
PriceAdjustmentLevels = 2,
RowVersion = 1u,
StockAdjustmentLevel2ThresholdValue = 0m,
StockAdjustmentLevels = 2
StockAdjustmentLevels = 2,
WastageLevels = 0
});
});
@@ -2252,6 +2256,10 @@ namespace ERPCore.Migrations
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("AccruedExpensesCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("CashAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
+17 -2
View File
@@ -112,9 +112,19 @@ public sealed class AdjustmentService : IAdjustmentService
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null, CancellationToken ct = default)
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null,
CancellationToken ct = default, bool wastageOnly = false, int? itemId = null)
{
var q = _adjustments.Query().AsNoTracking();
// Adjustments that touch this item (the product page's activity).
if (itemId is not null) q = q.Where(a => a.Lines.Any(l => l.ItemId == itemId));
if (wastageOnly)
{
// Wastage = an adjustment with a damage / theft / expiry reason code.
var wastageCodes = WastageReasonCodes.Codes.Select(c => c.ToUpper()).ToList();
var wastageReasonIds = _reasonCodes.Query().Where(r => wastageCodes.Contains(r.Code.ToUpper())).Select(r => r.ReasonCodeId);
q = q.Where(a => wastageReasonIds.Contains(a.ReasonCodeId));
}
if (!string.IsNullOrWhiteSpace(query.Q))
{
@@ -189,7 +199,12 @@ public sealed class AdjustmentService : IAdjustmentService
// Validated only: posting resolves them again and creates any new ones exactly once.
DiscardUnsavedSerials();
var required = await _approvals.RequiredForStockAdjustmentAsync(value, ct);
// Wastage (a write-off with a damage / theft / expiry reason) has its own switch; every other adjustment and a
// stock-count variance keeps the stock-adjustment levels and threshold.
var isWastage = WastageReasonCodes.IsWastage(reason.Code) && request.Lines.All(l => l.QtyDelta < 0);
var required = isWastage
? await _approvals.RequiredForWastageAsync(ct)
: await _approvals.RequiredForStockAdjustmentAsync(value, ct);
var now = DateTime.UtcNow;
var (adjustment, refs) = await _uow.ExecuteInTransactionAsync(async token =>
+5 -1
View File
@@ -55,6 +55,7 @@ public sealed class ApprovalService : IApprovalService
config.StockAdjustmentLevels = Math.Clamp(request.StockAdjustmentLevels, 0, 2);
config.StockAdjustmentLevel2ThresholdValue = Math.Max(0m, request.StockAdjustmentLevel2ThresholdValue);
if (request.WastageLevels is int wastageLevels) config.WastageLevels = Math.Clamp(wastageLevels, 0, 2);
config.PriceAdjustmentLevels = Math.Clamp(request.PriceAdjustmentLevels, 0, 2);
config.PriceAdjustmentLevel2ThresholdPercent = Math.Max(0m, request.PriceAdjustmentLevel2ThresholdPercent);
var roleIds = request.Level2RoleIds.Where(id => id > 0).Distinct().ToList();
@@ -79,6 +80,9 @@ public sealed class ApprovalService : IApprovalService
return Levels(c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue, absoluteValue);
}
public async Task<int> RequiredForWastageAsync(CancellationToken ct = default)
=> Math.Clamp((await LoadConfigAsync(ct)).WastageLevels, 0, 2);
public async Task<int> RequiredForPriceAdjustmentAsync(decimal largestChangePercent, CancellationToken ct = default)
{
var c = await LoadConfigAsync(ct);
@@ -188,5 +192,5 @@ public sealed class ApprovalService : IApprovalService
private static ApprovalConfigDto Map(ApprovalConfig c) => new(
c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue,
c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent,
Level2RoleIds(c).Order().ToList(), c.UpdatedAt);
Level2RoleIds(c).Order().ToList(), c.UpdatedAt, c.WastageLevels);
}
@@ -27,11 +27,11 @@ public sealed class ExpensePaymentService : IExpensePaymentService
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
private readonly string _glAccruedExpensesCode;
private readonly IGlAccountConfigService _glAccounts;
public ExpensePaymentService(
IRepository<Expense> expenses, IRepository<ExpensePayment> payments, ExpenseService expenseService,
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration)
IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts)
{
_expenses = expenses;
_payments = payments;
@@ -39,7 +39,7 @@ public sealed class ExpensePaymentService : IExpensePaymentService
_gl = gl;
_currentUser = currentUser;
_uow = uow;
_glAccruedExpensesCode = configuration["Expense:GlAccruedExpensesCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<IReadOnlyList<ExpensePaymentDto>> PayAsync(int expenseId, CreateExpensePaymentRequest request, CancellationToken ct = default)
@@ -110,7 +110,8 @@ public sealed class ExpensePaymentService : IExpensePaymentService
{
var total = request.Lines.Sum(l => l.Amount);
var narration = $"Payment of expense {expense.DocNo}";
var glLines = new List<GlJournalEntryLineRequest> { new(_glAccruedExpensesCode, total, 0m, narration) };
var accruedExpensesCode = (await _glAccounts.GetCodesAsync(ct))[GlAccountRole.AccruedExpenses];
var glLines = new List<GlJournalEntryLineRequest> { new(accruedExpensesCode, total, 0m, narration) };
glLines.AddRange(request.Lines.Select(l => new GlJournalEntryLineRequest(accounts[l.GlBankAccountId].GlAccountCode, 0m, l.Amount, narration)));
var period = await _gl.GetPeriodByDateAsync(paymentDay, ct);
+7 -5
View File
@@ -32,12 +32,12 @@ public sealed class ExpenseService : IExpenseService
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
private readonly IGeneralLedgerService _gl;
private readonly string _glAccruedExpensesCode;
private readonly IGlAccountConfigService _glAccounts;
public ExpenseService(
IRepository<Expense> expenses, IRepository<ExpenseTitle> titles, IRepository<Vendor> vendors,
ICurrentUser currentUser, INumberSequenceService numbers, IBranchConfigService branchConfig,
IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration)
IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts)
{
_expenses = expenses;
_titles = titles;
@@ -47,7 +47,7 @@ public sealed class ExpenseService : IExpenseService
_branchConfig = branchConfig;
_uow = uow;
_gl = gl;
_glAccruedExpensesCode = configuration["Expense:GlAccruedExpensesCode"] ?? string.Empty;
_glAccounts = glAccounts;
}
public async Task<PagedResponse<ExpenseSummaryDto>> ListAsync(PageQuery query, ExpenseListFilter filter, CancellationToken ct = default)
@@ -174,6 +174,7 @@ public sealed class ExpenseService : IExpenseService
var now = DateTime.UtcNow;
var narration = $"{expense.Category?.Name} - {title.Name} ({expense.DocNo})";
var accruedExpensesCode = (await _glAccounts.GetCodesAsync(ct))[GlAccountRole.AccruedExpenses];
var period = await _gl.GetPeriodByDateAsync(expense.ExpenseDate, ct);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
@@ -185,7 +186,7 @@ public sealed class ExpenseService : IExpenseService
Lines =
[
new GlJournalEntryLineRequest(title.GlExpenseAccountCode, expense.Amount, 0m, narration),
new GlJournalEntryLineRequest(_glAccruedExpensesCode, 0m, expense.Amount, narration)
new GlJournalEntryLineRequest(accruedExpensesCode, 0m, expense.Amount, narration)
]
}, ct);
@@ -215,6 +216,7 @@ public sealed class ExpenseService : IExpenseService
{
var today = DateOnly.FromDateTime(now);
var narration = $"Cancellation of expense {expense.DocNo}";
var accruedExpensesCode = (await _glAccounts.GetCodesAsync(token))[GlAccountRole.AccruedExpenses];
var period = await _gl.GetPeriodByDateAsync(today, token);
var reversal = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
@@ -225,7 +227,7 @@ public sealed class ExpenseService : IExpenseService
Narration = narration,
Lines =
[
new GlJournalEntryLineRequest(_glAccruedExpensesCode, expense.Amount, 0m, narration),
new GlJournalEntryLineRequest(accruedExpensesCode, expense.Amount, 0m, narration),
new GlJournalEntryLineRequest(expense.GlExpenseAccountCode ?? string.Empty, 0m, expense.Amount, narration)
]
}, token);
+5 -1
View File
@@ -54,7 +54,11 @@ public enum GlAccountRole
InventoryLoss,
/// <summary>Cash takings at day-end close.</summary>
Cash
Cash,
/// <summary>Expenses booked but not yet paid (a liability): credited when an expense is posted, debited when it is paid.
/// Used to live only in appsettings (<c>Expense:GlAccruedExpensesCode</c>), where nobody could see or check it.</summary>
AccruedExpenses
}
/// <summary>
@@ -105,6 +105,8 @@ public sealed class GlAccountConfigService : IGlAccountConfigService
config.CogsAccountCode = submitted[GlAccountRole.Cogs];
config.InventoryLossAccountCode = submitted[GlAccountRole.InventoryLoss];
config.CashAccountCode = submitted[GlAccountRole.Cash];
// An older client that does not send this role keeps what is stored (it must never blank a mapping it can't see).
if (request.AccruedExpensesCode is not null) config.AccruedExpensesCode = submitted[GlAccountRole.AccruedExpenses];
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -146,7 +148,8 @@ public sealed class GlAccountConfigService : IGlAccountConfigService
[GlAccountRole.InventoryGain] = Trim(c.InventoryGainAccountCode),
[GlAccountRole.Cogs] = Trim(c.CogsAccountCode),
[GlAccountRole.InventoryLoss] = Trim(c.InventoryLossAccountCode),
[GlAccountRole.Cash] = Trim(c.CashAccountCode)
[GlAccountRole.Cash] = Trim(c.CashAccountCode),
[GlAccountRole.AccruedExpenses] = Trim(c.AccruedExpensesCode)
};
private static Dictionary<GlAccountRole, string?> Submitted(UpdateGlAccountConfigRequest r) => new()
@@ -162,7 +165,8 @@ public sealed class GlAccountConfigService : IGlAccountConfigService
[GlAccountRole.InventoryGain] = Trim(r.InventoryGainAccountCode),
[GlAccountRole.Cogs] = Trim(r.CogsAccountCode),
[GlAccountRole.InventoryLoss] = Trim(r.InventoryLossAccountCode),
[GlAccountRole.Cash] = Trim(r.CashAccountCode)
[GlAccountRole.Cash] = Trim(r.CashAccountCode),
[GlAccountRole.AccruedExpenses] = Trim(r.AccruedExpensesCode)
};
private static string? Trim(string? code) => string.IsNullOrWhiteSpace(code) ? null : code.Trim();
@@ -8,7 +8,8 @@ namespace ERPCore.Services.Interfaces;
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null, CancellationToken ct = default);
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, AdjustmentStatus? status = null,
CancellationToken ct = default, bool wastageOnly = false, int? itemId = null);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
@@ -29,6 +29,9 @@ public interface IApprovalService
/// <summary>Approvals a stock adjustment worth <paramref name="absoluteValue"/> needs (0, 1 or 2).</summary>
Task<int> RequiredForStockAdjustmentAsync(decimal absoluteValue, CancellationToken ct = default);
/// <summary>Approvals a wastage write-off needs (Settings -> Approvals "Wastage needs dual approval"): 0 or 2.</summary>
Task<int> RequiredForWastageAsync(CancellationToken ct = default);
/// <summary>Approvals a price change of <paramref name="largestChangePercent"/> needs (0, 1 or 2).</summary>
Task<int> RequiredForPriceAdjustmentAsync(decimal largestChangePercent, CancellationToken ct = default);
@@ -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)
+18
View File
@@ -643,3 +643,21 @@ 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).
### 2026-10-18 (6) — Wastage reduces stock again; wastage approval is a setting
- **Why stock stopped reducing:** wastage is a stock adjustment, and the dual-approval engine seeds `StockAdjustmentLevels = 2`, so every wastage was only a pending request (no FIFO consume, ledger row or GL journal) until two other users approved it.
- **Setting:** `approval_config.WastageLevels` (0 = none, default; 2 = two approvers). Settings -> Approvals has a *Wastage needs dual approval* switch. Wastage = Adjustment reason DMG / THEFT / EXP with every line a decrease (`WastageReasonCodes`); everything else (manual adjustments, count variances, wastage *gains*) keeps the stock-adjustment levels and threshold. Migration `AddWastageApprovalSetting` (additive, default 0, **not applied**).
- **List:** `GET /stock-adjustments?wastage=true` filters server-side; the Wastage page uses it, counts posted wastage in its totals, shows pending requests apart ("Waiting for approval") with no value, and loads every item so names resolve past the first 200. Record Wastage shows whether it reduces stock now or needs approval.
- **Not touched:** requests already pending stay pending (approve them in the Approval Queue or withdraw and re-record).
- **Tests:** 5 new in `DualApprovalTests` (switch off posts at once; on stays pending until two approvers; the two settings don't affect each other; list filter; default). The existing adjustment tests now use an ordinary reason (VAR) instead of DMG. Suite 223/223.
### 2026-10-18 (7) — Expense posting: "Account 5200-01 / 2300 not found"
- **Cause:** the GL used by this deployment had none of the accounts the Expense module posts to: the four expense accounts the seeded titles point at (`5200-01`..`5200-04`, children of an "Operating Expenses" `5200` control account) and the **Accrued Expenses** liability (`2300`) that every expense is credited to. The expense-title accounts are editable on Accounts -> Expense Setup; the accrued account was **only in appsettings** (`Expense:GlAccruedExpensesCode`), invisible and unchecked.
- **Fix in code:** `GlAccountRole.AccruedExpenses` + `gl_account_config.AccruedExpensesCode` (migration `AddAccruedExpensesGlRole`, **not applied**, fills existing config with `2300`; fresh databases seed it from the old appsettings key). `ExpenseService` / `ExpensePaymentService` read the mapped role, so it is edited, validated (must exist and be postable) and checked at startup like every other GL role, on Settings -> GL Accounts.
- **GL data (done through the GL's own API, not SQL):** created `5200 Operating Expenses` (header) with `5200-01` Utility, `5200-02` Rental, `5200-03` Salaries and Wages, `5200-04` Other Expenses, and `2300 Accrued Expenses`.
- **Still true for a brand-new GL:** the chart of accounts itself is created in the GL service, not in the ERP; the ERP only maps roles to existing accounts and now tells you which are missing.
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { useParams, usePathname, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
import { AlertOctagon, AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
import { itemsApi } from "@/lib/api/items"
@@ -14,6 +14,8 @@ import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { WastageRecord, wastageApi } from "@/lib/api/wastage"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
@@ -104,6 +106,9 @@ function ItemDetailPage() {
// Profile activity: full recent movement history for this item, across every warehouse.
const [ledger, setLedger] = useState<LedgerEntry[] | null>(null)
// Damage / theft / expiry write-offs of this item (stock adjustments with a wastage reason), and the reason names.
const [wastage, setWastage] = useState<WastageRecord[] | null>(null)
const [reasonNames, setReasonNames] = useState<Map<number, string>>(new Map())
const [stockValue, setStockValue] = useState<number | null>(null)
// Basic info form
@@ -218,6 +223,11 @@ function ItemDetailPage() {
useEffect(() => {
if (!Number.isFinite(itemId)) return
stockApi.ledger({ itemId, pageSize: 500 }).then((res) => setLedger(res.items)).catch(() => setLedger([]))
wastageApi.list({ itemId, pageSize: 100 }).then(setWastage).catch(() => setWastage([]))
reasonCodesApi
.list("Adjustment", { pageSize: 200 })
.then((r) => setReasonNames(new Map(r.items.map((x) => [x.reasonCodeId, x.description]))))
.catch(() => {})
stockApi
.onHandList({ itemId, pageSize: 200 })
.then((res) => res.items.filter((r) => r.onHand > 0))
@@ -280,6 +290,8 @@ function ItemDetailPage() {
.reduce((sum, r) => sum + r.qtyBase, 0),
[sortedLedger, cutoff30]
)
// Wastage that has actually reduced stock counts; a request still waiting for approval is listed but not counted.
const wastedQty = useMemo(() => (wastage ?? []).filter((w) => w.status === "Posted").reduce((sum, w) => sum + w.qty, 0), [wastage])
const recentSales = useMemo(
() => sortedLedger.filter((r) => r.direction === "Out" && SALES_DOC_TYPES.has(r.sourceDocType)).slice(-8).reverse(),
[sortedLedger]
@@ -626,7 +638,7 @@ function ItemDetailPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<StatChip icon={Boxes} label={`On hand (${uomName(item.baseUomId)})`} value={currentStockQty.toLocaleString()} />
<StatChip
icon={BadgeDollarSign}
@@ -635,6 +647,7 @@ function ItemDetailPage() {
/>
<StatChip icon={ShoppingCart} label="Sold, last 30 days" value={salesQty30d.toLocaleString()} />
<StatChip icon={PackageCheck} label="Purchases on record" value={recentGrns.length > 0 ? `${sortedLedger.filter((r) => r.sourceDocType === "GRN").length}` : "0"} />
<StatChip icon={AlertOctagon} label="Wasted, all time" value={wastage === null ? "…" : wastedQty.toLocaleString()} />
</div>
{conflict && (
@@ -919,6 +932,76 @@ function ItemDetailPage() {
<div className="flex flex-col gap-3 rounded-xl border p-5">
<h2 className="text-base font-semibold text-foreground">Recent wastage</h2>
{wastage === null ? (
<Skeleton className="h-32 w-full" />
) : wastage.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No wastage recorded for this item.</p>
) : (
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-2 md:hidden">
{wastage.slice(0, 8).map((w) => (
<div key={`${w.adjustmentId}-${w.adjLineId}`} className="flex flex-col gap-1.5 rounded-lg border p-3 text-sm">
<div className="flex items-center justify-between gap-2">
<Link href={`/dashboard/stock/adjustments/${w.adjustmentId}`} className="truncate font-medium text-primary hover:underline">
{w.docNo}
</Link>
<span className="shrink-0 text-xs text-muted-foreground">{new Date(w.createdAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>{reasonNames.get(w.reasonCodeId) ?? `Reason #${w.reasonCodeId}`} · Qty {w.qty.toLocaleString()}</span>
<span className="font-medium text-foreground">
{w.status === "Posted" ? `LKR ${w.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : "Pending approval"}
</span>
</div>
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto md:block">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-9 px-2 text-xs">Date</TableHead>
<TableHead className="h-9 px-2 text-xs">Document</TableHead>
<TableHead className="h-9 px-2 text-xs">Reason</TableHead>
<TableHead className="h-9 px-2 text-xs">Qty</TableHead>
<TableHead className="h-9 px-2 text-xs">Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wastage.slice(0, 8).map((w) => (
<TableRow key={`${w.adjustmentId}-${w.adjLineId}`}>
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(w.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-2 py-2">
<Link href={`/dashboard/stock/adjustments/${w.adjustmentId}`} className="font-medium text-primary hover:underline">
{w.docNo}
</Link>
</TableCell>
<TableCell className="px-2 py-2 text-muted-foreground">{reasonNames.get(w.reasonCodeId) ?? `#${w.reasonCodeId}`}</TableCell>
<TableCell className="px-2 py-2 font-medium text-destructive">-{w.qty.toLocaleString()}</TableCell>
<TableCell className="px-2 py-2">
{w.status === "Posted" ? (
`LKR ${w.value.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
) : (
<span className="rounded bg-warning/10 px-1.5 py-0.5 text-xs text-warning">Pending approval</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Link href="/dashboard/stock/wastage" className="self-end text-sm font-medium text-primary hover:underline">
All wastage
</Link>
</>
)}
</div>
<p className="text-sm text-muted-foreground">
{uomName(item.baseUomId)} is the base UOM — every transaction records quantities as a count of it (FR-MD-03).
There are no conversions: a differently sized pack is a different item.
@@ -14,6 +14,7 @@ import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
const levelOptions = [
@@ -28,6 +29,7 @@ export default function ApprovalSettingsPage() {
const [error, setError] = useState<string | null>(null)
const [stockLevels, setStockLevels] = useState("2")
const [stockThreshold, setStockThreshold] = useState("0")
const [wastageDual, setWastageDual] = useState(false)
const [priceLevels, setPriceLevels] = useState("2")
const [priceThreshold, setPriceThreshold] = useState("0")
const [level2Roles, setLevel2Roles] = useState<number[]>([])
@@ -38,6 +40,7 @@ export default function ApprovalSettingsPage() {
setConfig(c)
setStockLevels(String(c.stockAdjustmentLevels))
setStockThreshold(String(c.stockAdjustmentLevel2ThresholdValue))
setWastageDual((c.wastageLevels ?? 0) > 0)
setPriceLevels(String(c.priceAdjustmentLevels))
setPriceThreshold(String(c.priceAdjustmentLevel2ThresholdPercent))
setLevel2Roles(c.level2RoleIds)
@@ -60,6 +63,7 @@ export default function ApprovalSettingsPage() {
const saved = await approvalsApi.updateConfig({
stockAdjustmentLevels: Number(stockLevels),
stockAdjustmentLevel2ThresholdValue: sv,
wastageLevels: wastageDual ? 2 : 0,
priceAdjustmentLevels: Number(priceLevels),
priceAdjustmentLevel2ThresholdPercent: pv,
level2RoleIds: level2Roles,
@@ -81,7 +85,7 @@ export default function ApprovalSettingsPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Approvals</h1>
<p className="text-base text-muted-foreground">
How many people must approve a stock or price adjustment before it takes effect. The requester can never approve their own request,
How many people must approve a stock adjustment, wastage or price adjustment before it takes effect. The requester can never approve their own request,
and with two approvals the second must be a different person from the first. Changes apply to new requests; pending ones keep their rule.
</p>
</div>
@@ -89,7 +93,7 @@ export default function ApprovalSettingsPage() {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
<h2 className="text-lg font-semibold text-foreground">Stock adjustments</h2>
<p className="text-sm text-muted-foreground">Adjustments, wastage and stock-count variances.</p>
<p className="text-sm text-muted-foreground">Manual adjustments and stock-count variances. Wastage has its own switch below.</p>
<Field>
<FieldLabel>Approvals required</FieldLabel>
<Select<string> value={stockLevels} onValueChange={(v) => v && setStockLevels(v)}>
@@ -106,6 +110,27 @@ export default function ApprovalSettingsPage() {
)}
</section>
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
<h2 className="text-lg font-semibold text-foreground">Wastage</h2>
<p className="text-sm text-muted-foreground">Damage, theft / loss and expiry write-offs.</p>
<div className="flex items-start gap-4 py-1">
<Switch
checked={wastageDual}
onCheckedChange={setWastageDual}
disabled={saving}
aria-label="Wastage needs dual approval"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Wastage needs dual approval</span>
<span className="text-sm text-muted-foreground">
Off: recording wastage reduces stock immediately. On: two different approvers must approve before stock is reduced
(the request waits in the Approval Queue until then).
</span>
</div>
</div>
</section>
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
<h2 className="text-lg font-semibold text-foreground">Price adjustments</h2>
<p className="text-sm text-muted-foreground">
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ScrollText } from "lucide-react"
import { ScrollText, X } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
@@ -20,6 +20,7 @@ import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
@@ -27,6 +28,42 @@ const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 10
/** The Value column (and card line) is hidden for now; flip to true to bring it back. */
const SHOW_VALUE = false
/** Quantities as they are: no trailing zeros, thousands separators, a sign for the direction of the movement. */
function formatQty(n: number, signed?: "In" | "Out") {
const text = Math.abs(n).toLocaleString(undefined, { maximumFractionDigits: 4 })
if (!signed) return n < 0 ? `-${text}` : text
return `${signed === "In" ? "+" : "−"}${text}`
}
/** Unit costs keep their precision (FIFO costs are not always whole cents) but never show fewer than 2 places. */
function formatUnitCost(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
/** A movement's value: positive stock coming in, negative going out, always 2 places. */
function formatValue(n: number, direction: "In" | "Out") {
const text = Math.abs(n).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
return direction === "Out" && n !== 0 ? `−${text}` : text
}
/** "Name" with the SKU under it — the same item cell on the table and the cards. */
function itemCell(entry: LedgerEntry, fallback?: ItemListItem) {
const name = entry.itemName ?? fallback?.name
const sku = entry.itemSku ?? fallback?.sku
return (
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{name ?? sku ?? `#${entry.itemId}`}</p>
{name && sku && <p className="truncate text-xs text-muted-foreground">{sku}</p>}
{(entry.categoryName || entry.brandName) && (
<p className="truncate text-xs text-muted-foreground">{[entry.categoryName, entry.brandName].filter(Boolean).join(" · ")}</p>
)}
</div>
)
}
export default function StockLedgerPage() {
const { activeBranchId, branchingEnabled } = useAuth()
@@ -36,7 +73,8 @@ export default function StockLedgerPage() {
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [itemId, setItemId] = useState<number | "All">("All")
// 0 = every item.
const [itemId, setItemId] = useState(0)
const [warehouseId, setWarehouseId] = useState<number | "All">("All")
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
@@ -45,12 +83,22 @@ export default function StockLedgerPage() {
const [showMoreLoading, setShowMoreLoading] = useState(false)
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
.then(([it, wh]) => {
setItems(it.items)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
warehousesApi.list().then((wh) => setWarehouses(wh.items)).catch((err) => setError(errorMessage(err)))
}, [])
// The item picker searches the whole catalogue (the API returns at most 200 per page, so read every page).
useEffect(() => {
let cancelled = false
;(async () => {
const first = await itemsApi.list({ pageSize: 200 })
const rest = await Promise.all(
Array.from({ length: Math.max(first.pagination.totalPages - 1, 0) }, (_, i) => itemsApi.list({ pageSize: 200, page: i + 2 })),
)
if (!cancelled) setItems([first, ...rest].flatMap((r) => r.items))
})().catch((err) => !cancelled && setError(errorMessage(err)))
return () => {
cancelled = true
}
}, [])
useEffect(() => {
@@ -61,7 +109,7 @@ export default function StockLedgerPage() {
let cancelled = false
stockApi
.ledger({
itemId: itemId === "All" ? undefined : itemId,
itemId: itemId || undefined,
warehouseId: warehouseId === "All" ? undefined : warehouseId,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
from: from || undefined,
@@ -90,10 +138,16 @@ export default function StockLedgerPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
// Same picker as Create Sale / New GRN: type a SKU or name, pick from the list.
const itemOptions = useMemo<SuggestOption[]>(
() => (items ?? []).map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })),
[items],
)
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses])
const loading = entries === null || items === null || warehouses === null
const loading = entries === null || warehouses === null
const pickedItem = itemId ? itemsById.get(itemId) : undefined
return (
<div className="flex flex-col gap-6">
@@ -111,19 +165,24 @@ export default function StockLedgerPage() {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label className="text-base">Item</Label>
<Select<number | "All"> value={itemId} onValueChange={(v) => setItemId(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All items</SelectItem>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
{itemId ? (
<div className="flex h-11 items-center justify-between gap-2 rounded-lg border border-input bg-card px-3 text-base">
<span className="min-w-0 truncate">{pickedItem ? `${pickedItem.sku} — ${pickedItem.name}` : `Item #${itemId}`}</span>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setItemId(0)} aria-label="Show all items" title="Show all items">
<X className="size-4" />
</Button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => setItemId(Number(v))}
browsable
placeholder="All items — type SKU or name…"
emptyText="No items match."
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
aria-label="Filter by item"
/>
)}
</div>
<div className="flex flex-col gap-1.5">
@@ -136,7 +195,7 @@ export default function StockLedgerPage() {
<SelectItem value="All" className="text-base">All warehouses</SelectItem>
{(warehouses ?? []).map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
{w.code}
{w.code} — {w.name}
</SelectItem>
))}
</SelectContent>
@@ -192,8 +251,8 @@ export default function StockLedgerPage() {
<div key={entry.ledgerId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{item?.sku ?? `#${entry.itemId}`}</p>
<p className="text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</p>
{itemCell(entry, item)}
<p className="mt-0.5 text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</p>
</div>
<Badge
variant="outline"
@@ -211,23 +270,25 @@ export default function StockLedgerPage() {
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
<div>
<p className="text-muted-foreground">Qty / Unit cost</p>
<p className="font-medium text-foreground">{entry.qtyBase} @ {entry.unitCost.toFixed(2)}</p>
<p className="font-medium text-foreground">{formatQty(entry.qtyBase, entry.direction)} @ {formatUnitCost(entry.unitCost)}</p>
</div>
{SHOW_VALUE && (
<div>
<p className="text-muted-foreground">Value</p>
<p className="font-medium text-foreground">{entry.value.toFixed(2)}</p>
<p className="font-medium text-foreground">{formatValue(entry.value, entry.direction)}</p>
</div>
)}
<div>
<p className="text-muted-foreground">Running balance</p>
<p className="font-semibold text-foreground">{entry.runningBalance}</p>
<p className="font-semibold text-foreground">{formatQty(entry.runningBalance)}</p>
</div>
<div>
<p className="text-muted-foreground">Source</p>
<p className="font-medium text-foreground">{entry.sourceDocType} #{entry.sourceDocId}</p>
<span className="inline-block rounded-md bg-primary/10 px-2 py-0.5 font-bold text-primary">{entry.sourceDocType} #{entry.sourceDocId}</span>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Date</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleString()}</p>
<p className="font-medium text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
</div>
</div>
</div>
@@ -247,15 +308,13 @@ export default function StockLedgerPage() {
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-40">Date</TableHead>
<TableHead className="w-32">Item</TableHead>
<TableHead className="w-48">Date / Source</TableHead>
<TableHead className="w-56">Item</TableHead>
<TableHead className="w-32">Warehouse</TableHead>
<TableHead className="w-28">Direction</TableHead>
<TableHead className="w-24 text-right">Qty</TableHead>
<TableHead className="w-40">Direction / Qty</TableHead>
<TableHead className="w-28 text-right">Unit cost</TableHead>
<TableHead className="w-28 text-right">Value</TableHead>
{SHOW_VALUE && <TableHead className="w-28 text-right">Value</TableHead>}
<TableHead className="w-36 text-right">Running balance</TableHead>
<TableHead className="w-36">Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -264,14 +323,20 @@ export default function StockLedgerPage() {
const wh = warehousesById.get(entry.warehouseId)
return (
<TableRow key={entry.ledgerId} className="align-top hover:bg-muted/20">
<TableCell className="text-sm text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</TableCell>
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${entry.itemId}`}</TableCell>
<TableCell>
<p className="text-sm text-foreground">{new Date(entry.createdAt).toLocaleDateString()}</p>
<span className="mt-1 inline-block rounded-md bg-primary/10 px-2 py-0.5 text-xs font-bold text-primary">
{entry.sourceDocType} #{entry.sourceDocId}
</span>
</TableCell>
<TableCell>{itemCell(entry, item)}</TableCell>
<TableCell className="text-sm text-muted-foreground">{wh?.code ?? `#${entry.warehouseId}`}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={cn(
"h-6 w-20 justify-center text-sm",
"h-6 w-14 shrink-0 justify-center text-sm",
entry.direction === "In"
? "border-transparent bg-success/10 text-success"
: "border-transparent bg-destructive/10 text-destructive"
@@ -279,14 +344,16 @@ export default function StockLedgerPage() {
>
{entry.direction}
</Badge>
<span className={cn("text-sm font-medium tabular-nums", entry.direction === "In" ? "text-success" : "text-destructive")}>
{formatQty(entry.qtyBase, entry.direction)}
</span>
</div>
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.qtyBase}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.unitCost.toFixed(2)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{entry.value.toFixed(2)}</TableCell>
<TableCell className="text-right font-semibold text-foreground tabular-nums">{entry.runningBalance}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{entry.sourceDocType} #{entry.sourceDocId}
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{formatUnitCost(entry.unitCost)}</TableCell>
{SHOW_VALUE && (
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{formatValue(entry.value, entry.direction)}</TableCell>
)}
<TableCell className="text-right font-semibold text-foreground tabular-nums">{formatQty(entry.runningBalance)}</TableCell>
</TableRow>
)
})}
@@ -1,17 +1,19 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { AlertOctagon, CheckCircle2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { AlertOctagon, X } from "lucide-react"
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { approvalsApi } from "@/lib/api/approvals"
import { stockApi } from "@/lib/api/stock"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { OnHand, ReasonCode, StockAdjustment } from "@/types/stock"
import { OnHand, ReasonCode } from "@/types/stock"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
@@ -21,9 +23,11 @@ import { Label } from "@/components/ui/label"
import { FieldError } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { SuggestInput, SuggestOption } from "@/components/ui/suggest-input"
import { toast } from "@/components/ui/toast"
export default function NewWastagePage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -44,13 +48,26 @@ export default function NewWastagePage() {
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [result, setResult] = useState<StockAdjustment | null>(null)
// Settings -> Approvals "Wastage needs dual approval". Null until read; the server decides either way.
const [needsApproval, setNeedsApproval] = useState<boolean | null>(null)
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
approvalsApi.getConfig().then((c) => setNeedsApproval((c.wastageLevels ?? 0) > 0)).catch(() => setNeedsApproval(null))
}, [])
useEffect(() => {
const allActiveItems = async () => {
const first = await itemsApi.list({ pageSize: 200, status: "Active" })
const rest = await Promise.all(
Array.from({ length: Math.max(first.pagination.totalPages - 1, 0) }, (_, i) =>
itemsApi.list({ pageSize: 200, status: "Active", page: i + 2 })),
)
return [first, ...rest].flatMap((r) => r.items)
}
Promise.all([warehousesApi.list(), allActiveItems(), reasonCodesApi.list("Adjustment")])
.then(([wh, it, rc]) => {
setWarehouses(wh.items)
setItems(it.items)
setItems(it)
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
})
.catch((err) => setLoadError(errorMessage(err)))
@@ -110,9 +127,10 @@ export default function NewWastagePage() {
qty: Number(qty),
reasonCodeId: reasonCodeId as number,
})
setResult(adjustment)
if (adjustment.status === "Posted") toast.success("Wastage recorded", `${adjustment.docNo} — stock written off.`)
else toast.success("Wastage submitted for approval", `${adjustment.docNo} is written off once ${adjustment.requiredApprovals === 2 ? "two people approve it" : "it is approved"}.`)
// Straight back to the wastage table, where the new record is listed.
router.push("/dashboard/stock/wastage")
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not record wastage", errorMessage(err))
@@ -121,15 +139,12 @@ export default function NewWastagePage() {
}
}
function startAnother() {
setResult(null)
setItemId(null)
setQty("")
setReasonCodeId(null)
setSubmitError(null)
setOnHand(null)
setOnHandError(null)
}
// Same picker as Create Sale / New GRN: type a SKU or name, pick from the list.
const itemOptions = useMemo<SuggestOption[]>(
() => (items ?? []).map((i) => ({ value: String(i.itemId), label: `${i.sku} — ${i.name}` })),
[items],
)
const pickedItem = itemId ? (items ?? []).find((i) => i.itemId === itemId) : undefined
const loading = !warehouses || !items || !reasonCodes
@@ -138,7 +153,11 @@ export default function NewWastagePage() {
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">Record Wastage</h1>
<p className="text-base text-muted-foreground">Posts immediately as a stock adjustment (FR-STK-07) — a reason code is mandatory.</p>
<p className="text-base text-muted-foreground">
{needsApproval
? "Needs two approvals before stock is reduced (set in Settings → Approvals) — a reason code is mandatory."
: "Reduces stock immediately, as a stock adjustment (FR-STK-07) — a reason code is mandatory."}
</p>
</div>
</div>
@@ -148,38 +167,7 @@ export default function NewWastagePage() {
{loading && !loadError && <Skeleton className="h-12 w-full" />}
{!loading && result && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
<div className="flex items-center gap-2 text-success">
<CheckCircle2 className="size-6" />
<p className="text-base font-semibold">
{result.status === "Posted" ? `${result.docNo} posted` : `${result.docNo} submitted for approval`}
</p>
</div>
<div className="text-sm text-muted-foreground">
{result.status === "Posted" ? (
<>Ledger refs: {result.ledgerRefs.join(", ")}</>
) : (
<>
Stock is written off once approved.{" "}
<Link href={`/dashboard/stock/adjustments/${result.adjustmentId}`} className="font-medium text-primary hover:underline">View request</Link>
</>
)}
</div>
</div>
<div className="flex gap-3">
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to wastage report
</Link>
<Button size="lg" type="button" onClick={startAnother}>
Record another
</Button>
</div>
</div>
)}
{!loading && !result && (
{!loading && (
<>
{(reasonCodes ?? []).length === 0 && (
<div className="flex items-center gap-2 rounded-lg border border-warning/30 bg-warning/5 p-4 text-base text-warning">
@@ -224,18 +212,24 @@ export default function NewWastagePage() {
<div className="flex flex-col gap-2">
<Label className="text-base">Item</Label>
<Select<number | null> value={itemId} onValueChange={(v) => setItemId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{(items ?? []).map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
{i.sku} — {i.name}
</SelectItem>
))}
</SelectContent>
</Select>
{itemId ? (
<div className="flex h-12 items-center justify-between gap-2 rounded-lg border border-input bg-card px-3 text-base">
<span className="min-w-0 truncate">{pickedItem ? `${pickedItem.sku} — ${pickedItem.name}` : `Item #${itemId}`}</span>
<Button type="button" variant="ghost" size="icon-sm" onClick={() => setItemId(null)} aria-label="Change item" title="Change item">
<X className="size-4" />
</Button>
</div>
) : (
<SuggestInput
options={itemOptions}
onSelect={(v) => setItemId(Number(v))}
browsable
placeholder="Search item — SKU or name…"
emptyText="No items match."
listClassName="min-w-[min(32rem,calc(100vw-2rem))]"
aria-label="Item"
/>
)}
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
</div>
@@ -40,10 +40,17 @@ export default function WastagePage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
useEffect(() => {
Promise.all([reasonCodesApi.list("Adjustment"), itemsApi.list({ pageSize: 200 }), warehousesApi.list()])
const allItems = async () => {
const first = await itemsApi.list({ pageSize: 200 })
const rest = await Promise.all(
Array.from({ length: Math.max(first.pagination.totalPages - 1, 0) }, (_, i) => itemsApi.list({ pageSize: 200, page: i + 2 })),
)
return [first, ...rest].flatMap((r) => r.items)
}
Promise.all([reasonCodesApi.list("Adjustment"), allItems(), warehousesApi.list()])
.then(([rc, it, wh]) => {
setReasonCodes(rc.items.filter((r) => isWastageReasonCode(r.code)))
setItems(it.items)
setItems(it)
setWarehouses(wh.items)
})
.catch((err) => setError(errorMessage(err)))
@@ -72,11 +79,16 @@ export default function WastagePage() {
: null
const pagedRecords = records?.slice((page - 1) * pageSize, page * pageSize) ?? null
// Only wastage that has actually reduced stock counts towards the totals; requests still waiting for approval are
// counted apart (they have no cost yet — FIFO costs are fixed when the stock is written off).
const totals = useMemo(() => {
if (!records) return { qty: 0, value: 0 }
if (!records) return { qty: 0, value: 0, pending: 0 }
return records.reduce(
(acc, r) => ({ qty: acc.qty + r.qty, value: Math.round((acc.value + r.value) * 100) / 100 }),
{ qty: 0, value: 0 }
(acc, r) =>
r.status === "Posted"
? { ...acc, qty: acc.qty + r.qty, value: Math.round((acc.value + r.value) * 100) / 100 }
: { ...acc, pending: acc.pending + 1 },
{ qty: 0, value: 0, pending: 0 }
)
}, [records])
@@ -107,7 +119,7 @@ export default function WastagePage() {
)}
{!loading && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-destructive/20 bg-destructive/5 p-5">
<p className="text-sm text-muted-foreground">Total quantity wasted</p>
<p className="text-2xl font-semibold text-destructive">{totals.qty}</p>
@@ -116,6 +128,10 @@ export default function WastagePage() {
<p className="text-sm text-muted-foreground">Total value wasted (LKR)</p>
<p className="text-2xl font-semibold text-destructive">{totals.value.toFixed(2)}</p>
</div>
<div className="rounded-xl border border-warning/30 bg-warning/5 p-5">
<p className="text-sm text-muted-foreground">Waiting for approval</p>
<p className="text-2xl font-semibold text-warning">{totals.pending}</p>
</div>
</div>
)}
@@ -216,7 +232,7 @@ export default function WastagePage() {
</div>
<div>
<p className="text-muted-foreground">Value (LKR)</p>
<p className="font-medium text-foreground">{r.value.toFixed(2)}</p>
<p className="font-medium text-foreground">{r.status === "Posted" ? r.value.toFixed(2) : "—"}</p>
</div>
<div className="col-span-2">
<p className="text-muted-foreground">Date</p>
@@ -268,7 +284,7 @@ export default function WastagePage() {
</Badge>
</TableCell>
<TableCell className="text-right font-semibold text-destructive tabular-nums">-{r.qty}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.value.toFixed(2)}</TableCell>
<TableCell className="text-right text-sm text-muted-foreground tabular-nums">{r.status === "Posted" ? r.value.toFixed(2) : "—"}</TableCell>
</TableRow>
)
})}
+6 -3
View File
@@ -2,7 +2,7 @@
import { Suspense, useState } from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { useSearchParams } from "next/navigation"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Eye, EyeOff } from "lucide-react"
@@ -26,7 +26,6 @@ export default function LoginPage() {
}
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false)
const [remember, setRemember] = useState(false)
@@ -48,7 +47,11 @@ function LoginForm() {
// Honour the path the guard bounced us from, but only same-origin paths — an
// attacker-supplied absolute URL here would be an open redirect.
const next = searchParams.get("next")
router.push(next?.startsWith("/") && !next.startsWith("//") ? next : "/dashboard")
const target = next?.startsWith("/") && !next.startsWith("//") ? next : "/dashboard"
// A full page load, not router.push: when the guard bounced a click on a dashboard link here, the app router has
// cached that link's redirect-to-login, so a soft navigation back to it lands on the login page again even though
// the sign-in worked. A hard navigation starts clean and carries the fresh cookies.
window.location.assign(target)
} catch (err) {
setSubmitError(errorMessage(err))
}
@@ -13,6 +13,10 @@ export interface ListAdjustmentsParams {
branchId?: number
reasonCodeId?: number
status?: AdjustmentStatus
/** Only wastage: adjustments with a damage / theft / expiry reason code. */
wastage?: boolean
/** Only adjustments with a line for this item. */
itemId?: number
sort?: string
}
+3
View File
@@ -7,6 +7,9 @@ export interface LedgerQuery {
itemId?: number
warehouseId?: number
branchId?: number
/** Only items in this category / of this brand. */
categoryId?: number
brandId?: number
/** `YYYY-MM-DD`. */
from?: string
to?: string
+8 -7
View File
@@ -44,6 +44,8 @@ export interface WastageRecord {
}
export interface ListWastageParams {
/** Only this item's wastage (the product page). */
itemId?: number
warehouseId?: number
branchId?: number
reasonCodeId?: number
@@ -71,20 +73,19 @@ export const wastageApi = {
* because the adjustment page is already bounded.
*/
async list(params: ListWastageParams = {}): Promise<WastageRecord[]> {
const wastageIds = new Set(await wastageReasonCodeIds())
if (wastageIds.size === 0) return []
// The API filters by a single reasonCodeId, so a specific pick can be pushed down;
// otherwise fetch the page and keep the loss-type rows.
// The server returns only wastage (damage / theft / expiry reasons), newest first, so a busy adjustment history
// can't push the wastage out of the page the way a browser-side filter over mixed adjustments could.
const res = await stockAdjustmentsApi.list({
warehouseId: params.warehouseId,
branchId: params.branchId,
reasonCodeId: params.reasonCodeId,
itemId: params.itemId,
wastage: true,
page: params.page,
pageSize: params.pageSize ?? 50,
})
// A rejected or withdrawn request never wrote anything off.
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId) && a.status !== "Rejected" && a.status !== "Cancelled")
const summaries = res.items.filter((a) => a.status !== "Rejected" && a.status !== "Cancelled")
const detailed = await Promise.all(
summaries.map(async (summary) => {
@@ -94,7 +95,7 @@ export const wastageApi = {
])
return adjustment.lines
.filter((line) => line.qtyDelta < 0) // wastage is always a decrease
.filter((line) => line.qtyDelta < 0 && (params.itemId === undefined || line.itemId === params.itemId)) // wastage is always a decrease
.map<WastageRecord>((line) => {
const value = ledger.items
.filter((l) => l.itemId === line.itemId && l.direction === "Out")
+2
View File
@@ -43,6 +43,8 @@ export interface ApprovalConfig {
stockAdjustmentLevels: number
/** With 2 levels: adjustments worth less than this need one approval. 0 = always two. */
stockAdjustmentLevel2ThresholdValue: number
/** Approvals wastage needs: 0 = none (stock reduces on save), 2 = two approvers first. */
wastageLevels: number
priceAdjustmentLevels: number
/** With 2 levels: price changes smaller than this % need one approval. 0 = always two. */
priceAdjustmentLevel2ThresholdPercent: number
@@ -21,6 +21,7 @@ export type GlAccountRole =
| "Cogs"
| "InventoryLoss"
| "Cash"
| "AccruedExpenses"
export interface GlAccountOption {
accountCode: string
@@ -62,6 +63,7 @@ export interface UpdateGlAccountConfigRequest {
cogsAccountCode: string | null
inventoryLossAccountCode: string | null
cashAccountCode: string | null
accruedExpensesCode: string | null
}
/** Request-field name per role, so the page can build the PUT body from the mapping list. */
@@ -78,6 +80,7 @@ export const GL_ROLE_FIELD: Record<GlAccountRole, keyof UpdateGlAccountConfigReq
Cogs: "cogsAccountCode",
InventoryLoss: "inventoryLossAccountCode",
Cash: "cashAccountCode",
AccruedExpenses: "accruedExpensesCode",
}
/** What each role means in accounting terms, and which flows post to it. */
@@ -94,6 +97,7 @@ export const GL_ROLE_LABELS: Record<GlAccountRole, { label: string; description:
Cogs: { label: "Cost of Goods Sold", description: "Cost of sales — day-end, sales returns" },
InventoryLoss: { label: "Inventory Loss", description: "Stock written off by an adjustment or count" },
Cash: { label: "Cash", description: "Cash takings at day-end close" },
AccruedExpenses: { label: "Accrued Expenses", description: "Expenses booked but not yet paid — Accounts → Expenses (post and pay)" },
}
/** Display order: assets, liabilities, income, expense — the order an accountant reads them in. */
@@ -105,6 +109,7 @@ export const GL_ROLE_ORDER: GlAccountRole[] = [
"Clearing",
"TaxPayable",
"AccountsPayable",
"AccruedExpenses",
"SalesRevenue",
"SalesDiscount",
"InventoryGain",
+5
View File
@@ -37,11 +37,16 @@ export interface LedgerEntry {
qtyBase: number
unitCost: number
value: number
/** The item's balance in that warehouse right after this movement (computed by the server from the movements). */
runningBalance: number
sourceDocType: string
sourceDocId: number
userId: number
createdAt: string
itemSku?: string | null
itemName?: string | null
categoryName?: string | null
brandName?: string | null
}
// --- 5.3 Valuation ------------------------------------------------------------------