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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user