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