Add GL account configuration and role mapping entities #107
@@ -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<SalesConfig> SalesConfig => Set<SalesConfig>();
|
||||
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>
|
||||
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
|
||||
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;
|
||||
}
|
||||
@@ -25,3 +25,13 @@ public sealed record GlPeriod(int PeriodId, int FiscalYearId);
|
||||
public sealed record GlBankAccount(
|
||||
string AccountType, long AccountId, string AccountName, string? BankName,
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Every account code in GL's chart of accounts. Used at startup to verify the configured
|
||||
/// <c>Gl*Code</c> settings exist — see <see cref="ERPCore.Infra.Gl.GlAccountCodeValidator"/>.
|
||||
/// GL's whole chart of accounts. Backs the GL-account mapping pickers, the validation that
|
||||
/// 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>
|
||||
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";
|
||||
// General Ledger typed internal calls (GRN receipt/payment posting, docs/12 §5's deferred work)
|
||||
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
|
||||
public const string SalesInvoiceNotPayable = "SALES_INVOICE_NOT_PAYABLE";
|
||||
|
||||
Reference in New Issue
Block a user