diff --git a/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs b/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs
index 4e88af7..6e1217e 100644
--- a/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs
+++ b/Backend/ERPCore.Tests/GlAccountCodeValidatorTests.cs
@@ -1,8 +1,8 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
-using ERPCore.System.Errors;
using ERPCore.System.Time;
+using ERPCore.Tests.TestSupport;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -10,37 +10,34 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace ERPCore.Tests;
///
-/// The validator's whole value is in *which* failures it treats as fatal: a missing account code
-/// must stop the app (the config is wrong), while an unreachable GL must not (GL shares the host
-/// and may still be starting — hard-failing there turns that into a deploy crash-loop).
+/// The validator is advisory: enforcement lives in ,
+/// which refuses to store a bad mapping in the first place. So the behaviour that matters here is that
+/// it cannot abort startup. An earlier revision threw on a missing code and core-dumped
+/// erpcore.service on deploy (2026-10-07), turning a long-standing data mismatch into an outage.
///
public class GlAccountCodeValidatorTests
{
[Fact]
- public async Task StartAsync_Succeeds_WhenEveryConfiguredCodeExistsInGl()
+ public async Task StartAsync_Succeeds_WhenEveryMappedCodeExistsInGl()
{
var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
- new StubGl(["1100", "2000", "4000"]));
+ new(),
+ new StubGeneralLedgerService([
+ StubGeneralLedgerService.Postable("1100"),
+ StubGeneralLedgerService.Postable("2000")
+ ]),
+ (GlAccountRole.Inventory, "1100"), (GlAccountRole.Clearing, "2000"));
await validator.StartAsync(CancellationToken.None);
}
- ///
- /// The default must not block startup. Throwing here cost an outage on 2026-10-07 — a config
- /// mismatch that had existed all along became a core-dump the moment the check shipped.
- ///
[Fact]
- public async Task StartAsync_DoesNotThrow_WhenCodesAreMissing_ByDefault()
+ public async Task StartAsync_DoesNotThrow_WhenMappedCodesAreMissingFromGl()
{
var validator = CreateValidator(
- new()
- {
- ["Grn:GlInventoryAccountCode"] = "1100",
- ["SalesDayEnd:GlCashAccountCode"] = "1000-01",
- ["Adjustment:GlLossAccountCode"] = "5100"
- },
- new StubGl(["1000-01"]));
+ new(),
+ new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1000-01")]),
+ (GlAccountRole.Inventory, "1100"), (GlAccountRole.Cash, "1000-01"));
await validator.StartAsync(CancellationToken.None);
}
@@ -49,14 +46,11 @@ public class GlAccountCodeValidatorTests
public async Task StartAsync_Throws_AndNamesEveryMissingCode_WhenConfiguredToFail()
{
var validator = CreateValidator(
- new()
- {
- ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true",
- ["Grn:GlInventoryAccountCode"] = "1100",
- ["SalesDayEnd:GlCashAccountCode"] = "1000-01",
- ["Adjustment:GlLossAccountCode"] = "5100"
- },
- new StubGl(["1100"]));
+ new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
+ new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]),
+ (GlAccountRole.Inventory, "1100"),
+ (GlAccountRole.Cash, "1000-01"),
+ (GlAccountRole.InventoryLoss, "5100"));
var ex = await Assert.ThrowsAsync(
() => validator.StartAsync(CancellationToken.None));
@@ -64,97 +58,76 @@ public class GlAccountCodeValidatorTests
Assert.Contains("1000-01", ex.Message);
Assert.Contains("5100", ex.Message);
Assert.Contains("2 of 3", ex.Message);
- Assert.DoesNotContain("1100,", ex.Message);
}
///
- /// The deploy-killer: if GL's /accounts shape ever changes, the reader yields nothing and a naive
- /// validator would call every configured code missing and refuse to start.
+ /// If GL's /accounts shape ever changes, the reader yields nothing. Treating that as
+ /// "everything is missing" would refuse to start on a contract change.
///
[Fact]
- public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoCodesAtAll()
+ public async Task StartAsync_DoesNotThrow_WhenGlReturnsNoAccountsAtAll()
{
var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100", ["Fifo:GlClearingAccountCode"] = "2000" },
- new StubGl([]));
-
- await validator.StartAsync(CancellationToken.None);
- }
-
- [Fact]
- public async Task StartAsync_DoesNotThrow_WhenGlIsUnreachable()
- {
- var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100" },
- new StubGl(new DomainException(ErrorCodes.GlServiceUnavailable, "unreachable", 503)));
+ new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
+ new StubGeneralLedgerService([]),
+ (GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
///
- /// The earlier catch filter only matched DomainException/OperationCanceledException, so anything
- /// else — a parse failure, or a null `items` NRE in the reader — escaped and aborted the host.
- /// A diagnostic must not be able to crash the thing it is diagnosing.
+ /// The catch filter once matched only DomainException/OperationCanceledException, so anything
+ /// else — a parse failure, a null-items NRE in the reader, a database hiccup reading the
+ /// mapping — escaped a hosted service and aborted the host.
///
[Theory]
[MemberData(nameof(UnexpectedFailures))]
public async Task StartAsync_DoesNotThrow_OnAnyUnexpectedFailure(Exception failure)
{
var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100" },
- new StubGl(failure));
+ new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
+ new StubGeneralLedgerService(failure),
+ (GlAccountRole.Inventory, "1100"));
await validator.StartAsync(CancellationToken.None);
}
public static TheoryData UnexpectedFailures() =>
[
+ StubGeneralLedgerService.Unreachable(),
new NullReferenceException("items was null"),
new global::System.Text.Json.JsonException("unexpected shape"),
new InvalidOperationException("something else entirely"),
new HttpRequestException("connection reset"),
+ new OperationCanceledException(),
];
[Fact]
- public async Task StartAsync_DoesNotThrow_WhenTheChartOfAccountsReadIsCancelled()
+ public async Task StartAsync_SkipsTheGlCall_WhenNothingIsMapped()
{
- var validator = CreateValidator(
- new() { ["Grn:GlInventoryAccountCode"] = "1100" },
- new StubGl(new OperationCanceledException()));
+ var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
- await validator.StartAsync(CancellationToken.None);
- }
-
- /// Only `Gl*Code` keys are account codes — a sibling setting must not be demanded of GL.
- [Fact]
- public async Task StartAsync_IgnoresSettingsThatAreNotAccountCodes()
- {
- var gl = new StubGl(["1100"]);
- var validator = CreateValidator(
- new()
- {
- ["Grn:GlInventoryAccountCode"] = "1100",
- ["Grn:GlPostingEnabled"] = "true",
- ["Grn:SomeOtherCode"] = "not-an-account"
- },
- gl);
-
- await validator.StartAsync(CancellationToken.None);
-
- Assert.Equal(1, gl.Calls);
- }
-
- [Fact]
- public async Task StartAsync_SkipsTheGlCall_WhenNoCodesAreConfigured()
- {
- var gl = new StubGl(["1100"]);
var validator = CreateValidator(new(), gl);
-
await validator.StartAsync(CancellationToken.None);
Assert.Equal(0, gl.Calls);
}
+ /// Duplicate codes across roles are one account to verify, not several.
+ [Fact]
+ public async Task StartAsync_DeduplicatesCodesSharedBySeveralRoles()
+ {
+ var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
+
+ var validator = CreateValidator(
+ new() { ["GeneralLedgerService:FailOnMissingAccountCodes"] = "true" },
+ gl,
+ (GlAccountRole.Inventory, "1100"), (GlAccountRole.InventoryGain, "1100"));
+
+ await validator.StartAsync(CancellationToken.None);
+ Assert.Equal(1, gl.Calls);
+ }
+
/// Asia/Colombo is UTC+05:30, so 20:00 UTC on the 31st is already the 1st locally.
[Fact]
public void BusinessClock_MapsAUtcInstantToTheLocalBusinessDay()
@@ -165,39 +138,14 @@ public class GlAccountCodeValidatorTests
Assert.Equal(new DateOnly(2026, 11, 1), BusinessClock.ToBusinessDate(new DateTime(2026, 10, 31, 18, 30, 0, DateTimeKind.Utc)));
}
- private static GlAccountCodeValidator CreateValidator(Dictionary settings, IGeneralLedgerService gl)
+ private static GlAccountCodeValidator CreateValidator(
+ Dictionary settings, StubGeneralLedgerService gl, params (GlAccountRole, string?)[] mapping)
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
- var services = new ServiceCollection().AddScoped(_ => gl).BuildServiceProvider();
+ var services = new ServiceCollection()
+ .AddScoped(_ => gl)
+ .AddScoped(_ => new StubGlAccountConfigService(mapping))
+ .BuildServiceProvider();
return new GlAccountCodeValidator(services, configuration, NullLogger.Instance);
}
-
- private sealed class StubGl(HashSet? codes = null, Exception? throws = null) : IGeneralLedgerService
- {
- private readonly HashSet _codes = codes ?? [];
- private readonly Exception? _throws = throws;
-
- public StubGl(Exception throws) : this(null, throws) { }
-
- public int Calls { get; private set; }
-
- public Task> ListAccountCodesAsync(CancellationToken ct)
- {
- Calls++;
- return _throws is not null ? Task.FromException>(_throws) : Task.FromResult(_codes);
- }
-
- public Task ForwardAsync(
- HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
- => throw new NotSupportedException();
-
- public Task PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
- => throw new NotSupportedException();
-
- public Task GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
- => throw new NotSupportedException();
-
- public Task> ListBankAccountsAsync(CancellationToken ct)
- => throw new NotSupportedException();
- }
}
diff --git a/Backend/ERPCore.Tests/GlAccountConfigServiceTests.cs b/Backend/ERPCore.Tests/GlAccountConfigServiceTests.cs
new file mode 100644
index 0000000..2f1732e
--- /dev/null
+++ b/Backend/ERPCore.Tests/GlAccountConfigServiceTests.cs
@@ -0,0 +1,234 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Dtos.Config;
+using ERPCore.Infra.Persistence;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.Services.Gl;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+
+namespace ERPCore.Tests;
+
+///
+/// The point of this service is that a mapping GL cannot post to never reaches the database.
+/// Validation at the point of entry is what the old appsettings approach could not do: those codes
+/// were only ever checked at boot, and on 2026-10-07 eleven of twelve turned out never to have
+/// existed in GL at all.
+///
+public class GlAccountConfigServiceTests
+{
+ [Fact]
+ public async Task UpdateAsync_PersistsAMappingGlCanPostTo()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var gl = new StubGeneralLedgerService([
+ StubGeneralLedgerService.Postable("1100", "Inventory"),
+ StubGeneralLedgerService.Postable("2000", "GRNI")
+ ]);
+ var service = CreateService(db, gl);
+ var before = (await service.GetAsync()).RowVersion;
+
+ var result = await service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "2000" }, before);
+
+ Assert.True(result.RowVersion > before);
+ var codes = await CreateService(db, gl).GetCodesAsync();
+ Assert.Equal("1100", codes[GlAccountRole.Inventory]);
+ Assert.Equal("2000", codes[GlAccountRole.Clearing]);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RejectsACodeGlDoesNotHave_AndNamesIt()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
+ var rowVersion = (await service.GetAsync()).RowVersion;
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100", ClearingAccountCode = "9999" }, rowVersion));
+
+ Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
+ Assert.Equal(422, ex.StatusCode);
+ Assert.Contains("9999", ex.Message);
+ Assert.Contains("Clearing", ex.Message);
+ await AssertNothingStoredAsync(db);
+ }
+
+ ///
+ /// GL rejects a journal line against a non-postable account, so storing one only defers the
+ /// failure to posting time. The auto-created 1000 Bank/Cash header is exactly this trap.
+ ///
+ [Fact]
+ public async Task UpdateAsync_RejectsANonPostableAccount()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([
+ StubGeneralLedgerService.NotPostable("1000", "Bank and Cash")
+ ]));
+ var rowVersion = (await service.GetAsync()).RowVersion;
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { CashAccountCode = "1000" }, rowVersion));
+
+ Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
+ Assert.Contains("not postable", ex.Message);
+ await AssertNothingStoredAsync(db);
+ }
+
+ /// Fails closed, deliberately opposite to the startup validator: an unverified mapping is not a valid one.
+ [Fact]
+ public async Task UpdateAsync_RefusesToSaveWhenGlIsUnreachable()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, 1));
+
+ Assert.Equal(ErrorCodes.GlServiceUnavailable, ex.Code);
+ await AssertNothingStoredAsync(db);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RefusesToSaveAgainstAnEmptyChartOfAccounts()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([]));
+ var rowVersion = (await service.GetAsync()).RowVersion;
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, rowVersion));
+
+ Assert.Equal(ErrorCodes.GlAccountInvalid, ex.Code);
+ Assert.Equal(503, ex.StatusCode);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_RejectsAStaleIfMatch()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]));
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(
+ new UpdateGlAccountConfigRequest { InventoryAccountCode = "1100" }, expectedRowVersion: 9999));
+
+ Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
+ Assert.Equal(412, ex.StatusCode);
+ }
+
+ /// An empty string clears a role rather than storing "" and posting it to GL.
+ [Fact]
+ public async Task UpdateAsync_TreatsBlankAsCleared()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var gl = new StubGeneralLedgerService([StubGeneralLedgerService.Postable("1100")]);
+ var service = CreateService(db, gl);
+ var rowVersion = (await service.GetAsync()).RowVersion;
+
+ await service.UpdateAsync(new UpdateGlAccountConfigRequest { InventoryAccountCode = " " }, rowVersion);
+
+ var codes = await CreateService(db, gl).GetCodesAsync();
+ Assert.Null(codes.GetOrNull(GlAccountRole.Inventory));
+ }
+
+ /// Reading an unmapped role names the role, instead of posting "" and letting GL 404.
+ [Fact]
+ public async Task GetCodesAsync_ThrowsNamingTheRole_WhenUnmapped()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([]));
+
+ var codes = await service.GetCodesAsync();
+ var ex = Assert.Throws(() => codes[GlAccountRole.Cogs]);
+
+ Assert.Equal(ErrorCodes.GlAccountNotConfigured, ex.Code);
+ Assert.Contains("Cogs", ex.Message);
+ }
+
+ /// One request can post through several services; they should not each re-read the row.
+ [Fact]
+ public async Task GetCodesAsync_ReadsOncePerScope()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([]));
+
+ var first = await service.GetCodesAsync();
+ var second = await service.GetCodesAsync();
+
+ Assert.Same(first, second);
+ }
+
+ /// GL being down must not stop the saved mapping from rendering on the settings screen.
+ [Fact]
+ public async Task GetAsync_StillReturnsTheMapping_WhenGlIsUnreachable()
+ {
+ await using var db = await CreateSeededContextAsync(c => c.InventoryAccountCode = "1100");
+ var service = CreateService(db, new StubGeneralLedgerService(StubGeneralLedgerService.Unreachable()));
+
+ var result = await service.GetAsync();
+
+ Assert.True(result.Value.GlUnavailable);
+ Assert.Empty(result.Value.Options);
+ var inventory = result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory));
+ Assert.Equal("1100", inventory.AccountCode);
+ }
+
+ /// The 2026-10-07 state: a stored code GL does not have is shown flagged, not blank.
+ [Fact]
+ public async Task GetAsync_FlagsAStoredCodeThatGlDoesNotHave()
+ {
+ await using var db = await CreateSeededContextAsync(c =>
+ {
+ c.InventoryAccountCode = "1100";
+ c.CashAccountCode = "1000-01";
+ });
+ var service = CreateService(db, new StubGeneralLedgerService([
+ StubGeneralLedgerService.Postable("1000-01", "Cash in Hand")
+ ]));
+
+ var result = await service.GetAsync();
+
+ Assert.False(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Inventory)).ExistsInGl);
+ Assert.True(result.Value.Mappings.Single(m => m.Role == nameof(GlAccountRole.Cash)).ExistsInGl);
+ }
+
+ /// The picker must only offer what validation will accept.
+ [Fact]
+ public async Task GetAsync_OffersOnlyPostableActiveAccounts()
+ {
+ await using var db = await CreateSeededContextAsync();
+ var service = CreateService(db, new StubGeneralLedgerService([
+ StubGeneralLedgerService.Postable("1100", "Inventory"),
+ StubGeneralLedgerService.NotPostable("1000", "Bank and Cash"),
+ new GlAccount("1200", "Retired", 1, true, false)
+ ]));
+
+ var options = (await service.GetAsync()).Value.Options;
+
+ Assert.Equal(["1100"], options.Select(o => o.AccountCode));
+ }
+
+ private static async Task AssertNothingStoredAsync(ErpDbContext db)
+ {
+ var row = await new Repository(db).GetByIdAsync(GlAccountConfig.SingletonId);
+ Assert.Null(row!.InventoryAccountCode);
+ Assert.Null(row.ClearingAccountCode);
+ Assert.Null(row.CashAccountCode);
+ }
+
+ private static async Task CreateSeededContextAsync(Action? configure = null)
+ {
+ var db = NotificationConfigTestSupport.CreateContext(Guid.NewGuid().ToString("N"));
+ db.Users.Add(new User { UserId = User.SystemUserId, Username = "system", DisplayName = "System" });
+
+ var config = new GlAccountConfig { ConfigId = GlAccountConfig.SingletonId };
+ configure?.Invoke(config);
+ db.GlAccountConfig.Add(config);
+ await db.SaveChangesAsync();
+ return db;
+ }
+
+ private static GlAccountConfigService CreateService(ErpDbContext db, IGeneralLedgerService gl) =>
+ new(new Repository(db), gl, new TestCurrentUser(), new ERPCore.Infra.UoW.UnitOfWork(db));
+}
diff --git a/Backend/ERPCore.Tests/TestSupport/GlTestSupport.cs b/Backend/ERPCore.Tests/TestSupport/GlTestSupport.cs
new file mode 100644
index 0000000..14cd8af
--- /dev/null
+++ b/Backend/ERPCore.Tests/TestSupport/GlTestSupport.cs
@@ -0,0 +1,66 @@
+using ERPCore.Infra.Gl;
+using ERPCore.Services.Gl;
+using ERPCore.Services.Interfaces;
+using ERPCore.System.Errors;
+
+namespace ERPCore.Tests.TestSupport;
+
+///
+/// Stands in for the external GL service. Only is implemented —
+/// nothing under test here posts a journal, and leaving the rest throwing means a test that starts
+/// doing so fails loudly rather than against a silently empty fake.
+///
+internal sealed class StubGeneralLedgerService : IGeneralLedgerService
+{
+ private readonly IReadOnlyList _accounts;
+ private readonly Exception? _throws;
+
+ public StubGeneralLedgerService(IReadOnlyList accounts) => _accounts = accounts;
+ public StubGeneralLedgerService(Exception throws) { _accounts = []; _throws = throws; }
+
+ public int Calls { get; private set; }
+
+ /// A postable, active account — the ordinary case.
+ public static GlAccount Postable(string code, string name = "Account", short type = 1) => new(code, name, type, true, true);
+
+ /// A header/control account. GL rejects journal lines against these.
+ public static GlAccount NotPostable(string code, string name = "Header", short type = 1) => new(code, name, type, false, true);
+
+ public Task> ListAccountsAsync(CancellationToken ct)
+ {
+ Calls++;
+ return _throws is not null
+ ? Task.FromException>(_throws)
+ : Task.FromResult(_accounts);
+ }
+
+ public Task ForwardAsync(
+ HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
+ => throw new NotSupportedException();
+
+ public Task PostJournalEntryAsync(GlJournalEntryRequest request, CancellationToken ct)
+ => throw new NotSupportedException();
+
+ public Task GetPeriodByDateAsync(DateOnly date, CancellationToken ct)
+ => throw new NotSupportedException();
+
+ public Task> ListBankAccountsAsync(CancellationToken ct)
+ => throw new NotSupportedException();
+
+ public static DomainException Unreachable() =>
+ new(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
+}
+
+/// Returns a fixed mapping, for exercising the startup validator without a database.
+internal sealed class StubGlAccountConfigService(params (GlAccountRole Role, string? Code)[] mappings) : IGlAccountConfigService
+{
+ public Task GetCodesAsync(CancellationToken ct = default) =>
+ Task.FromResult(new GlAccountCodes(mappings.ToDictionary(m => m.Role, m => m.Code)));
+
+ public Task> GetAsync(CancellationToken ct = default)
+ => throw new NotSupportedException();
+
+ public Task> UpdateAsync(
+ ERPCore.Dtos.Config.UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
+ => throw new NotSupportedException();
+}
diff --git a/Backend/ERPCore/Controllers/GlAccountConfigController.cs b/Backend/ERPCore/Controllers/GlAccountConfigController.cs
new file mode 100644
index 0000000..519c9c5
--- /dev/null
+++ b/Backend/ERPCore/Controllers/GlAccountConfigController.cs
@@ -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;
+
+///
+/// Role → GL account mapping endpoints.
+///
+/// Authorization: the settings nav policy, like .
+/// Note this endpoint also serves the selectable accounts, rather than the screen calling the GL
+/// proxy directly: is gated on ledgers, 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.
+///
+///
+[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> Get(CancellationToken ct)
+ {
+ var result = await _config.GetAsync(ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPut]
+ [ProducesResponseType(typeof(GlAccountConfigDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
+ public async Task> Update(
+ [FromBody] UpdateGlAccountConfigRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _config.UpdateAsync(request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+}
diff --git a/Backend/ERPCore/Dtos/Config/GlAccountConfigDtos.cs b/Backend/ERPCore/Dtos/Config/GlAccountConfigDtos.cs
new file mode 100644
index 0000000..f3dbbc8
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Config/GlAccountConfigDtos.cs
@@ -0,0 +1,50 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Entities;
+
+namespace ERPCore.Dtos.Config;
+
+/// One selectable GL account, for the mapping pickers.
+public sealed record GlAccountOptionDto(string AccountCode, string AccountName, short AccountTypeId, bool IsPostable, bool IsActive);
+
+///
+/// One role's current mapping. 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.
+///
+public sealed record GlAccountMappingDto(
+ string Role, string? AccountCode, string? AccountName, bool ExistsInGl, bool IsPostable);
+
+///
+/// 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.
+///
+/// is true when GL could not be reached: mappings still render from the
+/// database, is empty, and saving will be refused until GL answers.
+///
+///
+public sealed record GlAccountConfigDto(
+ IReadOnlyList Mappings,
+ IReadOnlyList Options,
+ bool GlUnavailable,
+ DateTime? UpdatedAt,
+ int? UpdatedBy);
+
+///
+/// Full replacement of the mapping. Every role is sent on every save; null or empty clears a role.
+/// UpdatedBy is derived from the token, never posted.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Infra/Gl/GlAccountCodeValidator.cs b/Backend/ERPCore/Infra/Gl/GlAccountCodeValidator.cs
index 61b4ff4..26ae0b6 100644
--- a/Backend/ERPCore/Infra/Gl/GlAccountCodeValidator.cs
+++ b/Backend/ERPCore/Infra/Gl/GlAccountCodeValidator.cs
@@ -1,12 +1,18 @@
+using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
namespace ERPCore.Infra.Gl;
///
-/// Startup guard for the GL chart of accounts. Every Gl*Code in configuration names an
-/// account that must already exist in GL; until now a typo or an un-seeded account only surfaced
-/// as a 404 when some user happened to trigger that one flow, mid-transaction — one flow, one
-/// discovery, repeat. This asserts the whole set once, at boot.
+/// Startup guard for the GL chart of accounts. Every account mapped in gl_account_config
+/// must exist in GL; without this, an un-seeded account only surfaced as a 404 when some user
+/// happened to trigger that one flow, mid-transaction — one flow, one discovery, repeat. This
+/// asserts the whole set once, at boot.
+///
+/// Advisory only. Enforcement lives at the point of entry — GlAccountConfigService.UpdateAsync
+/// refuses to store a code GL does not have or cannot post to — so this is a net for accounts that
+/// disappeared from GL *after* being mapped.
+///
///
/// Reports, does not block. An earlier revision threw on a missing code, on the reasoning
/// that the config is wrong and waiting won't fix it. In practice that took erpcore.service down
@@ -26,39 +32,41 @@ public sealed class GlAccountCodeValidator(
IConfiguration configuration,
ILogger logger) : IHostedService
{
- /// Configuration sections that carry GL account codes — see appsettings.json.
- private static readonly string[] Sections =
- ["Grn", "SalesDayEnd", "Adjustment", "SalesInvoice", "PurchaseInvoice", "Fifo"];
-
/// Startup must not stall on a hung GL — HttpClient's own default is 100s.
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(10);
public async Task StartAsync(CancellationToken ct)
{
- var expected = Sections
- .SelectMany(s => configuration.GetSection(s).GetChildren())
- .Where(c => c.Key.StartsWith("Gl", StringComparison.Ordinal) && c.Key.EndsWith("Code", StringComparison.Ordinal))
- .Select(c => c.Value)
- .Where(v => !string.IsNullOrWhiteSpace(v))
- .Select(v => v!)
- .Distinct(StringComparer.Ordinal)
- .ToArray();
-
- if (expected.Length == 0)
- {
- logger.LogWarning("No GL account codes are configured — skipping chart-of-accounts verification.");
- return;
- }
-
+ string[] expected;
HashSet present;
+
+ // The database read sits inside the catch-all too: a DB hiccup here would otherwise escape
+ // a hosted service and reopen the startup-abort hole this class already caused once.
try
{
using var scope = services.CreateScope();
+ var mapping = scope.ServiceProvider.GetRequiredService();
var gl = scope.ServiceProvider.GetRequiredService();
+ var codes = await mapping.GetCodesAsync(ct);
+ expected = Enum.GetValues()
+ .Select(codes.GetOrNull)
+ .Where(c => c is not null)
+ .Select(c => c!)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ if (expected.Length == 0)
+ {
+ logger.LogWarning(
+ "No GL accounts are mapped — skipping chart-of-accounts verification. "
+ + "Map them under Settings → GL Accounts; posting flows will fail until then.");
+ return;
+ }
+
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout);
- present = await gl.ListAccountCodesAsync(timeout.Token);
+ present = (await gl.ListAccountsAsync(timeout.Token)).Select(a => a.AccountCode).ToHashSet(StringComparer.Ordinal);
}
// Catch-all on purpose. This is a diagnostic, and a diagnostic that can abort the host is
// worse than no diagnostic — whatever GL answers with (unreachable, a timeout, a redirect,
@@ -67,8 +75,7 @@ public sealed class GlAccountCodeValidator(
catch (Exception e) when (!ct.IsCancellationRequested)
{
logger.LogWarning(e,
- "Could not read GL's chart of accounts at startup; {Count} configured account code(s) left unverified.",
- expected.Length);
+ "Could not verify the GL account mapping at startup; it is left unchecked.");
return;
}
@@ -78,7 +85,7 @@ public sealed class GlAccountCodeValidator(
if (present.Count == 0)
{
logger.LogWarning(
- "GL returned no account codes; {Count} configured account code(s) left unverified. " +
+ "GL returned no account codes; {Count} mapped account code(s) left unverified. " +
"If GL's chart of accounts is not actually empty, the /accounts response shape has changed.",
expected.Length);
return;
@@ -88,10 +95,10 @@ public sealed class GlAccountCodeValidator(
if (missing.Length > 0)
{
var message =
- $"General Ledger is missing {missing.Length} of {expected.Length} configured account code(s): " +
+ $"General Ledger is missing {missing.Length} of {expected.Length} mapped account code(s): " +
$"{string.Join(", ", missing.Order(StringComparer.Ordinal))}. Every flow that posts to one of these " +
- "will fail against GL until they are seeded in GL's chart of accounts, or the Gl*Code settings are " +
- "corrected to match the codes GL actually uses.";
+ "will fail against GL until the accounts are created in GL, or the mapping is corrected under " +
+ "Settings → GL Accounts.";
if (configuration.GetValue("GeneralLedgerService:FailOnMissingAccountCodes", false))
throw new InvalidOperationException(message);
@@ -100,7 +107,7 @@ public sealed class GlAccountCodeValidator(
return;
}
- logger.LogInformation("GL account codes verified: {Count} present.", expected.Length);
+ logger.LogInformation("GL account mapping verified: {Count} account code(s) present.", expected.Length);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
index 49c358c..4e3e681 100644
--- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
+++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
@@ -47,7 +47,8 @@ public static class DataSeeder
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
];
- public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
+ public static async Task SeedAsync(
+ ErpDbContext db, IConfiguration? configuration = null, CancellationToken ct = default)
{
// Must come first, and must save on its own: every branch-scoped entity has a
// non-nullable BranchId with a Restrict FK, so nothing branch-scoped can be written
@@ -60,6 +61,7 @@ public static class DataSeeder
dirty |= await SeedBranchConfigAsync(db, ct);
dirty |= await SeedSalesConfigAsync(db, ct);
dirty |= await SeedGrnConfigAsync(db, ct);
+ dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
@@ -199,6 +201,49 @@ public static class DataSeeder
return true;
}
+ ///
+ /// Seeds the GL account mapping singleton, carrying over the legacy Gl*Code appsettings
+ /// values once so an existing deployment keeps the mapping it has been running with.
+ ///
+ /// Those keys are seed-only: nothing reads them at runtime any more. Leaving a runtime
+ /// fallback in place would restore the two-sources-of-truth problem this table exists to end —
+ /// after seeding, gl_account_config is authoritative and is edited from
+ /// Settings → GL Accounts, not by redeploying.
+ ///
+ ///
+ /// Deliberately does not call GL: seeding runs inside the startup block that aborts the app on
+ /// failure (see Program.cs), so it must not depend on another service being up. The codes are
+ /// verified separately — advisory at boot by GlAccountCodeValidator, and enforced on
+ /// every save by GlAccountConfigService.UpdateAsync.
+ ///
+ ///
+ private static async Task SeedGlAccountConfigAsync(
+ ErpDbContext db, IConfiguration? configuration, CancellationToken ct)
+ {
+ if (await db.GlAccountConfig.AnyAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct)) return false;
+
+ string? Legacy(string key) =>
+ string.IsNullOrWhiteSpace(configuration?[key]) ? null : configuration[key]!.Trim();
+
+ db.GlAccountConfig.Add(new GlAccountConfig
+ {
+ ConfigId = GlAccountConfig.SingletonId,
+ InventoryAccountCode = Legacy("Grn:GlInventoryAccountCode"),
+ VatRecoverableAccountCode = Legacy("Grn:GlVatRecoverableAccountCode"),
+ AccountsReceivableCode = Legacy("SalesInvoice:GlAccountsReceivableCode"),
+ ClearingAccountCode = Legacy("Grn:GlClearingAccountCode"),
+ TaxPayableAccountCode = Legacy("SalesDayEnd:GlTaxPayableAccountCode"),
+ AccountsPayableCode = Legacy("PurchaseInvoice:GlAccountsPayableCode"),
+ SalesRevenueAccountCode = Legacy("SalesDayEnd:GlSalesRevenueAccountCode"),
+ SalesDiscountAccountCode = Legacy("SalesDayEnd:GlSalesDiscountAccountCode"),
+ InventoryGainAccountCode = Legacy("Adjustment:GlGainAccountCode"),
+ CogsAccountCode = Legacy("SalesDayEnd:GlCogsAccountCode"),
+ InventoryLossAccountCode = Legacy("Adjustment:GlLossAccountCode"),
+ CashAccountCode = Legacy("SalesDayEnd:GlCashAccountCode")
+ });
+ return true;
+ }
+
private static async Task SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
diff --git a/Backend/ERPCore/Migrations/20261007072921_AddGlAccountConfig.Designer.cs b/Backend/ERPCore/Migrations/20261007072921_AddGlAccountConfig.Designer.cs
new file mode 100644
index 0000000..444577c
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20261007072921_AddGlAccountConfig.Designer.cs
@@ -0,0 +1,8999 @@
+//
+using System;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace ERPCore.Migrations
+{
+ [DbContext(typeof(ErpDbContext))]
+ [Migration("20261007072921_AddGlAccountConfig")]
+ partial class AddGlAccountConfig
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .UseCollation("utf8mb4_unicode_ci")
+ .HasAnnotation("ProductVersion", "9.0.20")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
+ {
+ b.Property("AttendanceRecordId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AttendanceRecordId"));
+
+ b.Property("AttendanceDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("AttendanceStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("AttendanceUploadBatchId")
+ .HasColumnType("int");
+
+ b.Property("CheckIn")
+ .HasColumnType("time(6)");
+
+ b.Property("CheckOut")
+ .HasColumnType("time(6)");
+
+ b.Property("DuplicateOfAttendanceRecordId")
+ .HasColumnType("int");
+
+ b.Property("EarlyLeaveMinutes")
+ .HasColumnType("int");
+
+ b.Property("EditedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EditedBy")
+ .HasColumnType("int");
+
+ b.Property("EmployeeId")
+ .HasColumnType("int");
+
+ b.Property("IsManualOverride")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LateMinutes")
+ .HasColumnType("int");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("OvertimeMinutes")
+ .HasColumnType("int");
+
+ b.Property("RowValidationStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("int");
+
+ b.Property("WorkingMinutes")
+ .HasColumnType("int");
+
+ b.HasKey("AttendanceRecordId");
+
+ b.HasIndex("AttendanceUploadBatchId");
+
+ b.HasIndex("WorkShiftId");
+
+ b.HasIndex("EmployeeId", "AttendanceDate");
+
+ b.ToTable("hr_attendance_records", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b =>
+ {
+ b.Property("AttendanceUploadBatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AttendanceUploadBatchId"));
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ConfirmedBy")
+ .HasColumnType("int");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(260)
+ .HasColumnType("varchar(260)");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("datetime(6)");
+
+ b.Property("PeriodStart")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RowCountDuplicate")
+ .HasColumnType("int");
+
+ b.Property("RowCountError")
+ .HasColumnType("int");
+
+ b.Property("RowCountTotal")
+ .HasColumnType("int");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("UploadedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UploadedBy")
+ .HasColumnType("int");
+
+ b.HasKey("AttendanceUploadBatchId");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("PeriodStart", "PeriodEnd");
+
+ b.ToTable("hr_attendance_upload_batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b =>
+ {
+ b.Property("AuditId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AuditId"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("varchar(10)");
+
+ b.Property("ChangeSet")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EntityId")
+ .HasColumnType("int");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("varchar(80)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("AuditId");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_logs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
+ {
+ b.Property("BatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BatchId"));
+
+ b.Property("Barcode")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("BatchNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("date");
+
+ b.Property("ItemId")
+ .HasColumnType("int");
+
+ b.HasKey("BatchId");
+
+ b.HasIndex("Barcode")
+ .IsUnique();
+
+ b.HasIndex("ItemId", "BatchNo")
+ .IsUnique();
+
+ b.ToTable("batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
+ {
+ b.Property("BinId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BinId"));
+
+ b.Property("BinType")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("int");
+
+ b.HasKey("BinId");
+
+ b.HasIndex("WarehouseId", "Code")
+ .IsUnique();
+
+ b.ToTable("bins", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Branch", b =>
+ {
+ b.Property("BranchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BranchId"));
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("PhoneNumber")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("BranchId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_branches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b =>
+ {
+ b.Property("ConfigId")
+ .HasColumnType("int");
+
+ b.Property("BranchingEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("int");
+
+ b.Property("WarehouseBranchingEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.HasKey("ConfigId");
+
+ b.HasIndex("UpdatedBy");
+
+ b.ToTable("branch_config", null, t =>
+ {
+ t.HasCheckConstraint("ck_branch_config_singleton", "`ConfigId` = 1");
+ });
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
+ {
+ b.Property("BrandId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BrandId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("BrandId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("brands", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b =>
+ {
+ b.Property("BundleSaleId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleId"));
+
+ b.Property("BundleCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BundleDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("BundleName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("BundleNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("BundlePrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("int");
+
+ b.Property("CashierUserId")
+ .HasColumnType("int");
+
+ b.Property("ComponentSubtotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CustomerId")
+ .HasColumnType("int");
+
+ b.Property("CustomerSnapshotName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("DayEndId")
+ .HasColumnType("int");
+
+ b.Property("DiscountTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("GrandTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("MarginAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Draft");
+
+ b.Property("TaxTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("int");
+
+ b.HasKey("BundleSaleId");
+
+ b.HasIndex("BundleNo")
+ .IsUnique();
+
+ b.HasIndex("BundleSaleTemplateId");
+
+ b.HasIndex("CashierUserId");
+
+ b.HasIndex("CustomerId");
+
+ b.HasIndex("DayEndId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sales", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b =>
+ {
+ b.Property("BundleSaleLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleLineId"));
+
+ b.Property("BundleSaleId")
+ .HasColumnType("int");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("IncludeInBundle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("IsComponent")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("tinyint(1)")
+ .HasDefaultValue(true);
+
+ b.Property("ItemId")
+ .HasColumnType("int");
+
+ b.Property("LineTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("ParentLineId")
+ .HasColumnType("int");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("int");
+
+ b.HasKey("BundleSaleLineId");
+
+ b.HasIndex("BundleSaleId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sale_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b =>
+ {
+ b.Property("BundleSaleTemplateId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleTemplateId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TemplateCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("TemplateName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("BundleSaleTemplateId");
+
+ b.HasIndex("TemplateCode")
+ .IsUnique();
+
+ b.ToTable("bundle_sale_templates", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b =>
+ {
+ b.Property("BundleSaleTemplateLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("BundleSaleTemplateLineId"));
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("int");
+
+ b.Property("IncludeInBundle")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ItemId")
+ .HasColumnType("int");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("SortOrder")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(0);
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("int");
+
+ b.HasKey("BundleSaleTemplateLineId");
+
+ b.HasIndex("BundleSaleTemplateId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("bundle_sale_template_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
+ {
+ b.Property("CategoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CategoryId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("CategoryId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b =>
+ {
+ b.Property("CustomerId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CustomerId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(250)
+ .HasColumnType("varchar(250)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(250)
+ .HasColumnType("varchar(250)");
+
+ b.Property("BranchId")
+ .HasColumnType("int");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreditDays")
+ .HasColumnType("int");
+
+ b.Property("CreditLimit")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("CustomerCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("CustomerType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("B2C");
+
+ b.Property("DefaultWarehouseId")
+ .HasColumnType("int");
+
+ b.Property("DisplayName")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("Email")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("Phone")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TaxRegistrationNo")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("CustomerId");
+
+ b.HasIndex("BranchId");
+
+ b.HasIndex("CustomerCode")
+ .IsUnique();
+
+ b.HasIndex("CustomerType");
+
+ b.HasIndex("DefaultWarehouseId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("customers", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Department", b =>
+ {
+ b.Property("DepartmentId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("DepartmentId"));
+
+ b.Property("BranchId")
+ .HasColumnType("int");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("HeadEmployeeId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ParentDepartmentId")
+ .HasColumnType("int");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("DepartmentId");
+
+ b.HasIndex("BranchId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("HeadEmployeeId");
+
+ b.HasIndex("ParentDepartmentId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_departments", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Designation", b =>
+ {
+ b.Property("DesignationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("DesignationId"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("int unsigned");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("DesignationId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_designations", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b =>
+ {
+ b.Property("EmployeeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("BranchId")
+ .HasColumnType("int");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("ConfirmationDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedBy")
+ .HasColumnType("int");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DepartmentId")
+ .HasColumnType("int");
+
+ b.Property("DesignationId")
+ .HasColumnType("int");
+
+ b.Property("Email")
+ .HasMaxLength(320)
+ .HasColumnType("varchar(320)");
+
+ b.Property("EmergencyContactName")
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("EmergencyContactPhone")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("EmergencyContactRelationship")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("EmployeeCode")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("EmploymentTypeId")
+ .HasColumnType("int");
+
+ b.Property("EpfNumber")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("EtfNumber")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("Gender")
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("HireDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("LastWorkingDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Nationality")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Nic")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("PersonalMobile")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property