Merge pull request 'Add GL account configuration and role mapping entities' (#107) from Dev into production

Reviewed-on: #107
This commit was merged in pull request #107.
This commit is contained in:
2026-10-07 08:19:12 +00:00
7 changed files with 226 additions and 3 deletions
@@ -0,0 +1,71 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Role → GL account code mapping — a singleton row, same shape as <see cref="GrnConfig"/>.
/// <para>
/// This is master data, not deployment config. It used to live in <c>appsettings.json</c> as
/// per-module <c>Gl*Code</c> keys, which meant (a) only a redeploy could correct a mapping, and
/// (b) nothing checked the codes existed in GL until something tried to post. Held here it is
/// validated against GL's live chart of accounts on every save, so an unknown or non-postable
/// account cannot be stored in the first place — see <c>GlAccountConfigService.UpdateAsync</c>.
/// </para>
/// <para>
/// Codes are nullable: a fresh install has nothing mapped, and a posting flow that needs an
/// unmapped role fails with <c>GL_ACCOUNT_NOT_CONFIGURED</c> naming that role. Width matches
/// GL's own <c>accounts.account_code varchar(20)</c>.
/// </para>
/// </summary>
public class GlAccountConfig
{
/// <summary>Always 1 - the singleton row's id.</summary>
public const int SingletonId = 1;
/// <summary>Matches GL's <c>accounts.account_code</c> column width.</summary>
public const int CodeMaxLength = 20;
public int ConfigId { get; set; }
/// <summary>Stock on hand (was <c>Grn|Adjustment|SalesDayEnd|Fifo:GlInventoryAccountCode</c>).</summary>
public string? InventoryAccountCode { get; set; }
/// <summary>Recoverable input VAT (was <c>Grn:GlVatRecoverableAccountCode</c>).</summary>
public string? VatRecoverableAccountCode { get; set; }
/// <summary>Trade debtors (was <c>SalesDayEnd|SalesInvoice:GlAccountsReceivableCode</c>).</summary>
public string? AccountsReceivableCode { get; set; }
/// <summary>Goods received not invoiced (was <c>Grn|PurchaseInvoice|Fifo:GlClearingAccountCode</c>).</summary>
public string? ClearingAccountCode { get; set; }
/// <summary>Output tax payable (was <c>SalesDayEnd:GlTaxPayableAccountCode</c>).</summary>
public string? TaxPayableAccountCode { get; set; }
/// <summary>Trade creditors (was <c>PurchaseInvoice:GlAccountsPayableCode</c>).</summary>
public string? AccountsPayableCode { get; set; }
/// <summary>Sales revenue (was <c>SalesDayEnd:GlSalesRevenueAccountCode</c>).</summary>
public string? SalesRevenueAccountCode { get; set; }
/// <summary>Sales discount given (was <c>SalesDayEnd:GlSalesDiscountAccountCode</c>).</summary>
public string? SalesDiscountAccountCode { get; set; }
/// <summary>Inventory gain on adjustment/count (was <c>Adjustment:GlGainAccountCode</c>).</summary>
public string? InventoryGainAccountCode { get; set; }
/// <summary>Cost of goods sold (was <c>SalesDayEnd:GlCogsAccountCode</c>).</summary>
public string? CogsAccountCode { get; set; }
/// <summary>Inventory loss on adjustment/count (was <c>Adjustment:GlLossAccountCode</c>).</summary>
public string? InventoryLossAccountCode { get; set; }
/// <summary>Cash takings at day-end (was <c>SalesDayEnd:GlCashAccountCode</c>).</summary>
public string? CashAccountCode { get; set; }
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>Optimistic concurrency token (ETag source); bumped by ErpDbContext.BumpRowVersions.</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,51 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton GL account-mapping row. The check constraint is what makes
/// "singleton" a database guarantee rather than a convention (mirrors <see cref="GrnConfigConfiguration"/>).
/// Backtick quoting is deliberate - MariaDB rejects the double-quoted form, which crash-looped
/// startup seeding once already (c97a7e9).
/// </summary>
public sealed class GlAccountConfigConfiguration : IEntityTypeConfiguration<GlAccountConfig>
{
public void Configure(EntityTypeBuilder<GlAccountConfig> builder)
{
builder.ToTable("gl_account_config", t =>
t.HasCheckConstraint("ck_gl_account_config_singleton", $"`ConfigId` = {GlAccountConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
builder.Property(c => c.ConfigId).ValueGeneratedNever();
foreach (var code in new[]
{
nameof(GlAccountConfig.InventoryAccountCode),
nameof(GlAccountConfig.VatRecoverableAccountCode),
nameof(GlAccountConfig.AccountsReceivableCode),
nameof(GlAccountConfig.ClearingAccountCode),
nameof(GlAccountConfig.TaxPayableAccountCode),
nameof(GlAccountConfig.AccountsPayableCode),
nameof(GlAccountConfig.SalesRevenueAccountCode),
nameof(GlAccountConfig.SalesDiscountAccountCode),
nameof(GlAccountConfig.InventoryGainAccountCode),
nameof(GlAccountConfig.CogsAccountCode),
nameof(GlAccountConfig.InventoryLossAccountCode),
nameof(GlAccountConfig.CashAccountCode)
})
{
builder.Property(code).HasMaxLength(GlAccountConfig.CodeMaxLength);
}
// RowVersion is made a concurrency token by the model-wide convention in ErpDbContext.
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -48,6 +48,8 @@ public class ErpDbContext : DbContext
public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>(); public DbSet<BranchConfig> BranchConfig => Set<BranchConfig>();
public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>(); public DbSet<SalesConfig> SalesConfig => Set<SalesConfig>();
public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>(); public DbSet<GrnConfig> GrnConfig => Set<GrnConfig>();
/// <summary>Singleton row mapping each accounting role to a GL account code.</summary>
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary> /// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>(); public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>(); public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
@@ -0,0 +1,83 @@
using ERPCore.System.Errors;
namespace ERPCore.Services.Gl;
/// <summary>
/// The accounting roles ERPCore posts to, independent of whatever code GL happens to use for each.
/// <para>
/// These replace the old <c>Grn:GlInventoryAccountCode</c>-style appsettings keys. Those pinned GL's
/// *codes* in ERPCore's config while GL *generates* codes positionally and refuses a requested one —
/// so nothing connected the two, and on 2026-10-07 eleven of the twelve configured codes turned out
/// never to have existed in GL. A role is the stable thing; the code it maps to is master data, held
/// in <see cref="ERPCore.Domain.Entities.GlAccountConfig"/> and validated against GL's live chart of
/// accounts whenever it is saved.
/// </para>
/// <para>
/// The old keys were per-module (`Grn:`, `Adjustment:`, `SalesDayEnd:`, `Fifo:`), but every module
/// carried the *same* value for a given role, so they collapse to one role each. See the report note
/// if a per-module split is ever wanted back.
/// </para>
/// </summary>
public enum GlAccountRole
{
/// <summary>Stock on hand. Debited on receipt, credited on issue.</summary>
Inventory,
/// <summary>Recoverable input VAT on purchases.</summary>
VatRecoverable,
/// <summary>Trade debtors — sales made on account.</summary>
AccountsReceivable,
/// <summary>Goods received not invoiced: the GRN↔purchase-invoice clearing account.</summary>
Clearing,
/// <summary>Output tax collected on sales and owed onward.</summary>
TaxPayable,
/// <summary>Trade creditors — supplier invoices awaiting payment.</summary>
AccountsPayable,
/// <summary>Sales revenue.</summary>
SalesRevenue,
/// <summary>Discounts given on sales (contra-revenue, carries a debit balance).</summary>
SalesDiscount,
/// <summary>Inventory written up by a stock adjustment or count.</summary>
InventoryGain,
/// <summary>Cost of goods sold.</summary>
Cogs,
/// <summary>Inventory written off by a stock adjustment or count.</summary>
InventoryLoss,
/// <summary>Cash takings at day-end close.</summary>
Cash
}
/// <summary>
/// A resolved role → GL account code map, as saved in <c>gl_account_config</c>.
/// <para>
/// Reading an unmapped role throws rather than returning null or an empty string. The old code
/// defaulted missing config to <c>string.Empty</c> and posted that to GL, which came back as an
/// opaque "Account not found" from inside a transaction. Naming the role up front is the whole
/// point of this type.
/// </para>
/// </summary>
public sealed class GlAccountCodes(IReadOnlyDictionary<GlAccountRole, string?> codes)
{
/// <summary>The code mapped to <paramref name="role"/>. Throws if nothing is mapped.</summary>
public string this[GlAccountRole role] =>
codes.TryGetValue(role, out var code) && !string.IsNullOrWhiteSpace(code)
? code
: throw new DomainException(
ErrorCodes.GlAccountNotConfigured,
$"No General Ledger account is mapped to '{role}'. Set it under Settings → GL Accounts before posting.",
409);
/// <summary>The code mapped to <paramref name="role"/>, or null. For display, never for posting.</summary>
public string? GetOrNull(GlAccountRole role) =>
codes.TryGetValue(role, out var code) && !string.IsNullOrWhiteSpace(code) ? code : null;
}
+10
View File
@@ -25,3 +25,13 @@ public sealed record GlPeriod(int PeriodId, int FiscalYearId);
public sealed record GlBankAccount( public sealed record GlBankAccount(
string AccountType, long AccountId, string AccountName, string? BankName, string AccountType, long AccountId, string AccountName, string? BankName,
string? CashAccountTypeName, string? AccountNumber, long GlAccountId, string GlAccountCode, string CurrencyCode); string? CashAccountTypeName, string? AccountNumber, long GlAccountId, string GlAccountCode, string CurrencyCode);
/// <summary>
/// One row of GL's chart of accounts, as far as ERPCore cares: enough to populate the GL-account
/// mapping pickers and to validate a saved mapping. <see cref="IsPostable"/> matters because GL
/// rejects a journal line against a non-postable account (its JournalEntryService.BuildLineAsync),
/// so an existing-but-unpostable code is just as broken as a missing one — control/header accounts
/// such as the auto-created <c>1000</c> Bank/Cash parent are exactly that.
/// </summary>
public sealed record GlAccount(
string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive);
@@ -26,8 +26,10 @@ public interface IGeneralLedgerService
Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct); Task<IReadOnlyList<GlBankAccount>> ListBankAccountsAsync(CancellationToken ct);
/// <summary> /// <summary>
/// Every account code in GL's chart of accounts. Used at startup to verify the configured /// GL's whole chart of accounts. Backs the GL-account mapping pickers, the validation that
/// <c>Gl*Code</c> settings exist — see <see cref="ERPCore.Infra.Gl.GlAccountCodeValidator"/>. /// rejects a mapping GL cannot post to (<c>GlAccountConfigService</c>), and the startup
/// verification in <see cref="ERPCore.Infra.Gl.GlAccountCodeValidator"/> — one source for all
/// three, so what a user can pick is exactly what passes validation.
/// </summary> /// </summary>
Task<HashSet<string>> ListAccountCodesAsync(CancellationToken ct); Task<IReadOnlyList<GlAccount>> ListAccountsAsync(CancellationToken ct);
} }
@@ -76,6 +76,10 @@ public static class ErrorCodes
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE"; public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work) // General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
public const string GlRequestFailed = "GL_REQUEST_FAILED"; public const string GlRequestFailed = "GL_REQUEST_FAILED";
// A posting flow needs a GL account whose role has no account mapped in gl_account_config.
public const string GlAccountNotConfigured = "GL_ACCOUNT_NOT_CONFIGURED";
// A submitted GL account mapping names an account GL does not have, or cannot post to.
public const string GlAccountInvalid = "GL_ACCOUNT_INVALID";
// Sales invoice payments // Sales invoice payments
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE"; public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";