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:
@@ -1,12 +1,18 @@
|
||||
using ERPCore.Services.Gl;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Infra.Gl;
|
||||
|
||||
/// <summary>
|
||||
/// Startup guard for the GL chart of accounts. Every <c>Gl*Code</c> in configuration names an
|
||||
/// account that must already exist in GL; until now a typo or an un-seeded account only surfaced
|
||||
/// as a 404 when some user happened to trigger that one flow, mid-transaction — one flow, one
|
||||
/// discovery, repeat. This asserts the whole set once, at boot.
|
||||
/// Startup guard for the GL chart of accounts. Every account mapped in <c>gl_account_config</c>
|
||||
/// must exist in GL; without this, an un-seeded account only surfaced as a 404 when some user
|
||||
/// happened to trigger that one flow, mid-transaction — one flow, one discovery, repeat. This
|
||||
/// asserts the whole set once, at boot.
|
||||
/// <para>
|
||||
/// Advisory only. Enforcement lives at the point of entry — <c>GlAccountConfigService.UpdateAsync</c>
|
||||
/// refuses to store a code GL does not have or cannot post to — so this is a net for accounts that
|
||||
/// disappeared from GL *after* being mapped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reports, does not block.</b> An earlier revision threw on a missing code, on the reasoning
|
||||
/// that the config is wrong and waiting won't fix it. In practice that took erpcore.service down
|
||||
@@ -26,39 +32,41 @@ public sealed class GlAccountCodeValidator(
|
||||
IConfiguration configuration,
|
||||
ILogger<GlAccountCodeValidator> logger) : IHostedService
|
||||
{
|
||||
/// <summary>Configuration sections that carry GL account codes — see appsettings.json.</summary>
|
||||
private static readonly string[] Sections =
|
||||
["Grn", "SalesDayEnd", "Adjustment", "SalesInvoice", "PurchaseInvoice", "Fifo"];
|
||||
|
||||
/// <summary>Startup must not stall on a hung GL — HttpClient's own default is 100s.</summary>
|
||||
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
var expected = Sections
|
||||
.SelectMany(s => configuration.GetSection(s).GetChildren())
|
||||
.Where(c => c.Key.StartsWith("Gl", StringComparison.Ordinal) && c.Key.EndsWith("Code", StringComparison.Ordinal))
|
||||
.Select(c => c.Value)
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (expected.Length == 0)
|
||||
{
|
||||
logger.LogWarning("No GL account codes are configured — skipping chart-of-accounts verification.");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] expected;
|
||||
HashSet<string> present;
|
||||
|
||||
// The database read sits inside the catch-all too: a DB hiccup here would otherwise escape
|
||||
// a hosted service and reopen the startup-abort hole this class already caused once.
|
||||
try
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var mapping = scope.ServiceProvider.GetRequiredService<IGlAccountConfigService>();
|
||||
var gl = scope.ServiceProvider.GetRequiredService<IGeneralLedgerService>();
|
||||
|
||||
var codes = await mapping.GetCodesAsync(ct);
|
||||
expected = Enum.GetValues<GlAccountRole>()
|
||||
.Select(codes.GetOrNull)
|
||||
.Where(c => c is not null)
|
||||
.Select(c => c!)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (expected.Length == 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"No GL accounts are mapped — skipping chart-of-accounts verification. "
|
||||
+ "Map them under Settings → GL Accounts; posting flows will fail until then.");
|
||||
return;
|
||||
}
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(FetchTimeout);
|
||||
present = await gl.ListAccountCodesAsync(timeout.Token);
|
||||
present = (await gl.ListAccountsAsync(timeout.Token)).Select(a => a.AccountCode).ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
// Catch-all on purpose. This is a diagnostic, and a diagnostic that can abort the host is
|
||||
// worse than no diagnostic — whatever GL answers with (unreachable, a timeout, a redirect,
|
||||
@@ -67,8 +75,7 @@ public sealed class GlAccountCodeValidator(
|
||||
catch (Exception e) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(e,
|
||||
"Could not read GL's chart of accounts at startup; {Count} configured account code(s) left unverified.",
|
||||
expected.Length);
|
||||
"Could not verify the GL account mapping at startup; it is left unchecked.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,7 +85,7 @@ public sealed class GlAccountCodeValidator(
|
||||
if (present.Count == 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"GL returned no account codes; {Count} configured account code(s) left unverified. " +
|
||||
"GL returned no account codes; {Count} mapped account code(s) left unverified. " +
|
||||
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
|
||||
expected.Length);
|
||||
return;
|
||||
@@ -88,10 +95,10 @@ public sealed class GlAccountCodeValidator(
|
||||
if (missing.Length > 0)
|
||||
{
|
||||
var message =
|
||||
$"General Ledger is missing {missing.Length} of {expected.Length} configured account code(s): " +
|
||||
$"General Ledger is missing {missing.Length} of {expected.Length} mapped account code(s): " +
|
||||
$"{string.Join(", ", missing.Order(StringComparer.Ordinal))}. Every flow that posts to one of these " +
|
||||
"will fail against GL until they are seeded in GL's chart of accounts, or the Gl*Code settings are " +
|
||||
"corrected to match the codes GL actually uses.";
|
||||
"will fail against GL until the accounts are created in GL, or the mapping is corrected under " +
|
||||
"Settings → GL Accounts.";
|
||||
|
||||
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
|
||||
throw new InvalidOperationException(message);
|
||||
@@ -100,7 +107,7 @@ public sealed class GlAccountCodeValidator(
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogInformation("GL account codes verified: {Count} present.", expected.Length);
|
||||
logger.LogInformation("GL account mapping verified: {Count} account code(s) present.", expected.Length);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
@@ -47,7 +47,8 @@ public static class DataSeeder
|
||||
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
||||
];
|
||||
|
||||
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
|
||||
public static async Task SeedAsync(
|
||||
ErpDbContext db, IConfiguration? configuration = null, CancellationToken ct = default)
|
||||
{
|
||||
// Must come first, and must save on its own: every branch-scoped entity has a
|
||||
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
|
||||
@@ -60,6 +61,7 @@ public static class DataSeeder
|
||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||
dirty |= await SeedSalesConfigAsync(db, ct);
|
||||
dirty |= await SeedGrnConfigAsync(db, ct);
|
||||
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
|
||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||
|
||||
if (dirty) await db.SaveChangesAsync(ct);
|
||||
@@ -199,6 +201,49 @@ public static class DataSeeder
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the GL account mapping singleton, carrying over the legacy <c>Gl*Code</c> appsettings
|
||||
/// values once so an existing deployment keeps the mapping it has been running with.
|
||||
/// <para>
|
||||
/// Those keys are <b>seed-only</b>: nothing reads them at runtime any more. Leaving a runtime
|
||||
/// fallback in place would restore the two-sources-of-truth problem this table exists to end —
|
||||
/// after seeding, <c>gl_account_config</c> is authoritative and is edited from
|
||||
/// Settings → GL Accounts, not by redeploying.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately does not call GL: seeding runs inside the startup block that aborts the app on
|
||||
/// failure (see Program.cs), so it must not depend on another service being up. The codes are
|
||||
/// verified separately — advisory at boot by <c>GlAccountCodeValidator</c>, and enforced on
|
||||
/// every save by <c>GlAccountConfigService.UpdateAsync</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static async Task<bool> SeedGlAccountConfigAsync(
|
||||
ErpDbContext db, IConfiguration? configuration, CancellationToken ct)
|
||||
{
|
||||
if (await db.GlAccountConfig.AnyAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)) return false;
|
||||
|
||||
string? Legacy(string key) =>
|
||||
string.IsNullOrWhiteSpace(configuration?[key]) ? null : configuration[key]!.Trim();
|
||||
|
||||
db.GlAccountConfig.Add(new GlAccountConfig
|
||||
{
|
||||
ConfigId = GlAccountConfig.SingletonId,
|
||||
InventoryAccountCode = Legacy("Grn:GlInventoryAccountCode"),
|
||||
VatRecoverableAccountCode = Legacy("Grn:GlVatRecoverableAccountCode"),
|
||||
AccountsReceivableCode = Legacy("SalesInvoice:GlAccountsReceivableCode"),
|
||||
ClearingAccountCode = Legacy("Grn:GlClearingAccountCode"),
|
||||
TaxPayableAccountCode = Legacy("SalesDayEnd:GlTaxPayableAccountCode"),
|
||||
AccountsPayableCode = Legacy("PurchaseInvoice:GlAccountsPayableCode"),
|
||||
SalesRevenueAccountCode = Legacy("SalesDayEnd:GlSalesRevenueAccountCode"),
|
||||
SalesDiscountAccountCode = Legacy("SalesDayEnd:GlSalesDiscountAccountCode"),
|
||||
InventoryGainAccountCode = Legacy("Adjustment:GlGainAccountCode"),
|
||||
CogsAccountCode = Legacy("SalesDayEnd:GlCogsAccountCode"),
|
||||
InventoryLossAccountCode = Legacy("Adjustment:GlLossAccountCode"),
|
||||
CashAccountCode = Legacy("SalesDayEnd:GlCashAccountCode")
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
|
||||
|
||||
Reference in New Issue
Block a user