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
@@ -0,0 +1,53 @@
using ERPCore.Dtos.Config;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Role → GL account mapping endpoints.
/// <para>
/// <b>Authorization:</b> the <c>settings</c> nav policy, like <see cref="BranchConfigController"/>.
/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
/// proxy directly: <see cref="GeneralLedgerController"/> is gated on <c>ledgers</c>, so a
/// settings-only user would get a 403 from it — and serving both from here guarantees the picker
/// and the server-side validation work from the same list.
/// </para>
/// </summary>
[Route("api/v1/gl-account-config")]
[Authorize(Policy = NavPolicies.Prefix + "settings")]
public sealed class GlAccountConfigController : ApiControllerBase
{
private readonly IGlAccountConfigService _config;
public GlAccountConfigController(IGlAccountConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<GlAccountConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>
/// Replaces the mapping. 422 when a code is unknown to GL or not postable; 503 when GL could
/// not be reached, since a mapping that could not be verified is not stored.
/// </summary>
[HttpPut]
[ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<GlAccountConfigDto>> Update(
[FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}