Move the GL account mapping out of appsettings into validated master data

The per-module Gl*Code appsettings keys pinned GL's account *codes* in
ERPCore's config, while GL generates codes positionally and refuses a
requested one. Nothing connected the two, so "1100 means Inventory" held
only because someone once created accounts in a particular order - and on
2026-10-07 eleven of the twelve configured codes turned out never to have
existed in GL. Every GRN, adjustment, day-end, invoice and FIFO posting had
been failing with "Account <code> not found".

The mapping is now master data in gl_account_config, keyed by accounting
role rather than by module, and GlAccountConfigService validates every
submitted code against GL's live chart of accounts before storing it. A
code GL does not have, or cannot post to, is refused with the role named -
so the mismatch is unrepresentable rather than merely detected. Correcting
a mapping no longer needs a redeploy.

Notes:
- Fails closed on save (503 if GL is unreachable), deliberately opposite to
  the startup validator, which must still let the app serve.
- Non-postable accounts are rejected too: GL rejects a journal line against
  one, so storing it only defers the failure. The auto-created 1000
  Bank/Cash header is exactly that trap.
- The settings endpoint also serves the selectable accounts, because
  GeneralLedgerController is gated on `ledgers` while this is on `settings`
  - and it keeps the picker and the validation list the same data.
- Reading an unmapped role throws naming the role, instead of posting ""
  and letting GL return an opaque 404 from inside a transaction.
- The appsettings keys are now seed-only: DataSeeder copies them in once so
  existing deployments keep their current mapping. No runtime fallback, or
  there would be two sources of truth again.
- The 32 read sites across 11 services collapsed to 12 roles, since every
  module carried the same value for a given role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:04:49 +05:30
parent 614df3065d
commit c41aed9a80
25 changed files with 10003 additions and 256 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddGlAccountConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "gl_account_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "int", nullable: false),
InventoryAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
VatRecoverableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
AccountsReceivableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
ClearingAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
TaxPayableAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
AccountsPayableCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
SalesRevenueAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
SalesDiscountAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
InventoryGainAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
CogsAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
InventoryLossAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
CashAccountCode = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
UpdatedBy = table.Column<int>(type: "int", nullable: true),
RowVersion = table.Column<uint>(type: "int unsigned", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_gl_account_config", x => x.ConfigId);
table.CheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
table.ForeignKey(
name: "FK_gl_account_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
})
.Annotation("Relational:Collation", "utf8mb4_unicode_ci");
migrationBuilder.CreateIndex(
name: "IX_gl_account_config_UpdatedBy",
table: "gl_account_config",
column: "UpdatedBy");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "gl_account_config");
}
}
}
@@ -1421,6 +1421,79 @@ namespace ERPCore.Migrations
b.ToTable("hr_employment_types", (string)null);
});
modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b =>
{
b.Property<int>("ConfigId")
.HasColumnType("int");
b.Property<string>("AccountsPayableCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("AccountsReceivableCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("CashAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("ClearingAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("CogsAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("InventoryAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("InventoryGainAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("InventoryLossAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("int unsigned");
b.Property<string>("SalesDiscountAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("SalesRevenueAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<string>("TaxPayableAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<int?>("UpdatedBy")
.HasColumnType("int");
b.Property<string>("VatRecoverableAccountCode")
.HasMaxLength(20)
.HasColumnType("varchar(20)");
b.HasKey("ConfigId");
b.HasIndex("UpdatedBy");
b.ToTable("gl_account_config", null, t =>
{
t.HasCheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1");
});
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.Property<int>("GrnId")
@@ -7117,6 +7190,16 @@ namespace ERPCore.Migrations
b.Navigation("SalaryComponent");
});
modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b =>
{
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
.WithMany()
.HasForeignKey("UpdatedBy")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("UpdatedByUser");
});
modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
{
b.HasOne("ERPCore.Domain.Entities.Branch", "Branch")