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:
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Entities;
|
||||
|
||||
namespace ERPCore.Dtos.Config;
|
||||
|
||||
/// <summary>One selectable GL account, for the mapping pickers.</summary>
|
||||
public sealed record GlAccountOptionDto(string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive);
|
||||
|
||||
/// <summary>
|
||||
/// One role's current mapping. <see cref="ExistsInGl"/> is false when the saved code is not in GL's
|
||||
/// chart of accounts — which is the state 11 of these were found in on 2026-10-07. The screen shows
|
||||
/// those as flagged rather than blank, so the mapping that is actually stored stays visible.
|
||||
/// </summary>
|
||||
public sealed record GlAccountMappingDto(
|
||||
string Role, string? AccountCode, string? AccountName, bool ExistsInGl, bool IsPostable);
|
||||
|
||||
/// <summary>
|
||||
/// GL account mapping resource. Carries the options alongside the mappings so the picker and the
|
||||
/// server's validation list are the same data — a code the user can choose is a code that will save.
|
||||
/// <para>
|
||||
/// <see cref="GlUnavailable"/> is true when GL could not be reached: mappings still render from the
|
||||
/// database, <see cref="Options"/> is empty, and saving will be refused until GL answers.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record GlAccountConfigDto(
|
||||
IReadOnlyList<GlAccountMappingDto> Mappings,
|
||||
IReadOnlyList<GlAccountOptionDto> Options,
|
||||
bool GlUnavailable,
|
||||
DateTime? UpdatedAt,
|
||||
int? UpdatedBy);
|
||||
|
||||
/// <summary>
|
||||
/// Full replacement of the mapping. Every role is sent on every save; null or empty clears a role.
|
||||
/// <c>UpdatedBy</c> is derived from the token, never posted.
|
||||
/// </summary>
|
||||
public sealed class UpdateGlAccountConfigRequest
|
||||
{
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? VatRecoverableAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsReceivableCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? ClearingAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? TaxPayableAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? AccountsPayableCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesRevenueAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? SalesDiscountAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryGainAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CogsAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? InventoryLossAccountCode { get; set; }
|
||||
[MaxLength(GlAccountConfig.CodeMaxLength)] public string? CashAccountCode { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user