Add migration to include AccruedExpensesCode in gl_account_config table

- Introduced a new column 'AccruedExpensesCode' of type varchar(20) to the 'gl_account_config' table.
- Added SQL command to update existing records with a default value of '2300' for the 'AccruedExpensesCode' where it is currently null.
This commit is contained in:
Dhananjaya99
2026-10-18 16:21:38 +05:30
parent dcdb628647
commit a0793b35d3
31 changed files with 23185 additions and 94 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();
@@ -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}")]
@@ -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; }
}
@@ -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);
+13
View File
@@ -648,3 +648,16 @@ Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike
- **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.