From c41aed9a80575466d43fb17ba04c0d15311f93cb Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Wed, 7 Oct 2026 13:04:49 +0530 Subject: [PATCH 1/7] 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 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 --- .../GlAccountCodeValidatorTests.cs | 168 +- .../GlAccountConfigServiceTests.cs | 234 + .../TestSupport/GlTestSupport.cs | 66 + .../Controllers/GlAccountConfigController.cs | 53 + .../Dtos/Config/GlAccountConfigDtos.cs | 50 + .../Infra/Gl/GlAccountCodeValidator.cs | 69 +- .../ERPCore/Infra/Persistence/DataSeeder.cs | 47 +- ...61007072921_AddGlAccountConfig.Designer.cs | 8999 +++++++++++++++++ .../20261007072921_AddGlAccountConfig.cs | 61 + .../Migrations/ErpDbContextModelSnapshot.cs | 83 + Backend/ERPCore/Program.cs | 3 +- Backend/ERPCore/Services/AdjustmentService.cs | 19 +- Backend/ERPCore/Services/CountService.cs | 19 +- .../ERPCore/Services/GeneralLedgerService.cs | 10 +- .../Services/GlAccountConfigService.cs | 195 + Backend/ERPCore/Services/GrnService.cs | 17 +- .../Interfaces/IGlAccountConfigService.cs | 30 + .../Services/PurchaseInvoicePaymentService.cs | 9 +- .../Services/PurchaseInvoiceService.cs | 13 +- .../ERPCore/Services/PurchaseReturnService.cs | 13 +- .../ERPCore/Services/SalesDayEndService.cs | 33 +- .../Services/SalesInvoicePaymentService.cs | 12 +- .../ERPCore/Services/SalesPostingService.cs | 29 +- .../ERPCore/Services/SalesReturnService.cs | 13 +- .../Services/Stock/FifoCostingService.cs | 14 +- 25 files changed, 10003 insertions(+), 256 deletions(-) create mode 100644 Backend/ERPCore.Tests/GlAccountConfigServiceTests.cs create mode 100644 Backend/ERPCore.Tests/TestSupport/GlTestSupport.cs create mode 100644 Backend/ERPCore/Controllers/GlAccountConfigController.cs create mode 100644 Backend/ERPCore/Dtos/Config/GlAccountConfigDtos.cs create mode 100644 Backend/ERPCore/Migrations/20261007072921_AddGlAccountConfig.Designer.cs create mode 100644 Backend/ERPCore/Migrations/20261007072921_AddGlAccountConfig.cs create mode 100644 Backend/ERPCore/Services/GlAccountConfigService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IGlAccountConfigService.cs 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("PostalCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ProfilePhotoPath") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReportingManagerId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("TaxIdentificationNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WorkShiftId") + .HasColumnType("int"); + + b.HasKey("EmployeeId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("DesignationId"); + + b.HasIndex("Email"); + + b.HasIndex("EmployeeCode") + .IsUnique(); + + b.HasIndex("EmploymentTypeId"); + + b.HasIndex("ReportingManagerId"); + + b.HasIndex("Status"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("WorkShiftId"); + + b.ToTable("hr_employees", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.Property("EmployeeBankDetailId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeBankDetailId")); + + b.Property("AccountHolderName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BankName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("BranchName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("SwiftCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("EmployeeBankDetailId"); + + b.HasIndex("EmployeeId"); + + b.ToTable("hr_employee_bank_details", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.Property("EmployeeDocumentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeDocumentId")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("HrDocumentTypeId") + .HasColumnType("int"); + + b.Property("IssueDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UploadedBy") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime(6)"); + + b.Property("VerifiedBy") + .HasColumnType("int"); + + b.HasKey("EmployeeDocumentId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("ExpiryDate"); + + b.HasIndex("HrDocumentTypeId"); + + b.ToTable("hr_employee_documents", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Property("EmployeeLoanId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeLoanId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("InstallmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("InterestRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("LoanKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("NumberOfInstallments") + .HasColumnType("int"); + + b.Property("OutstandingBalance") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StartMonth") + .HasColumnType("int"); + + b.Property("StartYear") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("EmployeeLoanId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("Status"); + + b.ToTable("hr_employee_loans", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Property("EmployeeSalaryStructureId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeSalaryStructureId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("BasicSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("varchar(3)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("EmployeeSalaryStructureId"); + + b.HasIndex("EmployeeId", "EffectiveTo"); + + b.ToTable("hr_employee_salary_structures", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.Property("EmployeeSalaryStructureLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeSalaryStructureLineId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmployeeSalaryStructureId") + .HasColumnType("int"); + + b.Property("SalaryComponentId") + .HasColumnType("int"); + + b.HasKey("EmployeeSalaryStructureLineId"); + + b.HasIndex("EmployeeSalaryStructureId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_employee_salary_structure_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmploymentType", b => + { + b.Property("EmploymentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmploymentTypeId")); + + 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("EmploymentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_employment_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("AccountsPayableCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("AccountsReceivableCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CashAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ClearingAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CogsAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("InventoryAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("InventoryGainAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("InventoryLossAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SalesDiscountAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SalesRevenueAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TaxPayableAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("VatRecoverableAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("gl_account_config", null, t => + { + t.HasCheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("PoId") + .HasColumnType("int"); + + b.Property("PostedAt") + .HasColumnType("datetime(6)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("GrnId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("PurchaseInvoiceId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("AllowDiscounts") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowVat") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowWarehouseSelection") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("DefaultWarehouseId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("ConfigId"); + + b.HasIndex("DefaultWarehouseId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("grn_config", null, t => + { + t.HasCheckConstraint("ck_grn_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("GrnId") + .HasColumnType("int"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PoLineId") + .HasColumnType("int"); + + b.Property("PoUnitPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("VatAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VatPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => + { + b.Property("GrnLineWarrantyNumberId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineWarrantyNumberId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("WarrantyNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + + b.HasKey("GrnLineWarrantyNumberId"); + + b.HasIndex("GrnLineId", "WarrantyNo") + .IsUnique(); + + b.ToTable("grn_line_warranty_numbers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b => + { + b.Property("HrDocumentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HrDocumentTypeId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpiryTracked") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequiredAtOnboarding") + .HasColumnType("tinyint(1)"); + + 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("HrDocumentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_document_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemId")); + + b.Property("Barcode") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("BaseUomId") + .HasColumnType("int"); + + b.Property("BrandId") + .HasColumnType("int"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContentBaseQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ContentBaseUnit") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ContentQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ContentUnit") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DefaultVendorId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SellingFlatPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SellingMaxPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SellingMinPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SubCategoryId") + .HasColumnType("int"); + + b.Property("TaxRateId") + .HasColumnType("int"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Warranty") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("NonWarranty"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + + b.HasKey("ItemId"); + + b.HasIndex("Barcode") + .IsUnique(); + + b.HasIndex("BaseUomId"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("SubCategoryId"); + + b.HasIndex("TaxRateId"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b => + { + b.Property("ItemImageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemImageId")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UploadedBy") + .HasColumnType("int"); + + b.HasKey("ItemImageId"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("item_images", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsMeasurable") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + 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("ItemTypeId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("item_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.Property("LeaveBalanceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveBalanceId")); + + b.Property("AdjustmentDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("CarriedForwardDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EntitledDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("LeaveTypeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("TakenDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("LeaveBalanceId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("EmployeeId", "LeaveTypeId", "Year") + .IsUnique(); + + b.ToTable("hr_leave_balances", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.Property("LeaveRequestId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveRequestId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DaysCount") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("LeaveTypeId") + .HasColumnType("int"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("LeaveRequestId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("Status"); + + b.HasIndex("StartDate", "EndDate"); + + b.ToTable("hr_leave_requests", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveType", b => + { + b.Property("LeaveTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveTypeId")); + + b.Property("AccrualPerYear") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("CarryForwardAllowed") + .HasColumnType("tinyint(1)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CountsAsNoPay") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsPaid") + .HasColumnType("tinyint(1)"); + + b.Property("MaxCarryForwardDays") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequiresApproval") + .HasColumnType("tinyint(1)"); + + 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("LeaveTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_leave_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.Property("LoanInstallmentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LoanInstallmentId")); + + b.Property("DueMonth") + .HasColumnType("int"); + + b.Property("DueYear") + .HasColumnType("int"); + + b.Property("EmployeeLoanId") + .HasColumnType("int"); + + b.Property("InstallmentNumber") + .HasColumnType("int"); + + b.Property("PaidAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("ScheduledAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("LoanInstallmentId"); + + b.HasIndex("EmployeeLoanId"); + + b.HasIndex("PayrollRunId"); + + b.HasIndex("DueYear", "DueMonth"); + + b.ToTable("hr_loan_installments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b => + { + b.Property("LoginActivityId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LoginActivityId")); + + b.Property("DeviceLabel") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("LoginAt") + .HasColumnType("datetime(6)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginActivityId"); + + b.HasIndex("UserId", "LoginAt"); + + b.ToTable("login_activities", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Property("NavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.HasKey("NavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("nav_items", (string)null); + + b.HasData( + new + { + NavItemId = 1, + Code = "dashboard", + Href = "/dashboard", + Label = "Dashboard", + SortOrder = 1, + Status = "Active" + }, + new + { + NavItemId = 2, + Code = "products", + Href = "/dashboard/products", + Label = "Products", + SortOrder = 2, + Status = "Active" + }, + new + { + NavItemId = 4, + Code = "procurement", + Href = "/dashboard/procurement", + Label = "Procurement", + SortOrder = 4, + Status = "Active" + }, + new + { + NavItemId = 5, + Code = "receiving", + Href = "/dashboard/receiving/grn", + Label = "Receiving", + SortOrder = 5, + Status = "Active" + }, + new + { + NavItemId = 6, + Code = "stock", + Href = "/dashboard/stock", + Label = "Stock", + SortOrder = 6, + Status = "Active" + }, + new + { + NavItemId = 7, + Code = "warehouses", + Href = "/dashboard/warehouse", + Label = "Warehouses", + SortOrder = 7, + Status = "Active" + }, + new + { + NavItemId = 8, + Code = "orders", + Href = "/dashboard/orders", + Label = "Orders", + SortOrder = 8, + Status = "Active" + }, + new + { + NavItemId = 9, + Code = "settings", + Href = "/dashboard/settings", + Label = "Settings", + SortOrder = 9, + Status = "Active" + }, + new + { + NavItemId = 10, + Code = "help", + Href = "/dashboard/help", + Label = "Help", + SortOrder = 10, + Status = "Active" + }, + new + { + NavItemId = 11, + Code = "ledgers", + Href = "/dashboard/ledgers", + Label = "Ledgers", + SortOrder = 11, + Status = "Active" + }, + new + { + NavItemId = 12, + Code = "accounts", + Href = "/dashboard/accounts", + Label = "Accounts", + SortOrder = 12, + Status = "Active" + }, + new + { + NavItemId = 13, + Code = "sales", + Href = "/dashboard/sales", + Label = "Sales", + SortOrder = 13, + Status = "Active" + }, + new + { + NavItemId = 14, + Code = "production", + Href = "/dashboard/production", + Label = "Production", + SortOrder = 14, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Notification", b => + { + b.Property("NotificationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ModuleKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RelatedEntityId") + .HasColumnType("int"); + + b.Property("RelatedEntityType") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("NotificationId"); + + b.HasIndex("CreatedAt"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.Property("NotificationConfigId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("NotificationConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("notification_config", null, t => + { + t.HasCheckConstraint("ck_notification_config_singleton", "`NotificationConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b => + { + b.Property("NotificationConfigModuleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationConfigModuleId")); + + b.Property("EmailEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("InAppEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ModuleKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NotificationConfigId") + .HasColumnType("int"); + + b.Property("SmsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.HasKey("NotificationConfigModuleId"); + + b.HasIndex("ModuleKey") + .IsUnique(); + + b.HasIndex("NotificationConfigId"); + + b.ToTable("notification_config_modules", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationRecipient", b => + { + b.Property("NotificationRecipientId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationRecipientId")); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("NotificationId") + .HasColumnType("int"); + + b.Property("ReadAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("NotificationRecipientId"); + + b.HasIndex("NotificationId"); + + b.HasIndex("UserId", "IsRead", "NotificationId"); + + b.ToTable("notification_recipients", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SequenceId")); + + b.Property("BranchId") + .HasColumnType("int") + .HasColumnName("branch_id"); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("int") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("int") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocType", "BranchId", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Property("PayrollLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollLineId")); + + b.Property("AbsentDays") + .HasColumnType("int"); + + b.Property("BasicSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EpfEmployeeAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EpfEmployerAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EtfEmployerAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("GrossSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("LateDeductionAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("LateMinutesTotal") + .HasColumnType("int"); + + b.Property("LeaveDays") + .HasColumnType("int"); + + b.Property("LoanDeductionAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("NetSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("NoPayAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("OtMinutesTotal") + .HasColumnType("int"); + + b.Property("OtherDeductionsAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("OvertimeAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("int"); + + b.Property("PresentDays") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TotalAllowances") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("WorkingDays") + .HasColumnType("int"); + + b.HasKey("PayrollLineId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("PayrollRunId", "EmployeeId") + .IsUnique(); + + b.ToTable("hr_payroll_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.Property("PayrollLineComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollLineComponentId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ComponentCategory") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PayrollLineId") + .HasColumnType("int"); + + b.Property("SalaryComponentId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("PayrollLineComponentId"); + + b.HasIndex("PayrollLineId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_payroll_line_components", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Property("PayrollRunId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollRunId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GeneratedAt") + .HasColumnType("datetime(6)"); + + b.Property("GeneratedBy") + .HasColumnType("int"); + + b.Property("LockedAt") + .HasColumnType("datetime(6)"); + + b.Property("LockedBy") + .HasColumnType("int"); + + b.Property("PeriodMonth") + .HasColumnType("int"); + + b.Property("PeriodYear") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UnlockReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("UnlockedAt") + .HasColumnType("datetime(6)"); + + b.Property("UnlockedBy") + .HasColumnType("int"); + + b.HasKey("PayrollRunId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("PeriodYear", "PeriodMonth", "BranchId"); + + b.ToTable("hr_payroll_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollStatutorySetting", b => + { + b.Property("PayrollStatutorySettingId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollStatutorySettingId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("EpfEmployeeRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("EpfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("EtfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("OtMultiplierDefault") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.HasKey("PayrollStatutorySettingId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_payroll_statutory_settings", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.Property("PayslipId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayslipId")); + + b.Property("GeneratedAt") + .HasColumnType("datetime(6)"); + + b.Property("PayrollLineId") + .HasColumnType("int"); + + b.Property("ReleasedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReleasedBy") + .HasColumnType("int"); + + b.HasKey("PayslipId"); + + b.HasIndex("PayrollLineId") + .IsUnique(); + + b.ToTable("hr_payslips", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.Property("PermissionId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PermissionId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("NavItemId") + .HasColumnType("int"); + + b.Property("SubNavItemId") + .HasColumnType("int"); + + b.HasKey("PermissionId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.HasIndex("SubNavItemId"); + + b.ToTable("permissions", (string)null); + + b.HasData( + new + { + PermissionId = 1, + Code = "NAV:dashboard", + NavItemId = 1 + }, + new + { + PermissionId = 2, + Code = "NAV:products", + NavItemId = 2 + }, + new + { + PermissionId = 3, + Code = "NAV:procurement.vendors", + SubNavItemId = 30 + }, + new + { + PermissionId = 4, + Code = "NAV:procurement", + NavItemId = 4 + }, + new + { + PermissionId = 5, + Code = "NAV:receiving", + NavItemId = 5 + }, + new + { + PermissionId = 6, + Code = "NAV:stock", + NavItemId = 6 + }, + new + { + PermissionId = 7, + Code = "NAV:warehouses", + NavItemId = 7 + }, + new + { + PermissionId = 8, + Code = "NAV:orders", + NavItemId = 8 + }, + new + { + PermissionId = 9, + Code = "NAV:settings", + NavItemId = 9 + }, + new + { + PermissionId = 10, + Code = "NAV:help", + NavItemId = 10 + }, + new + { + PermissionId = 11, + Code = "NAV:products.item", + SubNavItemId = 1 + }, + new + { + PermissionId = 12, + Code = "NAV:products.category", + SubNavItemId = 2 + }, + new + { + PermissionId = 13, + Code = "NAV:products.brand", + SubNavItemId = 3 + }, + new + { + PermissionId = 14, + Code = "NAV:products.item-type", + SubNavItemId = 4 + }, + new + { + PermissionId = 15, + Code = "NAV:products.uom", + SubNavItemId = 5 + }, + new + { + PermissionId = 16, + Code = "NAV:products.configuration", + SubNavItemId = 6 + }, + new + { + PermissionId = 17, + Code = "NAV:settings.roles", + SubNavItemId = 7 + }, + new + { + PermissionId = 18, + Code = "NAV:settings.users", + SubNavItemId = 8 + }, + new + { + PermissionId = 28, + Code = "NAV:procurement.requisitions", + SubNavItemId = 17 + }, + new + { + PermissionId = 29, + Code = "NAV:procurement.rfqs", + SubNavItemId = 18 + }, + new + { + PermissionId = 30, + Code = "NAV:procurement.purchase-orders", + SubNavItemId = 19 + }, + new + { + PermissionId = 31, + Code = "NAV:procurement.purchase-returns", + SubNavItemId = 20 + }, + new + { + PermissionId = 19, + Code = "NAV:ledgers", + NavItemId = 11 + }, + new + { + PermissionId = 20, + Code = "NAV:ledgers.trial-balance", + SubNavItemId = 9 + }, + new + { + PermissionId = 21, + Code = "NAV:ledgers.balance-sheet", + SubNavItemId = 10 + }, + new + { + PermissionId = 22, + Code = "NAV:ledgers.general-ledger", + SubNavItemId = 11 + }, + new + { + PermissionId = 23, + Code = "NAV:ledgers.profit-and-loss", + SubNavItemId = 12 + }, + new + { + PermissionId = 24, + Code = "NAV:ledgers.cash-flow", + SubNavItemId = 13 + }, + new + { + PermissionId = 25, + Code = "NAV:ledgers.budget-vs-actual", + SubNavItemId = 14 + }, + new + { + PermissionId = 27, + Code = "NAV:ledgers.tax-report", + SubNavItemId = 16 + }, + new + { + PermissionId = 26, + Code = "NAV:accounts.bank-accounts", + SubNavItemId = 15 + }, + new + { + PermissionId = 32, + Code = "NAV:accounts", + NavItemId = 12 + }, + new + { + PermissionId = 33, + Code = "NAV:accounts.cheque-books", + SubNavItemId = 21 + }, + new + { + PermissionId = 34, + Code = "NAV:accounts.received-cheques", + SubNavItemId = 22 + }, + new + { + PermissionId = 35, + Code = "NAV:production", + NavItemId = 14 + }, + new + { + PermissionId = 36, + Code = "NAV:production.templates", + SubNavItemId = 27 + }, + new + { + PermissionId = 37, + Code = "NAV:production.runs", + SubNavItemId = 25 + }, + new + { + PermissionId = 38, + Code = "NAV:production.produced", + SubNavItemId = 26 + }, + new + { + PermissionId = 39, + Code = "NAV:procurement.purchase-invoices", + SubNavItemId = 28 + }, + new + { + PermissionId = 40, + Code = "NAV:settings.branches", + SubNavItemId = 29 + }, + new + { + PermissionId = 41, + Code = "NAV:stock.ledger", + SubNavItemId = 31 + }, + new + { + PermissionId = 42, + Code = "NAV:stock.enquiry", + SubNavItemId = 32 + }, + new + { + PermissionId = 43, + Code = "NAV:stock.valuation", + SubNavItemId = 33 + }, + new + { + PermissionId = 44, + Code = "NAV:stock.transfers", + SubNavItemId = 34 + }, + new + { + PermissionId = 45, + Code = "NAV:stock.adjustments", + SubNavItemId = 35 + }, + new + { + PermissionId = 46, + Code = "NAV:stock.counts", + SubNavItemId = 36 + }, + new + { + PermissionId = 47, + Code = "NAV:stock.reorder-alerts", + SubNavItemId = 37 + }, + new + { + PermissionId = 48, + Code = "NAV:stock.wastage", + SubNavItemId = 38 + }, + new + { + PermissionId = 49, + Code = "NAV:sales.bundle-templates", + SubNavItemId = 23 + }, + new + { + PermissionId = 50, + Code = "NAV:sales.invoices", + SubNavItemId = 40 + }, + new + { + PermissionId = 51, + Code = "NAV:sales.free-issues", + SubNavItemId = 41 + }, + new + { + PermissionId = 52, + Code = "NAV:sales.day-end", + SubNavItemId = 42 + }, + new + { + PermissionId = 53, + Code = "NAV:products.tax-rates", + SubNavItemId = 43 + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PoId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("BatchListsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CustomVariantsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("CustomWarrantyPeriodsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ItemImagesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SellingPricesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("SerialNumbersEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("WarrantyPeriodsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("product_config", null, t => + { + t.HasCheckConstraint("ck_product_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.Property("RunId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunId")); + + b.Property("CancelReasonCodeId") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OutputBinId") + .HasColumnType("int"); + + b.Property("ReworkCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("ScaleFactor") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TargetQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("RunId"); + + b.HasIndex("CancelReasonCodeId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("OutputBinId"); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("TemplateId", "Status"); + + b.ToTable("production_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.Property("TemplateId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TemplateId")); + + b.Property("Annotations") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("TemplateId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.ToTable("production_templates", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.Property("PurchaseInvoiceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoiceId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidAmount") + .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("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VatTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorInvoiceDate") + .HasColumnType("datetime(6)"); + + b.Property("VendorInvoiceNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("VendorSnapshotName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("VendorSnapshotTaxNo") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("PurchaseInvoiceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("VendorId", "VendorInvoiceNo"); + + b.ToTable("purchase_invoices", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b => + { + b.Property("PurchaseInvoiceLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoiceLineId")); + + b.Property("GrnId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VatTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("PurchaseInvoiceLineId"); + + b.HasIndex("GrnId") + .IsUnique(); + + b.HasIndex("PurchaseInvoiceId"); + + b.ToTable("purchase_invoice_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b => + { + b.Property("PurchaseInvoicePaymentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoicePaymentId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BankAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("GlBankAccountId") + .HasColumnType("bigint"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PaymentDate") + .HasColumnType("datetime(6)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("PurchaseInvoicePaymentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("PurchaseInvoiceId"); + + b.ToTable("purchase_invoice_payments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("tinyint(1)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("ApproverUserId") + .HasColumnType("int"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("PoId"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnId") + .HasColumnType("int"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RequisitionId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequestedBy") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RfqId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RfqId") + .HasColumnType("int"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => + { + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RoleId")); + + b.Property("AuthRoleId") + .HasColumnType("char(36)") + .HasColumnName("auth_role_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsSystemRole") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + 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("RoleId"); + + b.HasIndex("AuthRoleId") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("PermissionId") + .HasColumnType("int"); + + b.Property("Actions") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(31); + + b.HasKey("RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => + { + b.Property("RunEdgeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunEdgeId")); + + b.Property("ChildRunStageId") + .HasColumnType("int"); + + b.Property("ParentRunStageId") + .HasColumnType("int"); + + b.Property("RunId") + .HasColumnType("int"); + + b.HasKey("RunEdgeId"); + + b.HasIndex("ChildRunStageId"); + + b.HasIndex("RunId"); + + b.HasIndex("ParentRunStageId", "ChildRunStageId") + .IsUnique(); + + b.ToTable("run_edges", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.Property("RunStageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunStageId")); + + b.Property("ActualEndAt") + .HasColumnType("datetime(6)"); + + b.Property("ActualStartAt") + .HasColumnType("datetime(6)"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("FieldDefs") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FieldValues") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PosX") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PosY") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoleLabel") + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("RunId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TemplateStageId") + .HasColumnType("int"); + + b.HasKey("RunStageId"); + + b.HasIndex("TemplateStageId"); + + b.HasIndex("RunId", "Status"); + + b.ToTable("run_stages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EventId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Payload") + .HasColumnType("longtext"); + + b.Property("RunId") + .HasColumnType("int"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("EventId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("UserId"); + + b.HasIndex("RunId", "EventId"); + + b.ToTable("run_stage_events", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => + { + b.Property("RunInputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunInputId")); + + b.Property("ConsumedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ConsumedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DeliveredQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("FromRunOutputId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PlannedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ReturnedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RunInputId"); + + b.HasIndex("FromRunOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RunStageId"); + + b.ToTable("run_stage_inputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => + { + b.Property("RunOutputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunOutputId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PlannedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ProducedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("ScrapReasonCodeId") + .HasColumnType("int"); + + b.Property("ScrappedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TransferredQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UomId") + .HasColumnType("int"); + + b.HasKey("RunOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("ScrapReasonCodeId"); + + b.HasIndex("UomId"); + + b.ToTable("run_stage_outputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalaryComponent", b => + { + b.Property("SalaryComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalaryComponentId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEpfEtfApplicable") + .HasColumnType("tinyint(1)"); + + b.Property("IsTaxable") + .HasColumnType("tinyint(1)"); + + 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("SalaryComponentId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_salary_components", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("AllowCashCustomer") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("AllowDiscounts") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowFreeItems") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowOverStockSale") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("AllowWarehouseSelection") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CashCustomerId") + .HasColumnType("int"); + + b.Property("DefaultWarehouseId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("ConfigId"); + + b.HasIndex("CashCustomerId"); + + b.HasIndex("DefaultWarehouseId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("sales_config", null, t => + { + t.HasCheckConstraint("ck_sales_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.Property("SalesDayEndId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesDayEndId")); + + b.Property("BalanceTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BundleCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("BusinessDate") + .HasColumnType("date"); + + b.Property("CashierUserId") + .HasColumnType("int"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("ClosedBy") + .HasColumnType("int"); + + b.Property("CostOfGoodsSold") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SlipCount") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("SalesDayEndId"); + + b.HasIndex("ClosedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("CashierUserId", "BusinessDate") + .IsUnique(); + + b.ToTable("sales_day_ends", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.Property("SalesInvoiceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("CreatorUserId") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("CustomerSnapshotName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CustomerSnapshotTaxNo") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("InvoiceDate") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("B2C"); + + b.Property("NetPayable") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoundOff") + .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("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatorUserId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("InvoiceDate"); + + b.HasIndex("InvoiceNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_invoices", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.Property("SalesInvoiceLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceLineId")); + + b.Property("BaseCost") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("DiscountAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountMode") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("FreeQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IsFreeIssue") + .HasColumnType("tinyint(1)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ParentLineId") + .HasColumnType("int"); + + b.Property("PriceSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SalesInvoiceId") + .HasColumnType("int"); + + b.Property("TaxAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SalesInvoiceId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_invoice_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => + { + b.Property("SalesInvoicePaymentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoicePaymentId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BankAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("GlBankAccountId") + .HasColumnType("bigint"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PaymentDate") + .HasColumnType("datetime(6)"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SalesInvoiceId") + .HasColumnType("int"); + + b.HasKey("SalesInvoicePaymentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("SalesInvoiceId"); + + b.ToTable("sales_invoice_payments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnId") + .HasColumnType("int"); + + b.Property("SalesInvoiceLineId") + .HasColumnType("int"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.HasIndex("SalesInvoiceLineId"); + + b.ToTable("sales_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.Property("SalesSlipId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CashierUserId") + .HasColumnType("int"); + + 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("PaidAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SlipDate") + .HasColumnType("datetime(6)"); + + b.Property("SlipNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Draft"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesSlipId"); + + b.HasIndex("CashierUserId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DayEndId"); + + b.HasIndex("SlipDate"); + + b.HasIndex("SlipNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_slips", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.Property("SalesSlipLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipLineId")); + + b.Property("BaseCost") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("DiscountAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountMode") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("FreeQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IsFreeIssue") + .HasColumnType("tinyint(1)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ParentLineId") + .HasColumnType("int"); + + b.Property("PriceSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SalesSlipId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("TaxAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesSlipLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SalesSlipId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_slip_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SerialId")); + + b.Property("Barcode") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("Barcode") + .IsUnique(); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => + { + b.Property("EdgeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EdgeId")); + + b.Property("ChildStageId") + .HasColumnType("int"); + + b.Property("ParentStageId") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("EdgeId"); + + b.HasIndex("ChildStageId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("ParentStageId", "ChildStageId") + .IsUnique(); + + b.ToTable("stage_edges", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => + { + b.Property("InputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("InputId")); + + b.Property("FromOutputId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyPerBatch") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("StageId") + .HasColumnType("int"); + + b.HasKey("InputId"); + + b.HasIndex("FromOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("StageId"); + + b.ToTable("stage_inputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => + { + b.Property("OutputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("OutputId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("QtyPerBatch") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("StageId") + .HasColumnType("int"); + + b.Property("UomId") + .HasColumnType("int"); + + b.HasKey("OutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("StageId"); + + b.HasIndex("UomId"); + + b.ToTable("stage_outputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AdjustmentId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("int"); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CountId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("CountId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CountLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CountId") + .HasColumnType("int"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime(6)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TransferId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DestWarehouseId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SrcWarehouseId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("DestBinId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SrcBinId") + .HasColumnType("int"); + + b.Property("TransferId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("int"); + + 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("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.Property("SubNavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SubNavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NavItemId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.HasKey("SubNavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.ToTable("sub_nav_items", (string)null); + + b.HasData( + new + { + SubNavItemId = 1, + Code = "products.item", + Href = "/dashboard/products", + Label = "Item", + NavItemId = 2, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 2, + Code = "products.category", + Href = "/dashboard/products/categories", + Label = "Category", + NavItemId = 2, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 3, + Code = "products.brand", + Href = "/dashboard/products/brands", + Label = "Brand", + NavItemId = 2, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 4, + Code = "products.item-type", + Href = "/dashboard/products/item-types", + Label = "Item Type", + NavItemId = 2, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 5, + Code = "products.uom", + Href = "/dashboard/products/uoms", + Label = "UOM", + NavItemId = 2, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 6, + Code = "products.configuration", + Href = "/dashboard/products/settings", + Label = "Configuration", + NavItemId = 2, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 43, + Code = "products.tax-rates", + Href = "/dashboard/products/tax-rates", + Label = "Tax Rates", + NavItemId = 2, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 29, + Code = "settings.branches", + Href = "/dashboard/settings/branches", + Label = "Branches", + NavItemId = 9, + SortOrder = 0, + Status = "Active" + }, + new + { + SubNavItemId = 7, + Code = "settings.roles", + Href = "/dashboard/settings/roles", + Label = "Roles", + NavItemId = 9, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 8, + Code = "settings.users", + Href = "/dashboard/settings/users", + Label = "Users", + NavItemId = 9, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 24, + Code = "settings.notification-config", + Href = "/dashboard/settings/notification-config", + Label = "Notification Config", + NavItemId = 9, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 40, + Code = "sales.invoices", + Href = "/dashboard/sales/invoices", + Label = "Sales", + NavItemId = 13, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 23, + Code = "sales.bundle-templates", + Href = "/dashboard/sales/bundles/templates", + Label = "Bundle Templates", + NavItemId = 13, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 41, + Code = "sales.free-issues", + Href = "/dashboard/sales/free-issues", + Label = "Free Issues", + NavItemId = 13, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 42, + Code = "sales.day-end", + Href = "/dashboard/sales/day-end", + Label = "Day End", + NavItemId = 13, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 30, + Code = "procurement.vendors", + Href = "/dashboard/vendors", + Label = "Vendors", + NavItemId = 4, + SortOrder = 0, + Status = "Active" + }, + new + { + SubNavItemId = 17, + Code = "procurement.requisitions", + Href = "/dashboard/procurement/requisitions", + Label = "Requisitions", + NavItemId = 4, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 18, + Code = "procurement.rfqs", + Href = "/dashboard/procurement/rfqs", + Label = "RFQs", + NavItemId = 4, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 19, + Code = "procurement.purchase-orders", + Href = "/dashboard/procurement/purchase-orders", + Label = "Purchase Orders", + NavItemId = 4, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 20, + Code = "procurement.purchase-returns", + Href = "/dashboard/procurement/purchase-returns", + Label = "Purchase Returns", + NavItemId = 4, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 28, + Code = "procurement.purchase-invoices", + Href = "/dashboard/procurement/purchase-invoices", + Label = "Purchase Invoices", + NavItemId = 4, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 9, + Code = "ledgers.trial-balance", + Href = "/dashboard/ledgers/trial-balance", + Label = "Trial Balance", + NavItemId = 11, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 10, + Code = "ledgers.balance-sheet", + Href = "/dashboard/ledgers/balance-sheet", + Label = "Balance Sheet", + NavItemId = 11, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 11, + Code = "ledgers.general-ledger", + Href = "/dashboard/ledgers/general-ledger", + Label = "General Ledger", + NavItemId = 11, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 12, + Code = "ledgers.profit-and-loss", + Href = "/dashboard/ledgers/profit-and-loss", + Label = "Profit & Loss", + NavItemId = 11, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 13, + Code = "ledgers.cash-flow", + Href = "/dashboard/ledgers/cash-flow", + Label = "Cash Flow", + NavItemId = 11, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 14, + Code = "ledgers.budget-vs-actual", + Href = "/dashboard/ledgers/budget-vs-actual", + Label = "Budget vs Actual", + NavItemId = 11, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 16, + Code = "ledgers.tax-report", + Href = "/dashboard/ledgers/tax-report", + Label = "Tax Report", + NavItemId = 11, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 15, + Code = "accounts.bank-accounts", + Href = "/dashboard/accounts/bank-accounts", + Label = "Cash / Bank Accounts", + NavItemId = 12, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 21, + Code = "accounts.cheque-books", + Href = "/dashboard/accounts/cheque-books", + Label = "Cheque Books", + NavItemId = 12, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 22, + Code = "accounts.received-cheques", + Href = "/dashboard/accounts/received-cheques", + Label = "Received Cheques", + NavItemId = 12, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 27, + Code = "production.templates", + Href = "/dashboard/production/templates", + Label = "Templates", + NavItemId = 14, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 25, + Code = "production.runs", + Href = "/dashboard/production/runs", + Label = "Runs", + NavItemId = 14, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 26, + Code = "production.produced", + Href = "/dashboard/production/produced", + Label = "Produced", + NavItemId = 14, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 31, + Code = "stock.ledger", + Href = "/dashboard/stock/ledger", + Label = "Stock Ledger", + NavItemId = 6, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 32, + Code = "stock.enquiry", + Href = "/dashboard/stock/enquiry", + Label = "Stock Enquiry", + NavItemId = 6, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 33, + Code = "stock.valuation", + Href = "/dashboard/stock/valuation", + Label = "Valuation", + NavItemId = 6, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 34, + Code = "stock.transfers", + Href = "/dashboard/stock/transfers", + Label = "Transfers", + NavItemId = 6, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 35, + Code = "stock.adjustments", + Href = "/dashboard/stock/adjustments", + Label = "Adjustments", + NavItemId = 6, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 36, + Code = "stock.counts", + Href = "/dashboard/stock/counts", + Label = "Counts", + NavItemId = 6, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 37, + Code = "stock.reorder-alerts", + Href = "/dashboard/stock/reorder-alerts", + Label = "Reorder Alerts", + NavItemId = 6, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 38, + Code = "stock.wastage", + Href = "/dashboard/stock/wastage", + Label = "Wastage", + NavItemId = 6, + SortOrder = 8, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => + { + b.Property("TicketId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TicketId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("TicketId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.ToTable("support_tickets", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicketMessage", b => + { + b.Property("MessageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("MessageId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsFromAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("SenderUserId") + .HasColumnType("int"); + + b.Property("TicketId") + .HasColumnType("int"); + + b.HasKey("MessageId"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("support_ticket_messages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TaxRate", b => + { + b.Property("TaxRateId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TaxRateId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Percentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + 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("TaxRateId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("tax_rates", (string)null); + + b.HasData( + new + { + TaxRateId = 1, + Code = "STANDARD", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Standard", + Percentage = 15m, + RowVersion = 1u, + Status = "Active" + }, + new + { + TaxRateId = 2, + Code = "ZERO", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Zero-Rated", + Percentage = 0m, + RowVersion = 1u, + Status = "Active" + }, + new + { + TaxRateId = 3, + Code = "EXEMPT", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Exempt", + Percentage = 0m, + RowVersion = 1u, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TaxSlab", b => + { + b.Property("TaxSlabId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TaxSlabId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("LowerBound") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Rate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpperBound") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("TaxSlabId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_tax_slabs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.Property("StageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StageId")); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("FieldDefs") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PosX") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PosY") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoleLabel") + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("StageId"); + + b.HasIndex("TemplateId"); + + b.ToTable("template_stages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UomId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("char(36)") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("varchar(320)"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("VendorId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("varchar(3)") + .HasDefaultValue("LKR"); + + 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("TaxReg") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("VendorId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("RfqId") + .HasColumnType("int"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LeadDays") + .HasColumnType("int"); + + b.Property("QuotationId") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarehouseId")); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Phone") + .HasColumnType("longtext"); + + b.HasKey("WarehouseId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b => + { + b.Property("WarrantyPeriodId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarrantyPeriodId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Months") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("WarrantyPeriodId"); + + b.HasIndex("Months") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("warranty_periods", null, t => + { + t.HasCheckConstraint("ck_warranty_periods_months", "`Months` BETWEEN 1 AND 600"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b => + { + b.Property("WorkShiftId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WorkShiftId")); + + b.Property("BreakMinutes") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndTime") + .HasColumnType("time(6)"); + + b.Property("GraceMinutes") + .HasColumnType("int"); + + b.Property("IsOvernight") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OtMultiplier") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StandardWorkingMinutes") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("time(6)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WorkingDaysMask") + .HasColumnType("int"); + + b.HasKey("WorkShiftId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_work_shifts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => + { + b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch") + .WithMany() + .HasForeignKey("AttendanceUploadBatchId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceUploadBatch"); + + b.Navigation("Employee"); + + b.Navigation("WorkShift"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate") + .WithMany() + .HasForeignKey("BundleSaleTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd") + .WithMany("BundleSales") + .HasForeignKey("DayEndId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSaleTemplate"); + + b.Navigation("CashierUser"); + + b.Navigation("Customer"); + + b.Navigation("DayEnd"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale") + .WithMany("Lines") + .HasForeignKey("BundleSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSale"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate") + .WithMany("Lines") + .HasForeignKey("BundleSaleTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSaleTemplate"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("DefaultWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Employee", "HeadEmployee") + .WithMany() + .HasForeignKey("HeadEmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Department", "ParentDepartment") + .WithMany() + .HasForeignKey("ParentDepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + + b.Navigation("HeadEmployee"); + + b.Navigation("ParentDepartment"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Designation", "Designation") + .WithMany() + .HasForeignKey("DesignationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.EmploymentType", "EmploymentType") + .WithMany() + .HasForeignKey("EmploymentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Employee", "ReportingManager") + .WithMany() + .HasForeignKey("ReportingManagerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Employee", "UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Department"); + + b.Navigation("Designation"); + + b.Navigation("EmploymentType"); + + b.Navigation("ReportingManager"); + + b.Navigation("User"); + + b.Navigation("WorkShift"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.HrDocumentType", "HrDocumentType") + .WithMany() + .HasForeignKey("HrDocumentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("HrDocumentType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeSalaryStructure", "EmployeeSalaryStructure") + .WithMany("Lines") + .HasForeignKey("EmployeeSalaryStructureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EmployeeSalaryStructure"); + + b.Navigation("SalaryComponent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice") + .WithMany() + .HasForeignKey("PurchaseInvoiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("PurchaseInvoice"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DefaultWarehouse"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany("WarrantyNumbers") + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .HasForeignKey("DefaultVendorId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TaxRate", "TaxRate") + .WithMany() + .HasForeignKey("TaxRateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); + + b.Navigation("TaxRate"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeLoan", "EmployeeLoan") + .WithMany("Installments") + .HasForeignKey("EmployeeLoanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany() + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EmployeeLoan"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b => + { + b.HasOne("ERPCore.Domain.Entities.NotificationConfig", "NotificationConfig") + .WithMany("Modules") + .HasForeignKey("NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationRecipient", b => + { + b.HasOne("ERPCore.Domain.Entities.Notification", "Notification") + .WithMany("Recipients") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany("Lines") + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithMany("Components") + .HasForeignKey("PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("PayrollLine"); + + b.Navigation("SalaryComponent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Payslip", "PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PayrollLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany() + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem") + .WithMany() + .HasForeignKey("SubNavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("NavItem"); + + b.Navigation("SubNavItem"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "CancelReason") + .WithMany() + .HasForeignKey("CancelReasonCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Bin", "OutputBin") + .WithMany() + .HasForeignKey("OutputBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Runs") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CancelReason"); + + b.Navigation("Creator"); + + b.Navigation("OutputBin"); + + b.Navigation("Template"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany() + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice") + .WithMany("Lines") + .HasForeignKey("PurchaseInvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grn"); + + b.Navigation("PurchaseInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice") + .WithMany("Payments") + .HasForeignKey("PurchaseInvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Approver") + .WithMany() + .HasForeignKey("ApproverUserId"); + + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Approver"); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.HasOne("ERPCore.Domain.Entities.Permission", "Permission") + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => + { + b.HasOne("ERPCore.Domain.Entities.RunStage", "ChildRunStage") + .WithMany() + .HasForeignKey("ChildRunStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "ParentRunStage") + .WithMany() + .HasForeignKey("ParentRunStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Edges") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildRunStage"); + + b.Navigation("ParentRunStage"); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Stages") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "TemplateStage") + .WithMany() + .HasForeignKey("TemplateStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Run"); + + b.Navigation("TemplateStage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Events") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Events") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("RunStage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => + { + b.HasOne("ERPCore.Domain.Entities.RunStageOutput", "FromRunOutput") + .WithMany() + .HasForeignKey("FromRunOutputId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Inputs") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromRunOutput"); + + b.Navigation("Item"); + + b.Navigation("RunStage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Outputs") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ScrapReason") + .WithMany() + .HasForeignKey("ScrapReasonCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Item"); + + b.Navigation("RunStage"); + + b.Navigation("ScrapReason"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer") + .WithMany() + .HasForeignKey("CashCustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CashCustomer"); + + b.Navigation("DefaultWarehouse"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "ClosedByUser") + .WithMany() + .HasForeignKey("ClosedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CashierUser"); + + b.Navigation("ClosedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatorUserId"); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Customer"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice") + .WithMany("Lines") + .HasForeignKey("SalesInvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("SalesInvoice"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice") + .WithMany("Payments") + .HasForeignKey("SalesInvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("SalesInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Customer"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine") + .WithMany() + .HasForeignKey("SalesInvoiceLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Item"); + + b.Navigation("Return"); + + b.Navigation("SalesInvoiceLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd") + .WithMany("Slips") + .HasForeignKey("DayEndId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CashierUser"); + + b.Navigation("Customer"); + + b.Navigation("DayEnd"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesSlip", "SalesSlip") + .WithMany("Lines") + .HasForeignKey("SalesSlipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Item"); + + b.Navigation("SalesSlip"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => + { + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ChildStage") + .WithMany() + .HasForeignKey("ChildStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ParentStage") + .WithMany() + .HasForeignKey("ParentStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Edges") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildStage"); + + b.Navigation("ParentStage"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => + { + b.HasOne("ERPCore.Domain.Entities.StageOutput", "FromOutput") + .WithMany() + .HasForeignKey("FromOutputId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") + .WithMany("Inputs") + .HasForeignKey("StageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromOutput"); + + b.Navigation("Item"); + + b.Navigation("Stage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") + .WithMany("Outputs") + .HasForeignKey("StageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Item"); + + b.Navigation("Stage"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Count"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany("Children") + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NavItem"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicketMessage", b => + { + b.HasOne("ERPCore.Domain.Entities.SupportTicket", "Ticket") + .WithMany("Messages") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Stages") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Navigation("Installments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Navigation("WarrantyNumbers"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Notification", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.Navigation("Edges"); + + b.Navigation("Events"); + + b.Navigation("Stages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.Navigation("Edges"); + + b.Navigation("Runs"); + + b.Navigation("Stages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.Navigation("Lines"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.Navigation("Events"); + + b.Navigation("Inputs"); + + b.Navigation("Outputs"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.Navigation("BundleSales"); + + b.Navigation("Slips"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.Navigation("Lines"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.Navigation("Inputs"); + + b.Navigation("Outputs"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Migrations/20261007072921_AddGlAccountConfig.cs b/Backend/ERPCore/Migrations/20261007072921_AddGlAccountConfig.cs new file mode 100644 index 0000000..b417f17 --- /dev/null +++ b/Backend/ERPCore/Migrations/20261007072921_AddGlAccountConfig.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ERPCore.Migrations +{ + /// + public partial class AddGlAccountConfig : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "gl_account_config", + columns: table => new + { + ConfigId = table.Column(type: "int", nullable: false), + InventoryAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + VatRecoverableAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + AccountsReceivableCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + ClearingAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + TaxPayableAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + AccountsPayableCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + SalesRevenueAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + SalesDiscountAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + InventoryGainAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + CogsAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + InventoryLossAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + CashAccountCode = table.Column(type: "varchar(20)", maxLength: 20, nullable: true, collation: "utf8mb4_unicode_ci"), + UpdatedAt = table.Column(type: "datetime(6)", nullable: true), + UpdatedBy = table.Column(type: "int", nullable: true), + RowVersion = table.Column(type: "int unsigned", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_gl_account_config", x => x.ConfigId); + table.CheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1"); + table.ForeignKey( + name: "FK_gl_account_config_users_UpdatedBy", + column: x => x.UpdatedBy, + principalTable: "users", + principalColumn: "UserId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateIndex( + name: "IX_gl_account_config_UpdatedBy", + table: "gl_account_config", + column: "UpdatedBy"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "gl_account_config"); + } + } +} diff --git a/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs index dc90d0e..59f4860 100644 --- a/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs @@ -1421,6 +1421,79 @@ namespace ERPCore.Migrations b.ToTable("hr_employment_types", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("AccountsPayableCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("AccountsReceivableCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CashAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ClearingAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CogsAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("InventoryAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("InventoryGainAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("InventoryLossAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SalesDiscountAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SalesRevenueAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TaxPayableAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("VatRecoverableAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("gl_account_config", null, t => + { + t.HasCheckConstraint("ck_gl_account_config_singleton", "`ConfigId` = 1"); + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.Property("GrnId") @@ -7117,6 +7190,16 @@ namespace ERPCore.Migrations b.Navigation("SalaryComponent"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 388c542..5ab668c 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -119,6 +119,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -241,7 +242,7 @@ using (var scope = app.Services.CreateScope()) await db.Database.MigrateAsync(); try { - await DataSeeder.SeedAsync(db); + await DataSeeder.SeedAsync(db, builder.Configuration); } catch (Exception ex) { diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs index 6746d9e..0bda2f7 100644 --- a/Backend/ERPCore/Services/AdjustmentService.cs +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -37,15 +37,13 @@ public sealed class AdjustmentService : IAdjustmentService private readonly INotificationService _notifications; private readonly IUnitOfWork _uow; private readonly IGeneralLedgerService _gl; - private readonly string _glInventoryAccountCode; - private readonly string _glGainAccountCode; - private readonly string _glLossAccountCode; + private readonly IGlAccountConfigService _glAccounts; public AdjustmentService( IRepository adjustments, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository ledger, IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow, - IGeneralLedgerService gl, IConfiguration configuration) + IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { _adjustments = adjustments; _warehouses = warehouses; @@ -59,9 +57,7 @@ public sealed class AdjustmentService : IAdjustmentService _notifications = notifications; _uow = uow; _gl = gl; - _glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty; - _glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty; - _glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty; + _glAccounts = glAccounts; } /// Builds and posts the GL journal for a 's stock movement. @@ -74,17 +70,18 @@ public sealed class AdjustmentService : IAdjustmentService var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value); if (gain <= 0 && loss <= 0) return null; + var glCodes = await _glAccounts.GetCodesAsync(ct); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct); var lines = new List(); if (gain > 0) { - lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase")); - lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain")); } if (loss > 0) { - lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss")); - lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease")); } var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index 1bb688d..0f68255 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -41,16 +41,14 @@ public sealed class CountService : ICountService private readonly IRepository _stockLayers; private readonly IRepository _batches; private readonly IRepository _serials; - private readonly string _glInventoryAccountCode; - private readonly string _glGainAccountCode; - private readonly string _glLossAccountCode; + private readonly IGlAccountConfigService _glAccounts; public CountService( IRepository counts, IRepository adjustments, IRepository warehouses, IRepository items, IRepository reasonCodes, IFifoCostingService fifo, IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow, IGeneralLedgerService gl, IRepository stockLayers, IRepository batches, - IRepository serials, IConfiguration configuration) + IRepository serials, IGlAccountConfigService glAccounts) { _counts = counts; _adjustments = adjustments; @@ -67,9 +65,7 @@ public sealed class CountService : ICountService _stockLayers = stockLayers; _batches = batches; _serials = serials; - _glInventoryAccountCode = configuration["Adjustment:GlInventoryAccountCode"] ?? string.Empty; - _glGainAccountCode = configuration["Adjustment:GlGainAccountCode"] ?? string.Empty; - _glLossAccountCode = configuration["Adjustment:GlLossAccountCode"] ?? string.Empty; + _glAccounts = glAccounts; } /// Same logic as AdjustmentService's private method of the same name — see there for why @@ -81,17 +77,18 @@ public sealed class CountService : ICountService var loss = refs.Where(r => r.Direction == Direction.Out).Sum(r => r.Value); if (gain <= 0 && loss <= 0) return null; + var glCodes = await _glAccounts.GetCodesAsync(ct); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct); var lines = new List(); if (gain > 0) { - lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, gain, 0m, $"Adjustment {docNo} — stock increase")); - lines.Add(new GlJournalEntryLineRequest(_glGainAccountCode, 0m, gain, $"Adjustment {docNo} — inventory gain")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], gain, 0m, $"Adjustment {docNo} — stock increase")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryGain], 0m, gain, $"Adjustment {docNo} — inventory gain")); } if (loss > 0) { - lines.Add(new GlJournalEntryLineRequest(_glLossAccountCode, loss, 0m, $"Adjustment {docNo} — inventory loss")); - lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, loss, $"Adjustment {docNo} — stock decrease")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.InventoryLoss], loss, 0m, $"Adjustment {docNo} — inventory loss")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, loss, $"Adjustment {docNo} — stock decrease")); } var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest diff --git a/Backend/ERPCore/Services/GeneralLedgerService.cs b/Backend/ERPCore/Services/GeneralLedgerService.cs index 4969b36..3e1be51 100644 --- a/Backend/ERPCore/Services/GeneralLedgerService.cs +++ b/Backend/ERPCore/Services/GeneralLedgerService.cs @@ -49,7 +49,7 @@ public sealed class GeneralLedgerService : IGeneralLedgerService a.CashAccountTypeName, a.AccountNumber, a.GlAccountId, a.GlAccountCode, a.CurrencyCode)).ToList(); } - public async Task> ListAccountCodesAsync(CancellationToken ct) + public async Task> ListAccountsAsync(CancellationToken ct) { // No page/pageSize: GL pages this endpoint only when both are supplied, so this is the // full, flat chart of accounts (child codes like "1000-01" are rows of their own — @@ -59,8 +59,8 @@ public sealed class GeneralLedgerService : IGeneralLedgerService // `"items": null` deserializes over the property initialiser, so the null check is load-bearing. return (data.Items ?? []) .Where(a => a is not null && !string.IsNullOrWhiteSpace(a.AccountCode)) - .Select(a => a.AccountCode) - .ToHashSet(StringComparer.Ordinal); + .Select(a => new GlAccount(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive)) + .ToList(); } /// @@ -118,6 +118,10 @@ public sealed class GeneralLedgerService : IGeneralLedgerService private sealed class GlAccountData { public string AccountCode { get; set; } = string.Empty; + public string AccountName { get; set; } = string.Empty; + public short AccountTypeId { get; set; } + public bool IsPostable { get; set; } + public bool IsActive { get; set; } } private sealed class GlBankAccountData diff --git a/Backend/ERPCore/Services/GlAccountConfigService.cs b/Backend/ERPCore/Services/GlAccountConfigService.cs new file mode 100644 index 0000000..dce9b4c --- /dev/null +++ b/Backend/ERPCore/Services/GlAccountConfigService.cs @@ -0,0 +1,195 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Config; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Gl; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +public sealed class GlAccountConfigService : IGlAccountConfigService +{ + private readonly IRepository _config; + private readonly IGeneralLedgerService _gl; + private readonly ICurrentUser _currentUser; + private readonly IUnitOfWork _uow; + + /// Scope-lifetime cache — see . + private GlAccountCodes? _cached; + + public GlAccountConfigService( + IRepository config, IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow) + { + _config = config; + _gl = gl; + _currentUser = currentUser; + _uow = uow; + } + + public async Task> GetAsync(CancellationToken ct = default) + { + var config = await _config.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct) + ?? throw new NotFoundException("GL account configuration has not been seeded."); + + // Read-only screen data: GL being down must not stop the saved mapping from rendering. + IReadOnlyList accounts = []; + var glUnavailable = false; + try + { + accounts = await _gl.ListAccountsAsync(ct); + } + catch (DomainException) + { + glUnavailable = true; + } + + return new ETagged(Map(config, accounts, glUnavailable), config.RowVersion); + } + + public async Task> UpdateAsync( + UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var config = await _config.GetByIdAsync(GlAccountConfig.SingletonId, ct) + ?? throw new NotFoundException("GL account configuration has not been seeded."); + + if (config.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); + + var submitted = Submitted(request); + + // Fails closed, unlike GlAccountCodeValidator at startup: there we cannot verify and must + // still serve, here we cannot verify and must not store. Letting an unchecked mapping + // through is how the appsettings version went unnoticed for months. + var accounts = await _gl.ListAccountsAsync(ct); + if (accounts.Count == 0) + throw new DomainException( + ErrorCodes.GlAccountInvalid, + "GL returned an empty chart of accounts, so the mapping cannot be verified. Try again once GL has accounts.", + 503); + + var byCode = accounts.ToDictionary(a => a.AccountCode, StringComparer.Ordinal); + var problems = new List(); + foreach (var (role, code) in submitted) + { + if (code is null) continue; + + if (!byCode.TryGetValue(code, out var account)) + problems.Add($"{role}: account '{code}' does not exist in GL"); + else if (!account.IsPostable) + // GL rejects a journal line against a non-postable account, so storing one just + // moves the failure to posting time. Control/header accounts are the usual trap. + problems.Add($"{role}: account '{code}' ({account.AccountName}) is not postable"); + } + + if (problems.Count > 0) + throw new DomainException( + ErrorCodes.GlAccountInvalid, + "The GL account mapping was rejected — " + string.Join("; ", problems) + ".", + 422); + + config.InventoryAccountCode = submitted[GlAccountRole.Inventory]; + config.VatRecoverableAccountCode = submitted[GlAccountRole.VatRecoverable]; + config.AccountsReceivableCode = submitted[GlAccountRole.AccountsReceivable]; + config.ClearingAccountCode = submitted[GlAccountRole.Clearing]; + config.TaxPayableAccountCode = submitted[GlAccountRole.TaxPayable]; + config.AccountsPayableCode = submitted[GlAccountRole.AccountsPayable]; + config.SalesRevenueAccountCode = submitted[GlAccountRole.SalesRevenue]; + config.SalesDiscountAccountCode = submitted[GlAccountRole.SalesDiscount]; + config.InventoryGainAccountCode = submitted[GlAccountRole.InventoryGain]; + config.CogsAccountCode = submitted[GlAccountRole.Cogs]; + config.InventoryLossAccountCode = submitted[GlAccountRole.InventoryLoss]; + config.CashAccountCode = submitted[GlAccountRole.Cash]; + config.UpdatedAt = DateTime.UtcNow; + config.UpdatedBy = _currentUser.AuditUserId; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412); + } + + _cached = null; + return new ETagged(Map(config, accounts, glUnavailable: false), config.RowVersion); + } + + public async Task GetCodesAsync(CancellationToken ct = default) + { + if (_cached is not null) return _cached; + + var config = await _config.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.ConfigId == GlAccountConfig.SingletonId, ct) + ?? throw new NotFoundException("GL account configuration has not been seeded."); + + return _cached = new GlAccountCodes(Current(config)); + } + + /// Role → stored code, normalising blanks to null. + private static Dictionary Current(GlAccountConfig c) => new() + { + [GlAccountRole.Inventory] = Trim(c.InventoryAccountCode), + [GlAccountRole.VatRecoverable] = Trim(c.VatRecoverableAccountCode), + [GlAccountRole.AccountsReceivable] = Trim(c.AccountsReceivableCode), + [GlAccountRole.Clearing] = Trim(c.ClearingAccountCode), + [GlAccountRole.TaxPayable] = Trim(c.TaxPayableAccountCode), + [GlAccountRole.AccountsPayable] = Trim(c.AccountsPayableCode), + [GlAccountRole.SalesRevenue] = Trim(c.SalesRevenueAccountCode), + [GlAccountRole.SalesDiscount] = Trim(c.SalesDiscountAccountCode), + [GlAccountRole.InventoryGain] = Trim(c.InventoryGainAccountCode), + [GlAccountRole.Cogs] = Trim(c.CogsAccountCode), + [GlAccountRole.InventoryLoss] = Trim(c.InventoryLossAccountCode), + [GlAccountRole.Cash] = Trim(c.CashAccountCode) + }; + + private static Dictionary Submitted(UpdateGlAccountConfigRequest r) => new() + { + [GlAccountRole.Inventory] = Trim(r.InventoryAccountCode), + [GlAccountRole.VatRecoverable] = Trim(r.VatRecoverableAccountCode), + [GlAccountRole.AccountsReceivable] = Trim(r.AccountsReceivableCode), + [GlAccountRole.Clearing] = Trim(r.ClearingAccountCode), + [GlAccountRole.TaxPayable] = Trim(r.TaxPayableAccountCode), + [GlAccountRole.AccountsPayable] = Trim(r.AccountsPayableCode), + [GlAccountRole.SalesRevenue] = Trim(r.SalesRevenueAccountCode), + [GlAccountRole.SalesDiscount] = Trim(r.SalesDiscountAccountCode), + [GlAccountRole.InventoryGain] = Trim(r.InventoryGainAccountCode), + [GlAccountRole.Cogs] = Trim(r.CogsAccountCode), + [GlAccountRole.InventoryLoss] = Trim(r.InventoryLossAccountCode), + [GlAccountRole.Cash] = Trim(r.CashAccountCode) + }; + + private static string? Trim(string? code) => string.IsNullOrWhiteSpace(code) ? null : code.Trim(); + + private static GlAccountConfigDto Map( + GlAccountConfig config, IReadOnlyList accounts, bool glUnavailable) + { + var byCode = accounts.ToDictionary(a => a.AccountCode, StringComparer.Ordinal); + + var mappings = Current(config).Select(kv => + { + var found = kv.Value is not null && byCode.TryGetValue(kv.Value, out var a) ? a : null; + return new GlAccountMappingDto( + kv.Key.ToString(), + kv.Value, + found?.AccountName, + // Unknown while GL is down, rather than falsely "missing". + ExistsInGl: glUnavailable || found is not null, + IsPostable: glUnavailable || found?.IsPostable == true); + }).ToList(); + + var options = accounts + .Where(a => a.IsPostable && a.IsActive) + .OrderBy(a => a.AccountCode, StringComparer.Ordinal) + .Select(a => new GlAccountOptionDto(a.AccountCode, a.AccountName, a.AccountTypeId, a.IsPostable, a.IsActive)) + .ToList(); + + return new GlAccountConfigDto(mappings, options, glUnavailable, config.UpdatedAt, config.UpdatedBy); + } +} diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 00b549c..570a994 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -45,9 +45,7 @@ public sealed class GrnService : IGrnService private readonly IUnitOfWork _uow; private readonly IGeneralLedgerService _gl; private readonly INotificationService _notifications; - private readonly string _glInventoryAccountCode; - private readonly string _glVatRecoverableAccountCode; - private readonly string _glClearingAccountCode; + private readonly IGlAccountConfigService _glAccounts; public GrnService( IRepository grns, IRepository pos, IRepository poLines, @@ -55,7 +53,7 @@ public sealed class GrnService : IGrnService IRepository bins, IRepository vendors, IRepository batches, IRepository layers, IRepository ledger, IRepository taxRates, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig, - IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IConfiguration configuration) + IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts) { _grns = grns; _pos = pos; @@ -76,9 +74,7 @@ public sealed class GrnService : IGrnService _uow = uow; _gl = gl; _notifications = notifications; - _glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty; - _glVatRecoverableAccountCode = configuration["Grn:GlVatRecoverableAccountCode"] ?? string.Empty; - _glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty; + _glAccounts = glAccounts; } public async Task> ListAsync( @@ -311,17 +307,18 @@ public sealed class GrnService : IGrnService // with it, so inventory and the ledger never diverge (user-approved "fails // atomically" behavior; residual risk if GL posts but the local commit still // fails afterward is accepted for this phase, see the integration plan). + var glCodes = await _glAccounts.GetCodesAsync(token); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token); var glLines = new List(); decimal totalPayable = 0m; foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId)) { - glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], line.ReceivedValue, 0m, $"GRN {grn.DocNo} line {line.GrnLineId}")); if (line.VatAmount > 0) - glLines.Add(new GlJournalEntryLineRequest(_glVatRecoverableAccountCode, line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.VatRecoverable], line.VatAmount, 0m, $"GRN {grn.DocNo} line {line.GrnLineId} VAT")); totalPayable += line.LineTotal; } - glLines.Add(new GlJournalEntryLineRequest(_glClearingAccountCode, 0m, totalPayable, $"GRN {grn.DocNo} received")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], 0m, totalPayable, $"GRN {grn.DocNo} received")); var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { diff --git a/Backend/ERPCore/Services/Interfaces/IGlAccountConfigService.cs b/Backend/ERPCore/Services/Interfaces/IGlAccountConfigService.cs new file mode 100644 index 0000000..d006a9b --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IGlAccountConfigService.cs @@ -0,0 +1,30 @@ +using ERPCore.Common.Http; +using ERPCore.Dtos.Config; +using ERPCore.Services.Gl; + +namespace ERPCore.Services.Interfaces; + +/// +/// The role → GL account code mapping: master data, held in gl_account_config and validated +/// against GL's live chart of accounts on save. Replaces the per-module Gl*Code appsettings +/// keys, which could only be checked at boot and only after a redeploy could be corrected. +/// +public interface IGlAccountConfigService +{ + /// Current mapping plus the selectable accounts, for the settings screen. + Task> GetAsync(CancellationToken ct = default); + + /// + /// Replaces the mapping. Fails closed: rejects any code GL does not have or cannot post to, and + /// rejects the save outright if GL cannot be reached — an unverified mapping is not a valid one. + /// + Task> UpdateAsync( + UpdateGlAccountConfigRequest request, uint expectedRowVersion, CancellationToken ct = default); + + /// + /// The mapping as posting flows consume it. Cached for the lifetime of the scope — a single + /// request can post through several services (GrnService → FifoCostingService) and they should + /// not each re-read the row. + /// + Task GetCodesAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs index 2534451..66aec8c 100644 --- a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs @@ -28,18 +28,18 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi private readonly IGeneralLedgerService _gl; private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; - private readonly string _glAccountsPayableCode; + private readonly IGlAccountConfigService _glAccounts; public PurchaseInvoicePaymentService( IRepository invoices, IRepository payments, IGeneralLedgerService gl, - ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration) + ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts) { _invoices = invoices; _payments = payments; _gl = gl; _currentUser = currentUser; _uow = uow; - _glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty; + _glAccounts = glAccounts; } public async Task PayAsync(int purchaseInvoiceId, CreatePurchaseInvoicePaymentRequest request, CancellationToken ct = default) @@ -62,6 +62,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi var payment = await _uow.ExecuteInTransactionAsync(async token => { + var glCodes = await _glAccounts.GetCodesAsync(token); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token); var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { @@ -72,7 +73,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi Narration = $"Payment against invoice {invoice.DocNo}", Lines = [ - new GlJournalEntryLineRequest(_glAccountsPayableCode, request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"), + new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], request.Amount, 0m, $"Payment against invoice {invoice.DocNo}"), new GlJournalEntryLineRequest(account.GlAccountCode, 0m, request.Amount, $"Payment against invoice {invoice.DocNo}") ] }, token); diff --git a/Backend/ERPCore/Services/PurchaseInvoiceService.cs b/Backend/ERPCore/Services/PurchaseInvoiceService.cs index bf539f6..744f822 100644 --- a/Backend/ERPCore/Services/PurchaseInvoiceService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoiceService.cs @@ -35,13 +35,12 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService private readonly INumberSequenceService _numbers; private readonly IUnitOfWork _uow; private readonly IGeneralLedgerService _gl; - private readonly string _glClearingAccountCode; - private readonly string _glAccountsPayableCode; + private readonly IGlAccountConfigService _glAccounts; public PurchaseInvoiceService( IRepository invoices, IRepository vendors, IRepository grns, ICurrentUser currentUser, INumberSequenceService numbers, IUnitOfWork uow, - IGeneralLedgerService gl, IConfiguration configuration) + IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { _invoices = invoices; _vendors = vendors; @@ -50,8 +49,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService _numbers = numbers; _uow = uow; _gl = gl; - _glClearingAccountCode = configuration["PurchaseInvoice:GlClearingAccountCode"] ?? string.Empty; - _glAccountsPayableCode = configuration["PurchaseInvoice:GlAccountsPayableCode"] ?? string.Empty; + _glAccounts = glAccounts; } public async Task> ListAsync(PageQuery query, PurchaseInvoiceStatus? status, int? vendorId, CancellationToken ct = default) @@ -186,6 +184,7 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService // Reclassify the GRN clearing balance to Accounts Payable — the mirror image of // SalesInvoicePaymentService's AR credit — before committing, so a GL rejection // rolls the whole post back (same atomicity approach as GrnService.ConfirmAsync). + var glCodes = await _glAccounts.GetCodesAsync(token); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token); var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { @@ -196,8 +195,8 @@ public sealed class PurchaseInvoiceService : IPurchaseInvoiceService Narration = $"Vendor invoice recognized - {invoice.DocNo}", Lines = [ - new GlJournalEntryLineRequest(_glClearingAccountCode, invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"), - new GlJournalEntryLineRequest(_glAccountsPayableCode, 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}") + new GlJournalEntryLineRequest(glCodes[GlAccountRole.Clearing], invoice.GrandTotal, 0m, $"Purchase invoice {invoice.DocNo}"), + new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsPayable], 0m, invoice.GrandTotal, $"Purchase invoice {invoice.DocNo}") ] }, token); diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs index 28f145d..54581e5 100644 --- a/Backend/ERPCore/Services/PurchaseReturnService.cs +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -43,15 +43,14 @@ public sealed class PurchaseReturnService : IPurchaseReturnService private readonly INotificationService _notifications; private readonly IUnitOfWork _uow; private readonly IGeneralLedgerService _gl; - private readonly string _glInventoryAccountCode; - private readonly string _glClearingAccountCode; + private readonly IGlAccountConfigService _glAccounts; public PurchaseReturnService( IRepository returns, IRepository vendors, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository grnLines, IRepository layers, IRepository ledger, IStockMutator mutator, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, - IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration) + IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { _returns = returns; _vendors = vendors; @@ -68,8 +67,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService _notifications = notifications; _uow = uow; _gl = gl; - _glInventoryAccountCode = configuration["Grn:GlInventoryAccountCode"] ?? string.Empty; - _glClearingAccountCode = configuration["Grn:GlClearingAccountCode"] ?? string.Empty; + _glAccounts = glAccounts; } public async Task> ListAsync( @@ -221,6 +219,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService var totalValue = refs.Sum(r => r.Value); if (totalValue > 0) { + var glCodes = await _glAccounts.GetCodesAsync(token); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token); var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { @@ -231,8 +230,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService Narration = $"Purchase return {docNo} — stock returned to vendor", Lines = new List { - new(_glClearingAccountCode, totalValue, 0m, $"Purchase return {docNo}"), - new(_glInventoryAccountCode, 0m, totalValue, $"Purchase return {docNo} — inventory reduction") + new(glCodes[GlAccountRole.Clearing], totalValue, 0m, $"Purchase return {docNo}"), + new(glCodes[GlAccountRole.Inventory], 0m, totalValue, $"Purchase return {docNo} — inventory reduction") } }, token); ret.GlJournalNo = glResult.JournalNo; diff --git a/Backend/ERPCore/Services/SalesDayEndService.cs b/Backend/ERPCore/Services/SalesDayEndService.cs index 41c635b..540dd2f 100644 --- a/Backend/ERPCore/Services/SalesDayEndService.cs +++ b/Backend/ERPCore/Services/SalesDayEndService.cs @@ -43,18 +43,12 @@ public sealed class SalesDayEndService : ISalesDayEndService private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; private readonly IGeneralLedgerService _gl; - private readonly string _glCashAccountCode; - private readonly string _glSalesRevenueAccountCode; - private readonly string _glSalesDiscountAccountCode; - private readonly string _glTaxPayableAccountCode; - private readonly string _glCogsAccountCode; - private readonly string _glInventoryAccountCode; - private readonly string _glAccountsReceivableCode; + private readonly IGlAccountConfigService _glAccounts; public SalesDayEndService( IRepository dayEnds, IRepository slips, IRepository bundles, IRepository invoices, IRepository ledger, IRepository items, INumberSequenceService numbers, - ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IConfiguration configuration) + ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { _dayEnds = dayEnds; _slips = slips; @@ -66,13 +60,7 @@ public sealed class SalesDayEndService : ISalesDayEndService _currentUser = currentUser; _uow = uow; _gl = gl; - _glCashAccountCode = configuration["SalesDayEnd:GlCashAccountCode"] ?? string.Empty; - _glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty; - _glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty; - _glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty; - _glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty; - _glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty; - _glAccountsReceivableCode = configuration["SalesDayEnd:GlAccountsReceivableCode"] ?? string.Empty; + _glAccounts = glAccounts; } public async Task PreviewAsync(int cashierUserId, DateOnly businessDate, CancellationToken ct = default) @@ -206,24 +194,25 @@ public sealed class SalesDayEndService : ISalesDayEndService if (grandTotal > 0 || cogs > 0) { + var glCodes = await _glAccounts.GetCodesAsync(token); var period = await _gl.GetPeriodByDateAsync(businessDate, token); var glLines = new List(); if (grandTotal > 0) { if (paidTotal > 0) - glLines.Add(new GlJournalEntryLineRequest(_glCashAccountCode, paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cash], paidTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — cash collected")); if (balanceTotal > 0) - glLines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], balanceTotal, 0m, $"Day-end {businessDate:yyyy-MM-dd} cashier {request.CashierUserId} — sold on account")); if (discountTotal > 0) - glLines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, discountTotal, 0m, "Sales discount")); - glLines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, subtotal, "Sales revenue")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], discountTotal, 0m, "Sales discount")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, subtotal, "Sales revenue")); if (taxTotal > 0) - glLines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, taxTotal, "Output tax payable")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.TaxPayable], 0m, taxTotal, "Output tax payable")); } if (cogs > 0) { - glLines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold")); - glLines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold")); + glLines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction")); } var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest diff --git a/Backend/ERPCore/Services/SalesInvoicePaymentService.cs b/Backend/ERPCore/Services/SalesInvoicePaymentService.cs index 4267629..5018125 100644 --- a/Backend/ERPCore/Services/SalesInvoicePaymentService.cs +++ b/Backend/ERPCore/Services/SalesInvoicePaymentService.cs @@ -29,11 +29,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService private readonly IGeneralLedgerService _gl; private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; - private readonly string _glAccountsReceivableCode; + private readonly IGlAccountConfigService _glAccounts; public SalesInvoicePaymentService( IRepository invoices, IRepository payments, IRepository salesConfig, - IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IConfiguration configuration) + IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow, IGlAccountConfigService glAccounts) { _invoices = invoices; _payments = payments; @@ -41,7 +41,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService _gl = gl; _currentUser = currentUser; _uow = uow; - _glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty; + _glAccounts = glAccounts; } public async Task PayAsync(int salesInvoiceId, CreateSalesInvoicePaymentRequest request, CancellationToken ct = default) @@ -68,6 +68,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService // Same atomicity approach as GrnPaymentService.PayAsync: post to GL first, inside // this transaction, before anything is committed — a GL rejection/timeout rolls // the whole payment back with no partial local state. + var glCodes = await _glAccounts.GetCodesAsync(token); var period = await _gl.GetPeriodByDateAsync(paymentDay, token); var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { @@ -79,7 +80,7 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService Lines = [ new GlJournalEntryLineRequest(account.GlAccountCode, request.Amount, 0m, $"Payment against invoice {invoice.InvoiceNo}"), - new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}") + new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], 0m, request.Amount, $"Payment against invoice {invoice.InvoiceNo}") ] }, token); @@ -178,10 +179,11 @@ public sealed class SalesInvoicePaymentService : ISalesInvoicePaymentService { // One journal for the whole payment (Debit each cash/bank line / Credit AR in total), posted // before anything is committed — so a GL rejection leaves neither GL nor local state half-done. + var glCodes = await _glAccounts.GetCodesAsync(token); var period = await _gl.GetPeriodByDateAsync(paymentDay, token); var glLines = lines .Select(x => new GlJournalEntryLineRequest(x.Account.GlAccountCode, x.Line.Amount, 0m, narration)) - .Append(new GlJournalEntryLineRequest(_glAccountsReceivableCode, 0m, total, narration)) + .Append(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], 0m, total, narration)) .ToList(); var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs index 6e61a3f..adfcd1f 100644 --- a/Backend/ERPCore/Services/SalesPostingService.cs +++ b/Backend/ERPCore/Services/SalesPostingService.cs @@ -26,12 +26,7 @@ public sealed class SalesPostingService : ISalesPostingService private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; private readonly IGeneralLedgerService _gl; - private readonly string _glAccountsReceivableCode; - private readonly string _glSalesRevenueAccountCode; - private readonly string _glSalesDiscountAccountCode; - private readonly string _glTaxPayableAccountCode; - private readonly string _glCogsAccountCode; - private readonly string _glInventoryAccountCode; + private readonly IGlAccountConfigService _glAccounts; public SalesPostingService( IRepository invoices, @@ -44,7 +39,7 @@ public sealed class SalesPostingService : ISalesPostingService ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, - IConfiguration configuration) + IGlAccountConfigService glAccounts) { _invoices = invoices; _slips = slips; @@ -56,14 +51,9 @@ public sealed class SalesPostingService : ISalesPostingService _currentUser = currentUser; _uow = uow; _gl = gl; - _glAccountsReceivableCode = configuration["SalesInvoice:GlAccountsReceivableCode"] ?? string.Empty; + _glAccounts = glAccounts; // Same physical accounts Sales Day End credits for slip/bundle revenue — an invoice // just recognizes them immediately on Post instead of waiting for the day's close. - _glSalesRevenueAccountCode = configuration["SalesDayEnd:GlSalesRevenueAccountCode"] ?? string.Empty; - _glSalesDiscountAccountCode = configuration["SalesDayEnd:GlSalesDiscountAccountCode"] ?? string.Empty; - _glTaxPayableAccountCode = configuration["SalesDayEnd:GlTaxPayableAccountCode"] ?? string.Empty; - _glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty; - _glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty; } public async Task CheckInvoiceAsync(int salesInvoiceId, CancellationToken ct = default) @@ -185,21 +175,22 @@ public sealed class SalesPostingService : ISalesPostingService if (invoice.NetPayable <= 0 && cogs <= 0) return; var now = DateTime.UtcNow; + var glCodes = await _glAccounts.GetCodesAsync(ct); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), ct); var lines = new List(); if (invoice.NetPayable > 0) { - lines.Add(new GlJournalEntryLineRequest(_glAccountsReceivableCode, invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.AccountsReceivable], invoice.NetPayable, 0m, $"Invoice {invoice.InvoiceNo}")); if (invoice.DiscountTotal > 0) - lines.Add(new GlJournalEntryLineRequest(_glSalesDiscountAccountCode, invoice.DiscountTotal, 0m, "Sales discount")); - lines.Add(new GlJournalEntryLineRequest(_glSalesRevenueAccountCode, 0m, invoice.Subtotal, "Sales revenue")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesDiscount], invoice.DiscountTotal, 0m, "Sales discount")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.SalesRevenue], 0m, invoice.Subtotal, "Sales revenue")); if (invoice.TaxTotal > 0) - lines.Add(new GlJournalEntryLineRequest(_glTaxPayableAccountCode, 0m, invoice.TaxTotal, "Output tax payable")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.TaxPayable], 0m, invoice.TaxTotal, "Output tax payable")); } if (cogs > 0) { - lines.Add(new GlJournalEntryLineRequest(_glCogsAccountCode, cogs, 0m, "Cost of goods sold")); - lines.Add(new GlJournalEntryLineRequest(_glInventoryAccountCode, 0m, cogs, "Inventory reduction")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Cogs], cogs, 0m, "Cost of goods sold")); + lines.Add(new GlJournalEntryLineRequest(glCodes[GlAccountRole.Inventory], 0m, cogs, "Inventory reduction")); } var result = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest diff --git a/Backend/ERPCore/Services/SalesReturnService.cs b/Backend/ERPCore/Services/SalesReturnService.cs index 7515939..d648a63 100644 --- a/Backend/ERPCore/Services/SalesReturnService.cs +++ b/Backend/ERPCore/Services/SalesReturnService.cs @@ -39,15 +39,14 @@ public sealed class SalesReturnService : ISalesReturnService private readonly ICurrentUser _currentUser; private readonly IUnitOfWork _uow; private readonly IGeneralLedgerService _gl; - private readonly string _glInventoryAccountCode; - private readonly string _glCogsAccountCode; + private readonly IGlAccountConfigService _glAccounts; public SalesReturnService( IRepository returns, IRepository customers, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository salesInvoiceLines, IRepository returnLines, IRepository ledger, IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow, - IGeneralLedgerService gl, IConfiguration configuration) + IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { _returns = returns; _customers = customers; @@ -62,8 +61,7 @@ public sealed class SalesReturnService : ISalesReturnService _currentUser = currentUser; _uow = uow; _gl = gl; - _glInventoryAccountCode = configuration["SalesDayEnd:GlInventoryAccountCode"] ?? string.Empty; - _glCogsAccountCode = configuration["SalesDayEnd:GlCogsAccountCode"] ?? string.Empty; + _glAccounts = glAccounts; } public async Task> ListAsync( @@ -180,6 +178,7 @@ public sealed class SalesReturnService : ISalesReturnService var totalValue = refs.Sum(r => r.Value); if (totalValue > 0) { + var glCodes = await _glAccounts.GetCodesAsync(token); var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(now), token); var glResult = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { @@ -190,8 +189,8 @@ public sealed class SalesReturnService : ISalesReturnService Narration = $"Sales return {docNo} — stock restocked", Lines = new List { - new(_glInventoryAccountCode, totalValue, 0m, $"Sales return {docNo}"), - new(_glCogsAccountCode, 0m, totalValue, $"Sales return {docNo} — COGS reversal") + new(glCodes[GlAccountRole.Inventory], totalValue, 0m, $"Sales return {docNo}"), + new(glCodes[GlAccountRole.Cogs], 0m, totalValue, $"Sales return {docNo} — COGS reversal") } }, token); ret.GlJournalNo = glResult.JournalNo; diff --git a/Backend/ERPCore/Services/Stock/FifoCostingService.cs b/Backend/ERPCore/Services/Stock/FifoCostingService.cs index 1545522..d82f2a0 100644 --- a/Backend/ERPCore/Services/Stock/FifoCostingService.cs +++ b/Backend/ERPCore/Services/Stock/FifoCostingService.cs @@ -26,21 +26,19 @@ public sealed class FifoCostingService : IFifoCostingService private readonly IRepository _journal; private readonly ErpDbContext _db; private readonly IGeneralLedgerService _gl; - private readonly string _glInventoryAccountCode; - private readonly string _glClearingAccountCode; + private readonly IGlAccountConfigService _glAccounts; public FifoCostingService( IRepository layers, IRepository ledger, IRepository journal, ErpDbContext db, - IGeneralLedgerService gl, IConfiguration configuration) + IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { _layers = layers; _ledger = ledger; _journal = journal; _db = db; _gl = gl; - _glInventoryAccountCode = configuration["Fifo:GlInventoryAccountCode"] ?? string.Empty; - _glClearingAccountCode = configuration["Fifo:GlClearingAccountCode"] ?? string.Empty; + _glAccounts = glAccounts; } public async Task CreateInboundLayerAsync( @@ -273,10 +271,12 @@ public sealed class FifoCostingService : IFifoCostingService }; await _ledger.AddAsync(entry, ct); + var glCodes = await _glAccounts.GetCodesAsync(ct); + // Inbound debits Inventory / credits Clearing; outbound reverses. var (debit, credit) = direction == Direction.In - ? (_glInventoryAccountCode, _glClearingAccountCode) - : (_glClearingAccountCode, _glInventoryAccountCode); + ? (glCodes[GlAccountRole.Inventory], glCodes[GlAccountRole.Clearing]) + : (glCodes[GlAccountRole.Clearing], glCodes[GlAccountRole.Inventory]); // Local read-only stub kept for the journal-entries listing endpoint (FR-STK-13). await _journal.AddAsync(new JournalEntryStub -- 2.52.0 From c30ff473f551c97645edfda7d80589206bba2360 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Wed, 7 Oct 2026 13:17:22 +0530 Subject: [PATCH 2/7] Add the GL Accounts settings screen Pickers are populated from GL's own chart of accounts, postable and active accounts only, so a user can only choose a code the server will accept. A stored code GL does not have is shown flagged and still selected rather than appearing blank, which is the state 11 of the 12 mappings are in until GL's chart is seeded - the broken mapping stays visible instead of looking merely unset. Options come from /gl-account-config rather than the GL proxy: that proxy is gated on the `ledgers` nav permission while this screen is on `settings`, and one endpoint keeps the picker and the server-side validation working from the same list. Gated on the existing `settings` key so no role_permissions edit is needed. Saving is disabled outright while GL is unreachable, matching the server, which refuses to store a mapping it could not verify. Co-Authored-By: Claude Opus 5 --- .../dashboard/settings/configuration/page.tsx | 8 +- .../dashboard/settings/gl-accounts/page.tsx | 217 ++++++++++++++++++ .../erp-system/lib/api/gl-account-config.ts | 20 ++ .../erp-system/types/gl-account-config.ts | 113 +++++++++ 4 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 Frontend/erp-system/app/dashboard/settings/gl-accounts/page.tsx create mode 100644 Frontend/erp-system/lib/api/gl-account-config.ts create mode 100644 Frontend/erp-system/types/gl-account-config.ts diff --git a/Frontend/erp-system/app/dashboard/settings/configuration/page.tsx b/Frontend/erp-system/app/dashboard/settings/configuration/page.tsx index 5742979..14bbb97 100644 --- a/Frontend/erp-system/app/dashboard/settings/configuration/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/configuration/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link" -import { Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react" +import { BookOpen, Building2, LayoutTemplate, MapPin, PackageCheck, ShoppingCart, SlidersHorizontal } from "lucide-react" const cards = [ { @@ -32,6 +32,12 @@ const cards = [ icon: PackageCheck, description: "Warehouse selection and the default receiving warehouse", }, + { + title: "GL Accounts", + href: "/dashboard/settings/gl-accounts", + icon: BookOpen, + description: "Which General Ledger account each posting role uses", + }, { title: "Invoice Template", href: "/dashboard/settings/invoice-template", diff --git a/Frontend/erp-system/app/dashboard/settings/gl-accounts/page.tsx b/Frontend/erp-system/app/dashboard/settings/gl-accounts/page.tsx new file mode 100644 index 0000000..2c5a18c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/gl-accounts/page.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { AlertTriangle, BookOpen, CheckCircle2 } from "lucide-react" + +import { glAccountConfigApi } from "@/lib/api/gl-account-config" +import { errorMessage } from "@/lib/error-map" +import { + GL_ROLE_FIELD, + GL_ROLE_LABELS, + GL_ROLE_ORDER, + GlAccountConfig, + GlAccountRole, + UpdateGlAccountConfigRequest, +} from "@/types/gl-account-config" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +/** + * GL Accounts — which General Ledger account each accounting role posts to. + * + * This used to live in the backend's appsettings.json as per-module `Gl*Code` keys, which could + * only be corrected by a redeploy and were only ever checked at startup. On 2026-10-07 eleven of + * the twelve configured codes turned out never to have existed in GL, so every GRN, adjustment, + * day-end and invoice posting was failing with "Account not found". + * + * The picker is populated from GL's own chart of accounts (postable, active accounts only) and the + * server re-validates on save, so a mapping that GL cannot post to cannot be stored. A stored code + * that GL does not have is shown flagged rather than blank — the broken mapping stays visible + * instead of silently appearing empty. + */ +export default function GlAccountsPage() { + const [config, setConfig] = useState(null) + const [etag, setEtag] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + /** Pending edits, by role. Empty once saved or reloaded. */ + const [draft, setDraft] = useState>>({}) + + function load() { + setError(null) + glAccountConfigApi + .get() + .then((res) => { + setConfig(res.data) + setEtag(res.etag) + setDraft({}) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, []) + + const current = useMemo(() => { + const byRole = new Map(config?.mappings.map((m) => [m.role, m]) ?? []) + return (role: GlAccountRole) => ({ + mapping: byRole.get(role), + value: role in draft ? draft[role] ?? null : byRole.get(role)?.accountCode ?? null, + }) + }, [config, draft]) + + const dirty = Object.keys(draft).length > 0 + const broken = config?.mappings.filter((m) => m.accountCode && !m.existsInGl) ?? [] + const unmapped = config?.mappings.filter((m) => !m.accountCode) ?? [] + + async function save() { + if (!config) return + setSaving(true) + try { + // PUT is a full replacement: start from what is stored, then layer the pending edits. + const body = GL_ROLE_ORDER.reduce((acc, role) => { + acc[GL_ROLE_FIELD[role]] = current(role).value + return acc + }, {} as UpdateGlAccountConfigRequest) + + const res = await glAccountConfigApi.update(body, etag ?? "") + setConfig(res.data) + setEtag(res.etag) + setDraft({}) + toast.success("GL accounts saved", "Postings will use the new mapping immediately.") + } catch (err) { + toast.error("Could not save GL accounts", errorMessage(err)) + } finally { + setSaving(false) + } + } + + return ( +
+
+

GL Accounts

+

+ Choose which General Ledger account each posting role uses. Only postable accounts from + GL's chart of accounts can be selected. +

+
+ + {error && ( +
{error}
+ )} + + {!error && !config && } + + {!error && config && ( + <> + {config.glUnavailable && ( +
+ +
+ The General Ledger service could not be reached. + + The mapping below is what is stored, but it cannot be verified and cannot be changed + until GL responds. + +
+
+ )} + + {!config.glUnavailable && broken.length > 0 && ( +
+ +
+ + {broken.length} mapped account{broken.length === 1 ? "" : "s"} do not exist in GL. + + + Every flow that posts to {broken.length === 1 ? "it" : "them"} will fail. Either create + the account{broken.length === 1 ? "" : "s"} in GL, or pick{" "} + {broken.length === 1 ? "a replacement" : "replacements"} below:{" "} + {broken.map((m) => m.accountCode).join(", ")}. + +
+
+ )} + + {!config.glUnavailable && broken.length === 0 && unmapped.length === 0 && ( +
+ + Every role is mapped to an account that exists in GL and accepts postings. +
+ )} + +
+
+ +

Account mapping

+
+
+ + {GL_ROLE_ORDER.map((role) => { + const { mapping, value } = current(role) + const meta = GL_ROLE_LABELS[role] + const missing = Boolean(value) && !config.options.some((o) => o.accountCode === value) + + return ( +
+
+ {meta.label} + {meta.description} +
+ +
+ + + {missing && value && ( + Not in GL's chart of accounts + )} + {!missing && !value && Postings using this role will fail.} + {!missing && value && mapping?.accountName && ( + {mapping.accountName} + )} +
+
+ ) + })} + +
+
+ {dirty && ( + + )} + +
+
+ + )} +
+ ) +} diff --git a/Frontend/erp-system/lib/api/gl-account-config.ts b/Frontend/erp-system/lib/api/gl-account-config.ts new file mode 100644 index 0000000..e7e8351 --- /dev/null +++ b/Frontend/erp-system/lib/api/gl-account-config.ts @@ -0,0 +1,20 @@ +// Role → GL account mapping. Singleton, ETag-guarded, same shape as branch-config/grn-config. +// +// Note this is ERPCore's own endpoint, not the GL proxy in lib/api/general-ledger.ts: it speaks +// ProblemDetails and ETags like the rest of the app. It also serves the selectable accounts +// itself rather than the page calling the GL proxy, for two reasons — the GL proxy is gated on the +// `ledgers` nav permission while this screen is on `settings`, and serving both from one endpoint +// guarantees the picker offers exactly the accounts the server will accept. +import { apiRequestWithETag } from "@/lib/api-client" +import { ApiResult } from "@/types/common" +import { GlAccountConfig, UpdateGlAccountConfigRequest } from "@/types/gl-account-config" + +export const glAccountConfigApi = { + get(): Promise> { + return apiRequestWithETag("/gl-account-config") + }, + + update(request: UpdateGlAccountConfigRequest, ifMatch: string): Promise> { + return apiRequestWithETag("/gl-account-config", { method: "PUT", body: request, ifMatch }) + }, +} diff --git a/Frontend/erp-system/types/gl-account-config.ts b/Frontend/erp-system/types/gl-account-config.ts new file mode 100644 index 0000000..d11c703 --- /dev/null +++ b/Frontend/erp-system/types/gl-account-config.ts @@ -0,0 +1,113 @@ +// Role → GL account mapping (ERPCore's own `/gl-account-config`, not a GL passthrough — so the +// ordinary ProblemDetails/ETag conventions apply here, unlike types/general-ledger.ts). +// +// This replaced the per-module `Gl*Code` appsettings keys. Those pinned GL's account *codes* in +// backend config while GL generates codes itself, so nothing kept the two in step — on 2026-10-07 +// eleven of the twelve configured codes turned out never to have existed in GL. The server now +// validates every code against GL's live chart of accounts on save, and serves the selectable +// accounts from this same endpoint so the picker can only offer codes that will pass validation. + +/** Keep in step with the backend `GlAccountRole` enum — the server sends these as strings. */ +export type GlAccountRole = + | "Inventory" + | "VatRecoverable" + | "AccountsReceivable" + | "Clearing" + | "TaxPayable" + | "AccountsPayable" + | "SalesRevenue" + | "SalesDiscount" + | "InventoryGain" + | "Cogs" + | "InventoryLoss" + | "Cash" + +export interface GlAccountOption { + accountCode: string + accountName: string + accountTypeId: number + isPostable: boolean + isActive: boolean +} + +export interface GlAccountMapping { + role: GlAccountRole + accountCode: string | null + accountName: string | null + /** False when the stored code is not in GL's chart of accounts — shown flagged, not blank. */ + existsInGl: boolean + isPostable: boolean +} + +export interface GlAccountConfig { + mappings: GlAccountMapping[] + options: GlAccountOption[] + /** GL could not be reached: mappings still render, `options` is empty, and saving is refused. */ + glUnavailable: boolean + updatedAt: string | null + updatedBy: number | null +} + +/** Full replacement — every role is sent on every save; null clears one. */ +export interface UpdateGlAccountConfigRequest { + inventoryAccountCode: string | null + vatRecoverableAccountCode: string | null + accountsReceivableCode: string | null + clearingAccountCode: string | null + taxPayableAccountCode: string | null + accountsPayableCode: string | null + salesRevenueAccountCode: string | null + salesDiscountAccountCode: string | null + inventoryGainAccountCode: string | null + cogsAccountCode: string | null + inventoryLossAccountCode: string | null + cashAccountCode: string | null +} + +/** Request-field name per role, so the page can build the PUT body from the mapping list. */ +export const GL_ROLE_FIELD: Record = { + Inventory: "inventoryAccountCode", + VatRecoverable: "vatRecoverableAccountCode", + AccountsReceivable: "accountsReceivableCode", + Clearing: "clearingAccountCode", + TaxPayable: "taxPayableAccountCode", + AccountsPayable: "accountsPayableCode", + SalesRevenue: "salesRevenueAccountCode", + SalesDiscount: "salesDiscountAccountCode", + InventoryGain: "inventoryGainAccountCode", + Cogs: "cogsAccountCode", + InventoryLoss: "inventoryLossAccountCode", + Cash: "cashAccountCode", +} + +/** What each role means in accounting terms, and which flows post to it. */ +export const GL_ROLE_LABELS: Record = { + Inventory: { label: "Inventory", description: "Stock on hand — GRN, adjustments, day-end, FIFO costing" }, + VatRecoverable: { label: "VAT Recoverable", description: "Recoverable input VAT on purchases — GRN" }, + AccountsReceivable: { label: "Accounts Receivable", description: "Trade debtors — sales invoices, day-end" }, + Clearing: { label: "Goods Received Not Invoiced", description: "GRN ↔ purchase-invoice clearing account" }, + TaxPayable: { label: "Tax Payable", description: "Output tax collected on sales — day-end" }, + AccountsPayable: { label: "Accounts Payable", description: "Trade creditors — purchase invoices and payments" }, + SalesRevenue: { label: "Sales Revenue", description: "Sales income — day-end, invoice posting" }, + SalesDiscount: { label: "Sales Discount", description: "Discounts given (contra-revenue) — day-end" }, + InventoryGain: { label: "Inventory Gain", description: "Stock written up by an adjustment or count" }, + Cogs: { label: "Cost of Goods Sold", description: "Cost of sales — day-end, sales returns" }, + InventoryLoss: { label: "Inventory Loss", description: "Stock written off by an adjustment or count" }, + Cash: { label: "Cash", description: "Cash takings at day-end close" }, +} + +/** Display order: assets, liabilities, income, expense — the order an accountant reads them in. */ +export const GL_ROLE_ORDER: GlAccountRole[] = [ + "Inventory", + "VatRecoverable", + "AccountsReceivable", + "Cash", + "Clearing", + "TaxPayable", + "AccountsPayable", + "SalesRevenue", + "SalesDiscount", + "InventoryGain", + "Cogs", + "InventoryLoss", +] -- 2.52.0 From a3591d364330fad396380744e1463bb55b428cce Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Wed, 7 Oct 2026 13:17:52 +0530 Subject: [PATCH 3/7] Sync package-lock.json with package.json so npm ci works again html-to-image and jspdf were added to package.json without being added to the lockfile, so `npm ci` - which the frontend Dockerfile runs - failed with the package.json/package-lock.json out-of-sync error. tsc also could not resolve either module in SalesInvoicePrint.tsx. Pre-existing and unrelated to the GL account work; found because the GL Accounts screen could not be build-verified without it. Adds those two plus their transitive deps. It also drops @emnapi/core and @emnapi/runtime: both are optional, and required only by the wasm32-wasi / sharp-wasm32 variants, which linux-x64 never selects in favour of the native binaries - so the Docker build is unaffected. Verified: npm ci succeeds, tsc --noEmit is clean, npm run build compiles. Co-Authored-By: Claude Opus 5 --- Frontend/erp-system/package-lock.json | 282 +++++++++++++++++++++++--- 1 file changed, 259 insertions(+), 23 deletions(-) diff --git a/Frontend/erp-system/package-lock.json b/Frontend/erp-system/package-lock.json index 540a3c5..d3a849a 100644 --- a/Frontend/erp-system/package-lock.json +++ b/Frontend/erp-system/package-lock.json @@ -17,6 +17,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", "lucide-react": "^1.23.0", "next": "^16.3.5", "next-themes": "^0.4.6", @@ -83,6 +85,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -533,7 +536,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", @@ -726,6 +730,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -769,28 +774,6 @@ "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==", "license": "BSD-3-Clause" }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -2408,12 +2391,26 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2424,10 +2421,18 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -2479,6 +2484,7 @@ "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", @@ -3137,6 +3143,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3501,6 +3508,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", @@ -3592,6 +3609,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", @@ -3707,6 +3725,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3729,6 +3767,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -3965,6 +4004,21 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -4022,6 +4076,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4098,6 +4162,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -4212,6 +4277,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -4396,6 +4462,16 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", @@ -4740,6 +4816,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4925,6 +5002,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5214,6 +5292,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5320,6 +5399,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -5345,6 +5435,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -5843,10 +5939,31 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -5948,6 +6065,12 @@ "node": ">= 0.4" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.7.2", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", @@ -6682,6 +6805,23 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -7757,6 +7897,22 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7850,6 +8006,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8131,6 +8294,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -8164,6 +8337,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8209,6 +8383,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8221,6 +8396,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8287,6 +8463,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -8407,6 +8590,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8930,6 +9123,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9253,6 +9456,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/systeminformation": { "version": "5.31.13", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.13.tgz", @@ -9310,6 +9523,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -9357,6 +9580,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9577,6 +9801,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9767,6 +9992,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -9972,6 +10207,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } -- 2.52.0 From 3abcfa6e6ffbf33c2b177c120fb67f90345f61a1 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Wed, 7 Oct 2026 10:28:29 +0530 Subject: [PATCH 4/7] ui bug fixers --- .../ERPCore/Controllers/VendorsController.cs | 11 + .../Dtos/Procurement/PurchaseInvoiceDtos.cs | 2 + .../Services/Interfaces/IVendorService.cs | 1 + .../Services/PurchaseInvoicePaymentService.cs | 18 +- .../ERPCore/Services/RequisitionService.cs | 6 +- Backend/ERPCore/Services/VendorService.cs | 20 + Backend/ERPCore/appsettings.Development.json | 3 + Frontend/erp-system/app/apple-icon.png | Bin 0 -> 11795 bytes .../app/dashboard/customers/page.tsx | 93 ++++- Frontend/erp-system/app/dashboard/page.tsx | 20 +- .../purchase-invoices/[id]/page.tsx | 8 +- .../procurement/purchase-invoices/page.tsx | 8 +- .../procurement/purchase-returns/new/page.tsx | 86 ++-- .../procurement/requisitions/new/page.tsx | 8 +- .../app/dashboard/products/[id]/page.tsx | 11 - .../app/dashboard/products/new/page.tsx | 60 ++- .../app/dashboard/receiving/grn/[id]/page.tsx | 58 ++- .../app/dashboard/vendors/[id]/page.tsx | 18 +- .../erp-system/app/dashboard/vendors/page.tsx | 105 +++-- .../app/dashboard/warehouse/[id]/page.tsx | 68 ++- .../app/dashboard/warehouse/page.tsx | 51 ++- Frontend/erp-system/app/favicon.ico | Bin 25931 -> 6481 bytes Frontend/erp-system/app/icon.png | Bin 0 -> 90593 bytes Frontend/erp-system/app/layout.tsx | 4 +- .../customers/EditCustomerDialog.tsx | 253 ++++++++++++ .../invoice-template/InvoicePrintKit.tsx | 286 +++++++++++++ .../PurchaseInvoicePaymentDialog.tsx | 192 +++++---- .../procurement/PurchaseInvoicePrint.tsx | 386 ++++++++++++++++++ .../components/products/BrandPanel.tsx | 22 +- .../components/products/CategoryPanel.tsx | 82 ++-- .../components/products/UomPanel.tsx | 10 +- .../components/sales/SalesInvoicePrint.tsx | 289 ++----------- .../erp-system/components/ui/bar-chart.tsx | 3 +- Frontend/erp-system/components/ui/label.tsx | 17 +- .../components/vendors/EditVendorDialog.tsx | 185 +++++++++ Frontend/erp-system/lib/api/vendors.ts | 8 +- .../erp-system/types/purchase-invoices.ts | 2 + 37 files changed, 1852 insertions(+), 542 deletions(-) create mode 100644 Frontend/erp-system/app/apple-icon.png create mode 100644 Frontend/erp-system/app/icon.png create mode 100644 Frontend/erp-system/components/customers/EditCustomerDialog.tsx create mode 100644 Frontend/erp-system/components/invoice-template/InvoicePrintKit.tsx create mode 100644 Frontend/erp-system/components/procurement/PurchaseInvoicePrint.tsx create mode 100644 Frontend/erp-system/components/vendors/EditVendorDialog.tsx diff --git a/Backend/ERPCore/Controllers/VendorsController.cs b/Backend/ERPCore/Controllers/VendorsController.cs index 29f3175..05199c7 100644 --- a/Backend/ERPCore/Controllers/VendorsController.cs +++ b/Backend/ERPCore/Controllers/VendorsController.cs @@ -65,4 +65,15 @@ public sealed class VendorsController : ApiControllerBase await _vendors.SetStatusAsync(vendorId, request.Status, ct); return NoContent(); } + + [HttpDelete("{vendorId:int}")] + [Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int vendorId, CancellationToken ct) + { + await _vendors.DeleteAsync(vendorId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs index 483b8db..69e293e 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs @@ -31,6 +31,8 @@ public sealed class CreatePurchaseInvoicePaymentRequest [Range(0.01, double.MaxValue)] public decimal Amount { get; set; } [Required] public long GlBankAccountId { get; set; } [StringLength(100)] public string? Reference { get; set; } + /// When the vendor was paid; also the GL entry date. Omitted ⇒ today. Never in the future. + public DateOnly? PaymentDate { get; set; } } public sealed class CreatePurchaseInvoiceRequest diff --git a/Backend/ERPCore/Services/Interfaces/IVendorService.cs b/Backend/ERPCore/Services/Interfaces/IVendorService.cs index bc78b41..75a3077 100644 --- a/Backend/ERPCore/Services/Interfaces/IVendorService.cs +++ b/Backend/ERPCore/Services/Interfaces/IVendorService.cs @@ -13,4 +13,5 @@ public interface IVendorService Task> CreateAsync(CreateVendorRequest request, CancellationToken ct = default); Task> UpdateAsync(int vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int vendorId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int vendorId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs index 66aec8c..52ff97f 100644 --- a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs @@ -59,6 +59,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi var actor = _currentUser.AuditUserId; var now = DateTime.UtcNow; + var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now); var payment = await _uow.ExecuteInTransactionAsync(async token => { @@ -82,7 +83,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi { PurchaseInvoiceId = invoice.PurchaseInvoiceId, Amount = request.Amount, - PaymentDate = now, + PaymentDate = paymentDate, GlBankAccountId = account.AccountId, BankAccountName = account.AccountName, Reference = request.Reference, @@ -101,6 +102,21 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi return Map(payment); } + /// + /// The payment's calendar day (also the GL entry date) and the timestamp stored on it — same + /// rule as . Omitted ⇒ today. One day of slack on + /// "future": the client sends its local date, which runs ahead of UTC for users east of it. + /// Today keeps the actual time of entry; a back-dated payment is stamped at that day's start. + /// + private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now) + { + var today = DateOnly.FromDateTime(now); + var day = requested ?? today; + if (day > today.AddDays(1)) + throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400); + return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + } + public async Task> ListAsync(int purchaseInvoiceId, CancellationToken ct = default) { if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)) diff --git a/Backend/ERPCore/Services/RequisitionService.cs b/Backend/ERPCore/Services/RequisitionService.cs index 36b096c..06dfc5a 100644 --- a/Backend/ERPCore/Services/RequisitionService.cs +++ b/Backend/ERPCore/Services/RequisitionService.cs @@ -66,7 +66,11 @@ public sealed class RequisitionService : IRequisitionService { await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct); var actor = _currentUser.AuditUserId; - var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); + // Branch is optional on a requisition: an unset one falls back to the default branch + // rather than being rejected (this also covers reorder-alert requisitions, which carry none). + var branchId = request.BranchId > 0 + ? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct) + : await _branchConfig.ResolveDefaultBranchIdAsync(ct); var req = await _uow.ExecuteInTransactionAsync(async token => { diff --git a/Backend/ERPCore/Services/VendorService.cs b/Backend/ERPCore/Services/VendorService.cs index 238865a..ca17045 100644 --- a/Backend/ERPCore/Services/VendorService.cs +++ b/Backend/ERPCore/Services/VendorService.cs @@ -120,6 +120,26 @@ public sealed class VendorService : IVendorService await _uow.SaveChangesAsync(ct); } + /// + /// Hard delete. Every FK to vendors (POs, GRNs, invoices, returns, quotations, item + /// default vendor) is Restrict, so a vendor in use is refused with 409 — deactivate it instead. + /// + public async Task DeleteAsync(int vendorId, CancellationToken ct = default) + { + var vendor = await _vendors.GetByIdAsync(vendorId, ct) + ?? throw new NotFoundException($"Vendor {vendorId} was not found."); + + _vendors.Remove(vendor); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This vendor is used by purchase documents or items and cannot be deleted. Deactivate it instead.", 409); + } + } + private static VendorDto Map(Vendor v) => new( v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt); } diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index c80290e..7ff3bb5 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -10,5 +10,8 @@ }, "AuthHex": { "BaseUrl": "http://localhost:5011" + }, + "GeneralLedgerService": { + "BaseUrl": "http://localhost:5200/api/v1/" } } diff --git a/Frontend/erp-system/app/apple-icon.png b/Frontend/erp-system/app/apple-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b6caa7b7b9214e17939901b1e4b63d26bcc3be80 GIT binary patch literal 11795 zcmd6NWmBBb(>3la7JRV~+zGO{d(hzSE{kh$cYe6L1=rxPxP&0V-Ccq^Jo|tDe1N;^ znro`&#Y|VvoasJiI!Z-J1`~}04F(1VQ%+V=4ca&Vuc5#}|BZD?7SIkAEUV)R0|S5b zzXsdlt7ZoSLkS}%DW>j~bDry|t**7wcQf@MxcSSr$hx|Z{S&t;PA43JF=N3jmj!QU zWp?YB<4M{VN_Uvjm`}yD!vx$58AN}17%hBYKbfGpOJ=FteD68TalOnH7+BNh+#3gou%cR6Wh9SqS z<^fgEfH@~|ftd*pRy30vj?+Y0bcDD)oZ7T_KR??2c-myF$EA@3$pM z(k3@Bl#HWw)BZ^-ZJb+D*i~Yv>mOSFG#Xpacud{(UXaY?baeanwGOFzEfufu829=a zL<7aa!ao6A&`GiOLi_z8s7dRsXzwo^6@m#Pn?Itkx}jfz_?C)y#JEuV4#T^aAGNRY zTVx90*6{Kn!8>v2c#HCEH1RCj@kXz)5*7`q)6@{8g1ZnxDRDgZy|3^VHMKolwj7D) z_1K8|1c`|^Ee)9jK;#TF@^@}^lOWxQLqL{80A(2m14WsflSWh{akLL9x!l+#h;Rpw zF_S=hA8)1m0fCd%1KwDd92=Cli&|&&5r%sgeu)1Uj>)f|@kf6k;?;p31op9S3udL? zr(jV-*aVGM>tM&|*Iln_i$x&Io6zd^7WTu;r& z=57tK|A?lM!QHnHW$_ra_D#)U^L{HBX^cPr ze&nD6l2QM}27cuP>y%;sP~65XFn3WE3{Dc$#

JMuJ6K=A#)E4MD}mnOdXFB&qnI z9GII&7Y^8aAL^iu!KCRu2#s7r2B8R;p0#=J7UzsFi!m-H{-op4d=AAGg~<*UlHutW zCeF|i3kbpDOOCk;!VXG6Q_fpUrps$A^O^tpgHXQVl!J3)aUgWWv@E{1hi%Fe)x*=` zoZ_)zZSHTxkmesQRQ4Wl!OMU>4_3l0@Tt{unPC3bUE`*|>L5((IaC}pO*vqq?Co_U zhO$d0?;-BM717Vx$7D_aqkpfKd3mmHINc0$QEyDk?)Q-F>;3locM+c2m!=gK$w}i% zVrInUOcmZ`g+!2LOUu zDf*8qGT<|dsVceaKuVtA@mybYyUEJ*qTRx~4u@H{{jd-Fm?_)2*aA%;##S)c+!$Y$n7-=tqJl7)UOL^%ALvbjdh zD>3ESgdShpm*$qwF&gHncej#@%e%`1e(K!FKruZo(ClIeibu2*V;6u^6$Kj?GQZ0Y zb;U*Nh>i0>o*z6hcv}M{002z=6zk&6B*iN&FEBPgk0^E$x_L0QS>HA!DOjdhyx(_Ft{@5O4 zIpTtYznw}&KOkNR=Ara-y1gpq^L~PvNj;5m&TaU;B0@r7&&C!T?%Oc$yd%|0Sawjq_9xWQK71q}xVdn)GAXy7BzFZft{Yz4 zjGP6n9gG#)v%n>x`>g@7l#PsM$CmQ5o}D4BKWi#?gTqj85?~Fi%vS`qW3F!F{k#RF zDq*&D&^Nxse3hI*`Ux0RL;QEab)=VV7?s?PAbW@aUzZ#>kcH)!XlMd1AqTd5R+vBY zKjmzt4T@L$jDnRLSdY_)Dr@Y12#M`ZYQ6k(S1_I4A5lcDW%P5{?lX))linSwGHPq5 z`$+UQM-ZsmwX+~67T|pDVX8E^i}lkr4kM&k!O^2+{2^W?TbI~mxHZ-VeyXN?x^UuOlnITnsk5vyW_3wl41M>yV)h0cIrIhf!Wj;pq0fG%G&q3Z2)CBm zE-!4{&^)l)ShdqQVtQEkY5t;DJBKW=#>2sTvQ?)b{6QX$t2?iN&!QC{Fh1q&F=^o3 zCGr;_0el+t^f=v=)B0vq?q)^BTo6R*oeRR#cwjpk9LCp~lT4v0q@0HiagW)ZBuZ7D*_{=Su1;ZzSyt@2i1v2WnTpKnJd5 zq$4M$KD`Z^x9X%ZJTD$7&!d(^L_-!#7+wI7y;!uz1VDq08aECLt?O}kculJ1(L=I!u4z&452o0QTV7Y;y;oyv!lD} zbaaxbop0&sP#iF}J8-HVIFFRl!`5bwo@0qx5Nwy5+&el5KRF1u6$E8HZTFE5Wm0Zv zM1&hPJIckTjX7wO{CzogzNvv+jZK3905>?rvha#trt-SZM#_FuPG<57jZyioNC-zj z@pwov)Yjvt1Zmroiz@;u$DQ|}*4u8zU)WF8nDQAU5WJJV9w=M`2XYk!VZm+i^5z?b z`%p|c`JiXVn;xNSmhQkcG1!U$LV;N zAMwmTftk$YLNp%(=_UAC{;kZibki9ZP2XnYe&36x;y$eVZ_Lgd480S?0CpUcK~>nH zDAG}Mb!QFp>sB8Mul(0^*f^6lH|g2phO=!=|4yyW<*>;soJtnhbkE2BdR?t-D)63& z`4a$P$p`2DuC|yNt#`4v5MCRa5DX4uJV~Dr$eg4NSNwP+VKZFaL<~Q5NU6=hg9OzU zK?(pqVizRA)u?5RD8lymsIkUBM6dGwG!tEhpVI;?Oqxsql`v90q&e3P3Y$#%WM8sK z=pPP}UCBcgsq&KVA_hbB4scaSuXujyH}iA;va{&lz)K-`ix-v*tk(cgQEWorP%?*QTxsQW7}2M zJ9cP?Y@{#ft#w>CRX|VfOUu7lL}?^W3J`r@_R}^atriE13h6Vyys>d|a(vb6m$F7A zd@XL(077L$YG<>t?k96lMfmR!YG+JUmPqzik3KVq6QZL#5ive+<~LTIiAx}hc5JD@ z`C}gYeyOt+H)8j6LJIbnBGnJoF8c-JqjldR$7Rh74tVUPDAVcPS)%jcNnNC>{mKXA z0GtFw3P3RYMsasUZ;~>m`Xpvk_FFSO?xjkB?m-xnRdwBc+{yddmp|`IUfPoFjWbIv zhm!kv*^;cVoNVNyi7V5_Z}buj*o%o`em(HYy_o{Lp7X!;vHH=rq*g!#kFHyygB9Ig z?&!Rpr9Yx4JKt@?G0XlzD%lO4>^_Vm7DzL&*q_9sEk?yK>p2xnxiV}+edtlEo(FdrmSfszm@<_dRP+25LMwG%@66KZE57OE8xJtT+n5B+WoNB?ojd@ zO)V8CZ9+YRIg=S=D48y|we?|5>h}^A&25v)S323wFGyv5)F{l=P;~kjwq-$Vm!B_W zrD`n1CLJ#A)q4CYBp0sFkadwd9aRws-V>|wajxLb8^4r!c&MxjR1S*eV8^>gR|b^~ z4p+dAB~Af)IzZ10Q$<&-E>s%Jw>i(M+yTYr9`$Q@d*hL)gpI3I?p6w!f-}WczJQ#e z&$cKsBfU-B-&p5}Tg?j;N)3oqCycofgcm&@fd@_-=T2Yxxr!^DK8}%k>En=*V?ykO z0lZf36Rj0i$m`EYhfAp3$Qh9%wDZhmgV)OWDl{Rk^SrhQ47MYaF6%>D+$4$vqHh4k zI)=mWF=F6Wk*Rh)4f{N7y>USfvKH5J@8Kx#l2a&Q+kPe%)X^Ca&0)oWy{>lmn9rSR zB5&t0UQz6W1$MSSzRI1#3K?0CGT+Cj&?ywP=jPf3?Dmg;>S`u(-k zv6^)CAqy#@zE{S{=Dgadr{z9Mk;D|qoJtm4c^JlhW5kVx37>Ciucp>#5`6fy5Jll; zC&I1*H6!3xWm&u(gyg-;`F5>0%1n)gQMyt|<+c6ed= zfDZ(ud{6hip>Yj{rmri9A|$gsMZIJ|V25T^jmAK51L^slf*B=nP*671!`UrPpz5vL zZAU#eH88Y$%%1&(x3@HtH2U?sA1kfAkVrM(o)leJ@oAq9400`>J<@0>97x!W7Uc7j zKsxdeVM4IYML92a3}2|V!`4~-;W&TV2y>03O`q`()7po#spU+b1pCzbV%xU&{}kqO|X=j5jJd}XPkXQ={lI2>9b_cr!z$8Ggl&8JJ+34(HWQAch* zVCP4@v|}I~o9)|)N)3HthC@HE_cR_u$D#M3Co8B!DFQiy)eyJr zYzZGaca{$QDE+r(RmxHNHQLejmm!YZg@X)FF5yDsoe=tVZQL4@cwQh03i22gqNNnY zPBYlgUzdN#NX|ocCfch_A7@V*y2i)f-W=rk?Vk7W&|oSu`W^1<-?r`skaA=ZxpDbr zRhTh)Z1YbgXG=7r^h>m(%bg$ev6}oOnu4YNF3AfWc~fx6hD{*yEHjP+*S@;#HL#l~ zNF5y2cXT(&Siwhi?R$hyJBaRr3OY%5LIfv{ zLrEGk{SWdc3Tp~LMzHINO6z&kcW98G#-%O_3!h!7<`O*o1jb&RV(0vvM(?@jPm=y$ za+XF)mJJm(eiptM()8!6N(5l;UE4vuy3ex|e8eYdCLsf%b)kAj3w!K_Ab6wwz^$bU z&lT%1jFF?lYt&ReWT1304nyp4*{Sb6MU3LO{6?Pg63#5WBsFx?sJNXdZu!2koMTS3 zicXr0xgNb1*{aKUQ;G@f95OE&k^FrDHR)g@PEPK0|r4 zO~#nrbMc;qzvFmudMfg;K80H8eHFYm_q!;C4ADBVL3UEIm|m!gD6}y`DUka1o)@WG z^p9%+s!t+Q&N44PT@d zIhW`qR@O?CS9+L)%89(#Z3KE>ws%;xC7eQc9&P4yDg%aNHrNZX-FO7asrNNhZRx@$Lr323`Qx@W-GSZus`7^1bHMyZaIHDSCk!M-S`EN1876Q~0R zSXglL!---jX8m$ss9XB!??TNtWr7UI5DI}Wk(+Id-a%~7NF}DMjvZ44RJsnpDzDuw z;tsOHLJuXGyi?EKRZCm|ITk1qL0ZrEu&_8y5+rZCe8)Z-6h&FQRcqHRfo6K!A(}FtWFhZ$y1IE>f5`A{T z`@B7%1rq*^R{acqdwOZYReMWT25fu^i*qLFS<=a>_+YluQLX7)%uY<0j?iFDKFY#I7Rk;!zjZ5-}u1pB5z2 zm3dc$Hy~yWu-9M=4Ur2r`~4e@zZWT_5Ij}rB?h=K(JpNr<>l(~N3`zHD6f&hZ>N4< z2=ApqFGosKx+3HNi6fP}^~;ljNN|8XX(|ML+hYy^0*JqbTir{n2m|0!33BGv!@7Xt z3Q3rXJ|52cF88Un+>F;9Op~w+0?Yl|gO*ZIAAqAp^uLAwlA~HqZ&(11dKq<>#fKol z(U}oGT_=8?FLDT8yUkh0t;GFBO`RL@hk;N!MM%iOjSf(^0{yhP_27g|%j5ZgoBkgP`kZAvvW&T+=7isj_kQGL zIR863{*!HR)crR5(=L01F~3cXOF;W%4BnuBZON23Ep!Y|xT9kP4dACq;3|8?*gd3b z%a$j+!y;ku^_geUmg{NA2>a@*cUii<(SN@#<)>IL&QE(;CRd*ulxLWRD(=NP!L4`tK5caVoIJ< za^g@DqVj*Uo;=(kIz-g-B)ZKhxFjih<^Bj1w+OP$iQChCXpgjqVY_jXP{3r;g~Dr* zd(pAR_s7uX@f?8t+@A#lsnKCgPW@@i&R_3ejF6BZ#nJdmul_5AND?Wj7m=&D zWG&TaEdmm#hn7N^zHIePjNc_WRf*AL%#TZ!Y>2^^BWAaJQ#v(z|Egaqy;xp(?Blq7 z7-f?Y)u2=hL->W2rd;#8l}NRe2Z9D6S8H=`Zqd!bDS`dM6GYf7jer9uODgHadt31| z-?PV^Nz*b+q{EjYGz4pl=xfBM+~wW!sS%T`0grckJ^4RaBI${La}VNK=ez#7Xr(RX zvbb2FLPY&SQD(r0+I|4P8hfn^F=%G!cucT5^ta6nrA)WWd!^lk>|>0$eFhLM=;PCL z^FUL2*}Pyy)OZ!F-_q*O1(f_iF|#|IOxbxZ)#Li61kGL7r5-w<+wbH*@%3_3$X+xE2AZx>jsF-eA?$!_)o2I3P;NNK1LYNOrrL5+;IlnjrKVyCWRs zFf^sJN#VUdI1t{>XJKZZ=#lPxjlI6n`x8*x9nqQ>eYWgwqP5_@Oyt##4;n#f3?fZo zuobNEY0CCbPe^OFC>MTbetVOd;zbZH_rP3W4JA90rHJ4@2~4<~_+`7UBP1o0>r_)M z@&uu3KO~0h0LU-$iXHImuRR+Ul%5T!nCbILyw61^+^cK8K=&e3#~AdQ1ot z%KtgXqPMBh+03>)>7K3IboEx+#L~iY zYD1l)N$5h1)!(2$BuOasd8@pQ3vu(m{g=U*AXdPdGb9N-*Gpmq;6iqCBm6WiyOK=j zf@%%LrVgtdAP zmPrFP2MQCl`q`sPX6x0FD-0Lr zVe`!@8Z|#|U$|=_Cr_lRhm@V>FanKS(hq~uP*w3RbqOcKn>6NN5xQ>7fY>Z?0R)yu zu+4N4dp|i_HAFJPiNM4{vzm4l%^wjkpPW5mX^-75hV=;`n1u;Dkf%fSX0K2cW+LT2 zk>=Tw2V80+a++!WB0XvR(4YFtv;nTnag0WaVCoiWjkv;9@4clGwqsv!4Dn}MZod!f z99vxFP28Lh_!hzj@bev;<+H{}X`tSo?gFBd&CX=O-;X<6DG0UgC%@fM?3+3{SG-gH z9&b$nX3NbqSMRj#<*W{HAOmn|Ol>Dk>UyKwAO7yTqw~yj@>LMmEUIruN93jv;>KmOSPJAltfQ0*)XvENg4IJ%mnMBH_M` zpilAXrp`^l{B+Bwsk;z-;nohCLYSV(B)u;>oF(75=7mw5%d?aor*cNbuLm-VBjGg*lQSk;57p+9BBVvEr2b+z#?GF z{%J7PdOX{D$@(xPo^CPkQ)nnc^$K${OM z`<1850N64F#oGH@8>2WjS5)k;qU*!-plR2=#M+bAJg?3eHUuHYlByJZEhrB;UarVW z6*Rz*N1AWp#{ev1uV2?>g=O0rgk(R~tp}qvJ8y(oeIwZ=73kFHQB_;SR)LX*fz8)$ zj?BIbq>3f7b=vPAWmTy`Wg5AB*Ea?Og+lBA)n|;Xh$(qSkJ~$BO{@p#M9EzRoobeg3vvE`pLAamRN58RSSc;pojPf9~ zIKA8B8x#1XZuzhu<%$B}5`&5_2Uf`OMvEcyl9UtbdPZb-2@?U@s`rolTrvqVHKE*8 zlZ%}VqW1f^)4t^Ph3-?dST=ylE>2d&)QUJpxhY1+uZWAlJIREtZHbMPK!&rcihn!52q~IYE-oD{E%4Ml%Pa^- zO83n7FH>bTDgr7BJGJtAw9bY*1Z`TTb6MQX^olfbfe1{daGT4Jka_JRFDE*r9~_y% zfdf8wGzk0r`WTCz7ld>9D-B{juJ*f#mj)68ced}RG#{Pc_DIA*<=Wz~ScRjm zPH%T8c2hU={CAWoWCZLDs*@*iw4Ld0R0Iw6zFxVT(x$6mY1W2YByMGpF<);PK5tq) z@9p?{fTV2zq#!itOW-dLFfMU%jRb@ZxWHv=^eo9`5$C0u_!}Le76pB0W@Rmw1S5Vl z*MQ%-)K=9TtNFR@uhQEL?e`x){<1g9P*c|ojhNr`PmmkXTx~_J@tIcz`R2ApVjrs} zA(xxm7TAl0HoVdDH}7uM>&zKtWaMp5Bdk^X>IuCRIPiZ%A?z`;U`yYdvLOo_0z-8~JjUS5*mQ zFu=qmy6C(5QZ8`*6D@|o85t!wN;{J#Bc`UVM>lwsEV>9bkB@CPa=Xtu{&d#^remcBWYv-9;9f?YNvP5;u2=|Jp_+mMfE$`z4UP?e*ZG0 zPlpPPQ#OxQwL&b~z*+AA!jj+6V?WDy_mu)r$fq1KU~2^iF-mJtnV_ykkOJr>Z9Zfb zIxprQuSYb|%j=SjCnl=z5Ev2Y!tnR3@o2gf=xWtjqii18i}@T9_z%dg&bAsE-f?LO zS8#F|Ph|(yG#$685$K6L@Aspa_R*;w$%U}KV*ob9$5F5})L4fWZC0{UOKx+q1c0rb z)duu6`fnqRs7#Po7JA&K+~?2bY!2wneTU$F?Iq`DTyi`hBepbuhBy_YJ=Mt^16i;hnr z2t@kPAYH31&#p;VAPy4p?@G={4Mfc+r=P@l<}#s8A@a1<6=>d-b&~Ncd~R-Pv!PQR z?rt6y2j_C~Q@;$=VRPe_Qu9u+o)Cav)tB|*jV zq_vJAT{0}$RO5&!pPAiudYUb+j1dv_#_X|=f}P>^JAxlyiGxfdlo-qjsv8S>C^k^A zgQ`6~@e-{>C&&qTmnLgQ5d+&mN#c61B*6P!9MUjVo*xFwB^p_T!sL!bRV5{;T0{~O z10UUAzR6F~xuZ52u)Z)&P*FI7sx?Nz22J;=4i*WutiFD^OCb@AjMbDt;bzE>Ko}Y<2>ukZ`J<@w|gkYe*)mY#wHW;~@gcDNujb6e4-?%1R!%D$U>r<7{D~RL9C36t9TeW-! z7@C-dgQdP;i0QjK`@w>&fV{@1t@=nWT)m^GMuvovu-y*_jnLUfKN3>2u@dw*Lg!vBcXW&*&Pu@L1o(TeY?;@F zH}%5~soB`Gw+8s@o(=U1nv`N&4vt+QdLQKKM+ecV!H2TK#(n&jEUmOmZttnZu`fu< zi6dab_J^5$ZID}*^2M?L0&T&!K*V2jWzocv=StY71rH}Hm(l= z>8ds;2%=WVOnv#id7HZ7;CI;s<;*yU8}IBhX?jlgLyTFR;^J65qP-y7A@WGNy?=IbEad#`2bHw_Y*~9?v1SB3=JrGJx1n;s-0{<4??)+x z$EeH*N9H%Q`PAyyA6|l{Bi;3?L{LrJEV&Ww0VqzU3&iC~NDyQPYu8xuW`=XcIY`Nn zii}kyOfEtThiGagxnlEd>5O9SY}fx3y;0DZ2j1_so`;?5Yr(GRQClrqrOVSyKBq%D8O=3j~s(?icnekrd-C0A?`2+=DHx>4w zX3YGjJNx`qo{$rD<2mYBU{}qh@4w+y8}!>+sF1#@<^mPX4lK2J-x0}S9IlK9#QtRH z4ChWJVC!u)x4HvCE{v>v0v5=llIv0K*jpW;|5z&OMT4CEuLu{n(6pObrr}Lw{OQ0T zb2`z?MznK!JKjXOCk>Q}vr!%~rR)3Ge73YggkkvTAqlhy%+KpWLi7QM*-LtBk)P0H zLV+Pm4Z>r~FIf3q0IkW`D?P!4--pj|(X%9WC$pz3Gh8E#-aBvWXi96PrbI=AM!`6dXH9WR278Y_?j zz0gVVXT6(F=`$RVeeKshTFGeG@5w;%;~^9U2*YG(n8>ty3VH^`V=Rw3%~CntuQIr@ zp$g_dIaZoBh(p5%dQyprh`RpmmX^3B0>oY%N-gjU3wR9^NuiSI$*p$^il;GEW)muA z1X<7vch)p(azsMDqWsjy7X~J%_jnkXpwCSo+sW>C#^U(ji4Hbvrol^ucnsL{znY^< z#wRTP<4nS6Bh("B2C") const [phone, setPhone] = useState("") const [email, setEmail] = useState("") + const [addressLine1, setAddressLine1] = useState("") + const [addressLine2, setAddressLine2] = useState("") + const [city, setCity] = useState("") + const [country, setCountry] = useState("") const [creditLimit, setCreditLimit] = useState("") const [creditDays, setCreditDays] = useState("") const [errors, setErrors] = useState>({}) @@ -72,6 +77,8 @@ export default function CustomersPage() { // customer's own code (mirrors the vendor create dialog). const [allCustomerCodes, setAllCustomerCodes] = useState([]) const [actionPendingId, setActionPendingId] = useState(null) + // The customer whose edit popup is open. + const [editingId, setEditingId] = useState(null) const [showMoreLoading, setShowMoreLoading] = useState(false) const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : "" @@ -135,6 +142,10 @@ export default function CustomersPage() { setNewCustomerType("B2C") setPhone("") setEmail("") + setAddressLine1("") + setAddressLine2("") + setCity("") + setCountry("") setCreditLimit("") setCreditDays("") setBranchFilterId(null) @@ -156,6 +167,10 @@ export default function CustomersPage() { name, phone: phone || null, email: email || null, + addressLine1: addressLine1 || null, + addressLine2: addressLine2 || null, + city: city || null, + country: country || null, creditLimit: Number(creditLimit) || 0, creditDays: Number(creditDays) || 0, branchId: effectiveBranchId ?? 0, @@ -187,6 +202,17 @@ export default function CustomersPage() { } } + /** + * Row click → profile page. React bubbles events out of portals along the component tree, + * so a click on the delete confirmation (portaled to ) still reaches the row; only + * navigate for events whose DOM target really is inside the row and not an action cell. + */ + function openRow(e: React.MouseEvent | React.KeyboardEvent, customerId: number) { + const target = e.target as HTMLElement + if (!e.currentTarget.contains(target) || target.closest("[data-no-row-nav]")) return + router.push(`/dashboard/customers/${customerId}`) + } + const hasFilters = query.length > 0 || status !== "All" || customerType !== "All" return ( @@ -213,7 +239,7 @@ export default function CustomersPage() { - Name + Name setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} /> @@ -243,6 +269,24 @@ export default function CustomersPage() { setEmail(e.target.value)} placeholder="customer@example.com" />

+ + Address line 1 (optional) + setAddressLine1(e.target.value)} placeholder="No. 12, Galle Road" /> + + + Address line 2 (optional) + setAddressLine2(e.target.value)} placeholder="Wellawatte" /> + +
+ + City (optional) + setCity(e.target.value)} placeholder="Colombo" /> + + + Country (optional) + setCountry(e.target.value)} placeholder="Sri Lanka" /> + +
Credit limit (LKR) @@ -255,7 +299,7 @@ export default function CustomersPage() {
{showBranchPicker && ( - Branch + Branch value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}> @@ -357,12 +401,9 @@ export default function CustomersPage() { key={c.customerId} role="link" tabIndex={0} - onClick={(e) => { - if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - router.push(`/dashboard/customers/${c.customerId}`) - }} + onClick={(e) => openRow(e, c.customerId)} onKeyDown={(e) => { - if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`) + if (e.key === "Enter") openRow(e, c.customerId) }} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" > @@ -398,14 +439,15 @@ export default function CustomersPage() {
- setEditingId(c.customerId)} > - + { - if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - router.push(`/dashboard/customers/${c.customerId}`) - }} + onClick={(e) => openRow(e, c.customerId)} > @@ -497,14 +536,15 @@ export default function CustomersPage() { > - setEditingId(c.customerId)} > - + )} + + {editingId !== null && ( + setEditingId(null)} + onSaved={(saved) => { + // The code is editable here — swap it so later creates de-dupe against the new one. + const oldCode = customers?.find((c) => c.customerId === saved.customerId)?.customerCode + setAllCustomerCodes((codes) => [...codes.filter((c) => c !== oldCode), saved.customerCode]) + setEditingId(null) + load() + }} + /> + )}
) } diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index 9e22166..f00c9e9 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -24,6 +24,7 @@ import { DashboardStats } from "@/types/dashboard" import { LedgerEntry } from "@/types/stock" import { Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" +import { useIsMobile } from "@/hooks/use-mobile" import { StatCard } from "@/components/ui/stat-card" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" @@ -33,6 +34,8 @@ import BarChart from "@/components/ui/bar-chart" import LineChart from "@/components/ui/line-chart" const TREND_DAYS = 14 +/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */ +const CHART_MIN_SLOTS = 5 function isoDate(d: Date) { return d.toISOString().slice(0, 10) @@ -114,6 +117,13 @@ export default function DashboardPage() { } }, [trend]) + // Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS + // warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep + // the bars at the start. Skipped on mobile, where the bars should stretch to fill the card. + const isMobile = useIsMobile() + const valuationRows = stats?.stockValuationByWarehouse ?? [] + const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length) + const loaded = stats && movements && trend && warehouses return ( @@ -172,16 +182,18 @@ export default function DashboardPage() { (stats.stockValuationByWarehouse ?? []).length > 0 ? (
warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` - )} + labels={[ + ...valuationRows.map((w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`), + ...Array(valuationPad).fill(""), + ]} datasets={[ { label: "Stock Value (LKR)", - data: stats.stockValuationByWarehouse.map((w) => w.total), + data: [...valuationRows.map((w) => w.total), ...Array(valuationPad).fill(null)], backgroundColor: "#E11D48", }, ]} + barPercentage={0.6} />
) : ( diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx index d834e37..1bc88b2 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx @@ -294,7 +294,13 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise< - + router.push("/dashboard/receiving/grn")} + /> ) } diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx index b4c4e91..1998a65 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx @@ -433,7 +433,13 @@ export default function PurchaseInvoicesPage() { )} - + router.push("/dashboard/receiving/grn")} + /> ) } diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx index 22ee546..896c151 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -9,6 +9,8 @@ import { purchaseReturnsApi } from "@/lib/api/purchase-returns" import { grnsApi } from "@/lib/api/grns" import { reasonCodesApi } from "@/lib/api/reason-codes" import { itemsApi } from "@/lib/api/items" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" import { validateReturnLine } from "@/lib/validations/procurement" import { cn } from "@/lib/utils" @@ -23,11 +25,17 @@ import { Label } from "@/components/ui/label" import { Checkbox } from "@/components/ui/checkbox" import { FieldError } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Combobox } from "@/components/ui/combobox" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Skeleton } from "@/components/ui/skeleton" import { toast } from "@/components/ui/toast" import { HoldStatusBadge } from "@/components/receiving/status-badges" +// The stock checkbox (16px, faint border) and qty box are easy to miss on this form, so both are +// enlarged and given a solid border here. +const LINE_CHECKBOX = "size-5 shrink-0 border-2 border-muted-foreground/60" +const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background text-base font-medium md:text-base" + interface LineState { selected: boolean qty: string @@ -42,6 +50,8 @@ function NewPurchaseReturnContent() { const [grns, setGrns] = useState(null) const [items, setItems] = useState([]) const [reasonCodes, setReasonCodes] = useState([]) + const [vendorNames, setVendorNames] = useState>(new Map()) + const [warehouseNames, setWarehouseNames] = useState>(new Map()) const [loadError, setLoadError] = useState(null) const [grnId, setGrnId] = useState(presetGrnId) @@ -62,8 +72,18 @@ function NewPurchaseReturnContent() { setReasonCodes(rc.items) }) .catch((err) => setLoadError(errorMessage(err))) + + // Names only make the GRN picker searchable by vendor/warehouse — if these fail the + // labels fall back to "#id", so they don't block the form. + vendorsApi.list({ pageSize: 200 }).then((r) => setVendorNames(new Map(r.items.map((v) => [v.vendorId, v.name])))).catch(() => {}) + warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouseNames(new Map(r.items.map((w) => [w.warehouseId, w.name])))).catch(() => {}) }, []) + const grnOptions = (grns ?? []).map((g) => ({ + value: g.grnId, + label: `${g.docNo} — ${vendorNames.get(g.vendorId) ?? `Vendor #${g.vendorId}`}, ${warehouseNames.get(g.warehouseId) ?? `Warehouse #${g.warehouseId}`}`, + })) + const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null useEffect(() => { @@ -87,14 +107,18 @@ function NewPurchaseReturnContent() { } function toggleLine(line: GrnLine) { - setLineState((prev) => ({ - ...prev, - [line.grnLineId]: { selected: !prev[line.grnLineId]?.selected, qty: prev[line.grnLineId]?.qty || String(line.qty) }, - })) + setLineState((prev) => { + const selected = !prev[line.grnLineId]?.selected + // Ticking pre-fills the full received qty; unticking clears it, so a qty is only ever + // shown on lines that will actually be returned. + return { ...prev, [line.grnLineId]: { selected, qty: selected ? prev[line.grnLineId]?.qty || String(line.qty) : "" } } + }) } + // Typing a quantity ticks the line (and clearing it / entering 0 unticks it), so the qty box + // can stay editable instead of sitting greyed-out until the checkbox is found and ticked. function setQty(grnLineId: number, qty: string) { - setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty } })) + setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } })) } async function handleSubmit() { @@ -173,18 +197,15 @@ function NewPurchaseReturnContent() {
- value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}> - - - - - {(grns ?? []).map((g) => ( - - {g.docNo} — Vendor #{g.vendorId}, Warehouse #{g.warehouseId} - - ))} - - + + items={grnOptions} + value={grnId} + onValueChange={setGrnId} + disabled={!!presetGrnId} + placeholder="Search GRN — number, vendor or warehouse…" + emptyText="No confirmed GRNs match." + className="h-12 text-base" + />
@@ -218,9 +239,12 @@ function NewPurchaseReturnContent() { const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } const errors = lineErrors[line.grnLineId] ?? {} return ( -
-
@@ -509,6 +530,22 @@ export default function VendorsPage() { )} )} + + {editingId !== null && ( + setEditingId(null)} + onSaved={(saved) => { + // A renamed vendor gets a regenerated code — swap it so later creates de-dupe correctly. + const oldCode = vendors?.find((v) => v.vendorId === saved.vendorId)?.code + setAllVendorCodes((codes) => [...codes.filter((c) => c !== oldCode), saved.code]) + setEditingId(null) + load() + }} + /> + )}
) } diff --git a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx index 09919bd..e92dce7 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx @@ -10,6 +10,7 @@ import { itemsApi } from "@/lib/api/items" import { errorMessage, fieldErrors } from "@/lib/error-map" import { Bin, ItemListItem, Warehouse } from "@/types/master-data" import { LedgerEntry, OnHand } from "@/types/stock" +import { useIsMobile } from "@/hooks/use-mobile" import { useAuth } from "@/components/auth/AuthProvider" import { Button } from "@/components/ui/button" @@ -31,6 +32,8 @@ import BarChart from "@/components/ui/bar-chart" /** Charts stay legible with a lot of items only by capping to the top N by on-hand qty. */ const CHART_ROW_LIMIT = 15 +/** Fewest bar slots the charts' axis spans, so a couple of bars sit at the start (see padSlots). */ +const CHART_MIN_SLOTS = 5 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] @@ -120,8 +123,18 @@ export default function WarehouseDetailPage() { const stockRows = useMemo(() => (stock ?? []).slice().sort((a, b) => b.onHand - a.onHand), [stock]) const totalOnHand = stockRows.reduce((sum, r) => sum + r.onHand, 0) + // Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS bars, + // Chart.js would center them across the full width. Trailing blank slots keep the bars at the + // start. Skipped on mobile, where the bars should stretch to fill the narrower card instead. + const isMobile = useIsMobile() + const padSlots = (count: number) => (isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - count)) + const chartRows = stockRows.slice(0, CHART_ROW_LIMIT) - const chartLabels = chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`) + const chartPad = padSlots(chartRows.length) + const chartLabels = [ + ...chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`), + ...Array(chartPad).fill(""), + ] const movementByItem = useMemo(() => { const map = new Map() @@ -137,12 +150,24 @@ export default function WarehouseDetailPage() { }, [ledger]) const movementChartRows = movementByItem.slice(0, CHART_ROW_LIMIT) - const movementChartLabels = movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`) + const movementChartPad = padSlots(movementChartRows.length) + const movementChartLabels = [ + ...movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`), + ...Array(movementChartPad).fill(""), + ] const pagedBins = bins?.slice((binsPage - 1) * binsPageSize, binsPage * binsPageSize) ?? [] const pagedStockRows = stockRows.slice((stockPage - 1) * stockPageSize, stockPage * stockPageSize) const pagedMovementRows = movementByItem.slice((movementPage - 1) * movementPageSize, movementPage * movementPageSize) + /** Closes the new-bin dialog and clears whatever was typed, so the next open starts blank. */ + function closeCreate() { + setOpen(false) + setCode("") + setBinType("") + setErrors({}) + } + async function handleCreate() { const nextErrors: Record = {} if (!code.trim()) nextErrors.code = "Bin code is required" @@ -153,10 +178,7 @@ export default function WarehouseDetailPage() { try { const bin = await warehousesApi.createBin(warehouseId, { code, binType: binType || null }) toast.success("Bin created", bin.code) - setOpen(false) - setCode("") - setBinType("") - setErrors({}) + closeCreate() loadBins() } catch (err) { const fe = fieldErrors(err) @@ -198,7 +220,7 @@ export default function WarehouseDetailPage() { )}
- + (v ? setOpen(true) : closeCreate())}> @@ -214,8 +236,8 @@ export default function WarehouseDetailPage() { - Code - setCode(e.target.value)} placeholder="A-01-01" aria-invalid={!!errors.code} /> + Code + { setCode(e.target.value); setErrors({}) }} placeholder="A-01-01" aria-invalid={!!errors.code} /> @@ -224,7 +246,7 @@ export default function WarehouseDetailPage() {
-
@@ -451,9 +482,18 @@ export default function WarehouseDetailPage() { r.in), backgroundColor: "rgba(34,197,94,0.6)" }, - { label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "rgba(239,68,68,0.6)" }, + { + label: "In", + data: [...movementChartRows.map((r) => r.in), ...Array(movementChartPad).fill(null)], + backgroundColor: "rgba(34,197,94,0.6)", + }, + { + label: "Out", + data: [...movementChartRows.map((r) => r.out), ...Array(movementChartPad).fill(null)], + backgroundColor: "rgba(239,68,68,0.6)", + }, ]} + barPercentage={0.6} /> diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx index a9eef3c..721bd5c 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx @@ -126,10 +126,30 @@ export default function WarehousesPage() { return () => clearTimeout(t) }, [search]) + /** State updater that drops one field's error once the user starts editing that field. */ + function dropError(key: string) { + return (prev: Record) => { + if (!prev[key]) return prev + const next = { ...prev } + delete next[key] + return next + } + } + function binCount(warehouseId: number) { return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length } + /** Closes the create dialog and clears whatever was typed, so the next open starts blank. */ + function closeCreate() { + setOpen(false) + setName("") + setAddress("") + setPhone("") + setBranchId(0) + setErrors({}) + } + async function handleCreate() { const nextErrors: Record = {} if (!name.trim()) nextErrors.name = "Warehouse name is required" @@ -141,12 +161,7 @@ export default function WarehousesPage() { try { const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId }) toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`) - setOpen(false) - setName("") - setAddress("") - setPhone("") - setBranchId(0) - setErrors({}) + closeCreate() load() } catch (err) { // A 409 here means another creation raced ours for the same generated code — the @@ -219,7 +234,7 @@ export default function WarehousesPage() {

Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).

- + (v ? setOpen(true) : closeCreate())}> @@ -235,8 +250,8 @@ export default function WarehousesPage() { - Name - setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} /> + Name + { setName(e.target.value); setErrors(dropError("name")) }} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} /> @@ -253,8 +268,8 @@ export default function WarehousesPage() { {warehouseBranchingEnabled && ( - Branch - { setBranchId(v ? Number(v) : 0); setErrors(dropError("branchId")) }}> {branches.map((b) => {b.name})} @@ -263,7 +278,7 @@ export default function WarehousesPage() { )}
- + +
+ +
+ ) +} diff --git a/Frontend/erp-system/components/invoice-template/InvoicePrintKit.tsx b/Frontend/erp-system/components/invoice-template/InvoicePrintKit.tsx new file mode 100644 index 0000000..443a2b4 --- /dev/null +++ b/Frontend/erp-system/components/invoice-template/InvoicePrintKit.tsx @@ -0,0 +1,286 @@ +"use client" + +import { ReactNode, useRef, useState, useSyncExternalStore } from "react" +import { createPortal } from "react-dom" +import { Download, Loader2, Printer, Share2 } from "lucide-react" + +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +/* + * Shared plumbing for the printable invoice previews (sales and purchase): paper sizes, the + * sheet's building blocks, PDF export, and the preview frame with its Paper size / Share / + * Download / Print bar. Each invoice type only supplies its own A4 and receipt layouts. + */ + +// Deep-red accent from the reference layout — used for the heading underlines and header rule. +export const ACCENT = "#8b1c24" + +export type PaperSize = "A4" | "80mm" | "58mm" +export type ReceiptWidth = Exclude + +const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [ + { value: "A4", label: "A4", hint: "210 × 297 mm" }, + { value: "80mm", label: "80mm receipt", hint: "Thermal roll" }, + { value: "58mm", label: "58mm receipt", hint: "Small thermal roll" }, +] + +/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */ +const PAGE_RULES: Record = { + A4: "@page { size: A4; margin: 12mm; }", + "80mm": "@page { size: 80mm auto; margin: 3mm; }", + "58mm": "@page { size: 58mm auto; margin: 2mm; }", +} + +/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */ +export const RECEIPT_SHEET_CLASS: Record = { + "80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]", + "58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]", +} + +/** Screen chrome for the on-screen A4 page; in print the @page margin does the spacing. */ +export const A4_SHEET_CLASS = + "invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0" + +/** Screen chrome for a receipt roll; combine with RECEIPT_SHEET_CLASS[width]. */ +export const RECEIPT_SHEET_BASE_CLASS = + "invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0" + +export const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const + +export function money(n: number) { + return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + +export function qty(n: number) { + return n.toLocaleString(undefined, { maximumFractionDigits: 3 }) +} + +/** dd/MM/yyyy, as on the reference invoice. */ +export function formatDate(value: string | Date) { + const d = new Date(value) + return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}` +} + +/** Section heading with the short accent underline. */ +export function SectionTitle({ children }: { children: ReactNode }) { + return ( +
+

{children}

+
+
+ ) +} + +/** Dashed divider used between receipt sections. */ +export function ReceiptRule() { + return
+} + +export function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { + return ( +
+ {label} + {value} +
+ ) +} + +/** Terms + footer note lines from the invoice template, blank lines dropped. */ +export function templateNotes(termsAndConditions?: string | null, footerNote?: string | null): string[] { + return [...(termsAndConditions ?? "").split("\n"), ...(footerNote ?? "").split("\n")].map((l) => l.trim()).filter(Boolean) +} + +const subscribeNoop = () => () => {} + +/** True only on the client — the print copy is portalled into document.body. */ +function useIsClient() { + return useSyncExternalStore(subscribeNoop, () => true, () => false) +} + +/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */ +const PAPER_WIDTH_MM: Record = { A4: 210, "80mm": 80, "58mm": 58 } +const A4_HEIGHT_MM = 297 + +/** + * Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured + * as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split + * across as many 297mm pages as needed; a receipt is one page as long as the receipt itself. + * Both libraries are loaded on demand. + */ +async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise { + const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")]) + // The sheet's drop shadow / ring are screen decoration only. + const dataUrl = await toPng(sheet, { + pixelRatio: 2, + backgroundColor: "#ffffff", + style: { boxShadow: "none", margin: "0" }, + // A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF. + imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + }) + const img = new Image() + img.src = dataUrl + await img.decode() + + const widthMm = PAPER_WIDTH_MM[size] + const imgHeightMm = (img.height * widthMm) / img.width + if (size !== "A4") { + const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] }) + pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm) + return pdf.output("blob") + } + const pdf = new jsPDF({ unit: "mm", format: "a4" }) + // Same image on every page, shifted up one page-height each time; the page box crops the rest. + for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) { + if (page > 0) pdf.addPage() + pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm) + } + return pdf.output("blob") +} + +function saveBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + a.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +/** + * Invoice preview + paper-size bar underneath + Share / Download / Print. Printing uses a copy of + * the sheet portalled straight into (`.invoice-print-root`, see globals.css) so the printout + * is just the invoice, whether this is rendered in a page or inside a dialog. + */ +export function InvoicePreviewFrame({ + renderSheet, + error, + docNo, + shareTitle, + sharePath, + className, +}: { + /** The invoice laid out for a paper size, or null while it is still loading. */ + renderSheet: ((size: PaperSize) => ReactNode) | null + error: string | null + /** Used for the PDF file name. */ + docNo: string + shareTitle: string + /** App path of a page showing this invoice — shared/copied where the PDF itself can't be. */ + sharePath: string + className?: string +}) { + const [paperSize, setPaperSize] = useState("A4") + const isClient = useIsClient() + const previewRef = useRef(null) + const [busy, setBusy] = useState<"download" | "share" | null>(null) + + const fileName = `${docNo}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf` + + async function makePdf(): Promise { + const sheet = previewRef.current?.querySelector(".invoice-sheet") + if (!sheet) throw new Error("The invoice is still loading.") + return buildInvoicePdf(sheet, paperSize) + } + + async function handleDownload() { + setBusy("download") + try { + saveBlob(await makePdf(), fileName) + } catch (err) { + toast.error("Could not create the PDF", errorMessage(err)) + } finally { + setBusy(null) + } + } + + // Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge); + // otherwise shares — or, failing that, copies — a link to this invoice's page. + async function handleShare() { + const link = `${window.location.origin}${sharePath}` + setBusy("share") + try { + const file = new File([await makePdf()], fileName, { type: "application/pdf" }) + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file], title: shareTitle, text: shareTitle }) + } else if (navigator.share) { + await navigator.share({ title: shareTitle, text: shareTitle, url: link }) + } else { + await navigator.clipboard.writeText(link) + toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.") + } + } catch (err) { + // Closing the share sheet isn't an error. + if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err)) + } finally { + setBusy(null) + } + } + + if (error && !renderSheet) { + return
{error}
+ } + + return ( +
+ {/* Preview window — scrolls so the page keeps its true size. */} +
+ {renderSheet ? renderSheet(paperSize) : } +
+ + {/* Paper size selection, under the invoice. */} +
+
+ + + value={paperSize} + onValueChange={(v) => v && setPaperSize(v)} + items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))} + > + + + + + {PAPER_SIZES.map((s) => ( + + + {s.label} + {s.hint} + + + ))} + + +
+
+ + + +
+
+ + {isClient && + renderSheet && + createPortal( +
+ + {renderSheet(paperSize)} +
, + document.body + )} +
+ ) +} diff --git a/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx b/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx index fcef274..f623de8 100644 --- a/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx +++ b/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx @@ -13,8 +13,8 @@ import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { toast } from "@/components/ui/toast" +import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor" interface PurchaseInvoicePaymentDialogProps { // PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog @@ -24,23 +24,34 @@ interface PurchaseInvoicePaymentDialogProps { invoice: PurchaseInvoiceSummary | null open: boolean onOpenChange: (open: boolean) => void - /** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether - * it started that way or this dialog auto-posted it first. */ + /** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever + * succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */ onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void + /** Called once, after the dialog closes, when this submit cleared the whole balance. */ + onFullyPaid?: () => void } -/** Record a vendor payment against a Purchase Invoice's balance — full or partial, into an - * existing GL cash/bank account. A Draft invoice is posted first (the backend only accepts - * payments against a Posted invoice) so this doubles as "confirm and pay in one step" from a - * list row; an already-Posted invoice skips straight to payment. Modeled on - * SalesInvoicePaymentDialog's "pick an account, submit" shape. */ -export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: PurchaseInvoicePaymentDialogProps) { +/** Record a vendor payment against a Purchase Invoice's balance — full or partial, split across + * one or more payment lines (e.g. part cash, part bank), each from an existing GL cash/bank + * account. Same shape as SalesInvoicePaymentDialog: each line is posted as its own payment (the + * backend already allows several installments). A Draft invoice is posted first (the backend only + * accepts payments against a Posted invoice) so this doubles as "confirm and pay in one step" from + * a list row. If a line fails part-way, the lines already recorded are removed from the form and + * the rest stay for a retry. */ +export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid, onFullyPaid }: PurchaseInvoicePaymentDialogProps) { const [accounts, setAccounts] = useState(null) - const [glBankAccountId, setGlBankAccountId] = useState("") - const [amount, setAmount] = useState("") + const [lines, setLines] = useState([]) const [reference, setReference] = useState("") + const [paymentDate, setPaymentDate] = useState(localToday) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + // What this dialog has already done to the invoice in this session — the `invoice` prop isn't + // guaranteed to be refreshed by the parent between a partial failure and the retry. + const [postedHere, setPostedHere] = useState(false) + const [paidHere, setPaidHere] = useState(0) + // Balance as it stood when the dialog opened. The parent pages apply each `onPaid` to the + // invoice they pass in, so subtracting `paidHere` from the live prop would count it twice. + const [openingBalance, setOpeningBalance] = useState(0) // Reset the form fields for a fresh open (or a different invoice) during render — same // "adjust state while rendering" pattern as SalesInvoicePaymentDialog, not inside an effect. @@ -48,10 +59,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa const [resetFor, setResetFor] = useState(null) if (openKey !== null && resetFor !== openKey) { setResetFor(openKey) - setAmount(invoice!.totals.balanceAmount.toFixed(2)) - setGlBankAccountId("") + setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }]) setReference("") + setPaymentDate(localToday()) setErrors({}) + setPostedHere(false) + setPaidHere(0) + setOpeningBalance(invoice!.totals.balanceAmount) } useEffect(() => { @@ -61,98 +75,120 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa if (!invoice) return null + const isDraft = invoice.status === "Draft" && !postedHere + const balance = round2(openingBalance - paidHere) + async function submit() { if (!invoice) return - const nextErrors: Record = {} - const amountNum = Number(amount) - if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay from" - if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount" - else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})` + const nextErrors = validatePaymentLines(lines, balance) + if (!paymentDate) nextErrors.paymentDate = "Select the payment date" + else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future" setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) - try { - if (invoice.status === "Draft") { - try { - await purchaseInvoicesApi.post(invoice.purchaseInvoiceId) - } catch (err) { - toast.error("Could not confirm invoice", errorMessage(err)) - return - } + if (isDraft) { + try { + await purchaseInvoicesApi.post(invoice.purchaseInvoiceId) + setPostedHere(true) + } catch (err) { + toast.error("Could not confirm invoice", errorMessage(err)) + setSubmitting(false) + return } + } - const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, { - amount: amountNum, - glBankAccountId: Number(glBankAccountId), - reference: reference || undefined, - }) - const fullyPaid = amountNum >= invoice.totals.balanceAmount + // One payment per line, in order. On a failure, stop: drop the recorded lines from the form and + // keep the rest so the user can fix and retry without double-paying. + const recorded: PurchaseInvoicePayment[] = [] + let failure: unknown = null + for (const line of lines) { + try { + const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, { + amount: Number(line.amount), + glBankAccountId: Number(line.glBankAccountId), + reference: reference || undefined, + paymentDate, + }) + recorded.push(payment) + onPaid(payment, "Posted") + } catch (err) { + failure = err + break + } + } + setSubmitting(false) + + const recordedTotal = round2(recorded.reduce((sum, p) => sum + p.amount, 0)) + if (failure === null) { + const journals = recorded.map((p) => p.glJournalNo ?? "—").join(", ") + const fullyPaid = recordedTotal >= balance toast.success( invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded", - `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.` + `${formatAmount(recordedTotal)} posted to the ledger (${journals}) — ${fullyPaid ? "fully paid" : "partially paid"}.` ) - onPaid(payment, "Posted") onOpenChange(false) - } catch (err) { - toast.error( - invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment", - errorMessage(err) - ) - } finally { - setSubmitting(false) + if (fullyPaid) onFullyPaid?.() + return } + + setPaidHere((p) => round2(p + recordedTotal)) + setLines((prev) => prev.slice(recorded.length)) + toast.error( + recorded.length > 0 + ? `${recorded.length} of ${lines.length} payment lines recorded (${formatAmount(recordedTotal)}); the rest could not be` + : invoice.status === "Draft" + ? "Invoice was confirmed, but the payment could not be recorded" + : "Could not record payment", + errorMessage(failure) + ) } return ( - + - {invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo} + {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}

- {invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "} - Balance due: {formatAmount(invoice.totals.balanceAmount)} + {isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "} + Balance due: {formatAmount(balance)}

- - Pay from - value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}> - - - - - {(accounts ?? []).map((a) => ( - - {a.accountName} ({a.accountType}) - - ))} - - - - +
+ + Invoice no. + + - - Amount - setAmount(e.target.value)} - aria-invalid={!!errors.amount} - /> - - + + Payment date + setPaymentDate(e.target.value)} + aria-invalid={!!errors.paymentDate} + /> + + +
Reference (optional) setReference(e.target.value)} /> + +
@@ -160,11 +196,7 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa Cancel
diff --git a/Frontend/erp-system/components/procurement/PurchaseInvoicePrint.tsx b/Frontend/erp-system/components/procurement/PurchaseInvoicePrint.tsx new file mode 100644 index 0000000..1bfb559 --- /dev/null +++ b/Frontend/erp-system/components/procurement/PurchaseInvoicePrint.tsx @@ -0,0 +1,386 @@ +"use client" + +import { ReactNode, useEffect, useState } from "react" + +import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" +import { vendorsApi } from "@/lib/api/vendors" +import { companyProfileApi } from "@/lib/api/company-profile" +import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" +import { + A4_SHEET_CLASS, + ACCENT, + formatDate, + InvoicePreviewFrame, + money, + PaperSize, + printColorExact, + RECEIPT_SHEET_BASE_CLASS, + RECEIPT_SHEET_CLASS, + ReceiptRow, + ReceiptRule, + ReceiptWidth, + SectionTitle, + templateNotes, +} from "@/components/invoice-template/InvoicePrintKit" +import { CompanyProfile } from "@/types/company-profile" +import { InvoiceTemplateConfig } from "@/types/invoice-template" +import { Vendor } from "@/types/master-data" +import { PurchaseInvoice, PurchaseInvoicePayment } from "@/types/purchase-invoices" + +/* + * Printable purchase invoice (vendor bill) in the same three paper sizes as the sales invoice — + * A4, 80mm and 58mm receipt — sharing its preview frame, PDF export and print plumbing. The + * lines are the GRNs the bill covers; "From" is the vendor and "Bill To" is our company. + */ + +/** Everything both layouts render, resolved once. */ +interface PurchaseInvoiceDoc { + invoice: PurchaseInvoice + vendor: Vendor | null + payments: PurchaseInvoicePayment[] + company: CompanyProfile | null + template: InvoiceTemplateConfig | null + notes: string[] + showLogo: boolean +} + +function A4Sheet({ invoice, vendor, payments, company, template, notes, showLogo }: PurchaseInvoiceDoc) { + const t = invoice.totals + return ( +
+ {/* Title + meta */} +
+
+

PURCHASE INVOICE

+
+
Invoice No.
+
{invoice.docNo}
+
Vendor Invoice No.
+
{invoice.vendorInvoiceNo}
+
Invoice Date
+
{formatDate(invoice.vendorInvoiceDate)}
+ {invoice.status !== "Posted" && ( + <> +
Status
+
{invoice.status.toUpperCase()}
+ + )} +
+
+ {showLogo && ( + // eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed + {company!.legalName} + )} +
+ + {/* Header rule: heavy dark bar over a thin accent line */} +
+
+
+
+ + {/* From (vendor) / Bill To (us) */} +
+
+ From +
+

{invoice.vendorSnapshotName}

+ {vendor?.code &&

Vendor code: {vendor.code}

} +
+ {invoice.vendorSnapshotTaxNo &&

Tax No: {invoice.vendorSnapshotTaxNo}

} + {vendor?.terms &&

Terms: {vendor.terms}

} +
+
+
+ +
+ Bill To + {company ? ( +
+

{company.tradeName || company.legalName}

+

{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}

+

{[company.city, company.country].filter(Boolean).join(", ")}

+
+ {company.phone &&

{company.phone}

} + {company.email &&

{company.email}

} + {template?.showTaxRegNo && company.taxRegistrationNo &&

Tax Reg No: {company.taxRegistrationNo}

} +
+
+ ) : ( +

Company details aren't set up yet (Settings → Company Profile).

+ )} +
+
+ + {/* GRN lines */} +
+ Goods Received + + + + + + + + + + + + {invoice.lines.map((line) => ( + + + + + + + + ))} + +
GRNReceivedSubtotalVATAmount
{line.grnDocNo}{formatDate(line.grnPostedAt)}{money(line.subtotal)}{line.vatTotal > 0 ? money(line.vatTotal) : "—"}{money(line.lineTotal)}
+
+ + {/* Notes / totals */} +
+
+ {notes.length > 0 && ( + <> + Notes / Terms +
    + {notes.map((note, i) => ( +
  • {note}
  • + ))} +
+ + )} +
+ +
+
+
Subtotal{money(t.subtotal)}
+ {t.vatTotal > 0 &&
VAT{money(t.vatTotal)}
} +
+
+ Total + {money(t.grandTotal)} +
+ {t.paidAmount > 0 && ( +
+
Paid{money(t.paidAmount)}
+
Balance Due{money(t.balanceAmount)}
+
+ )} +
+
+ + {/* Payments */} +
+ Payments + {payments.length > 0 ? ( +
    + {payments.map((p) => ( +
  • + + {formatDate(p.paymentDate)} · {p.bankAccountName} + {p.reference && · Ref {p.reference}} + + {money(p.amount)} +
  • + ))} +
+ ) : ( +

No payment recorded yet.

+ )} +
+
+ ) +} + +/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */ +function ReceiptSheet({ invoice, payments, company, template, notes, showLogo, width }: PurchaseInvoiceDoc & { width: ReceiptWidth }) { + const t = invoice.totals + return ( +
+
+ {showLogo && ( + // eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed + {company!.legalName} + )} + {company && ( + <> +

{company.tradeName || company.legalName}

+

{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}

+ {company.phone &&

Tel: {company.phone}

} + {template?.showTaxRegNo && company.taxRegistrationNo &&

Tax Reg: {company.taxRegistrationNo}

} + + )} +

PURCHASE INVOICE

+ {invoice.status !== "Posted" &&

*** {invoice.status.toUpperCase()} ***

} +
+ + + + + + + + +
+ GRN + Amount +
+ +
+ {invoice.lines.map((line) => ( +
+

{line.grnDocNo}

+
+ {formatDate(line.grnPostedAt)} + {money(line.lineTotal)} +
+ {line.vatTotal > 0 &&

incl. VAT {money(line.vatTotal)}

} +
+ ))} +
+ + + + {t.vatTotal > 0 && } +
+ +
+ {t.paidAmount > 0 && ( + <> + + + + )} + + {payments.length > 0 && ( + <> + +

Payment

+ {payments.map((p) => ( + + ))} + + )} + + {notes.length > 0 && ( + <> + + {notes.map((note, i) =>

{note}

)} + + )} +
+ ) +} + +/** Loads the invoice plus the optional decoration around it (vendor, payments, company, template). + * Only the invoice is required — any other failure just leaves that block out. */ +function usePurchaseInvoiceDoc(purchaseInvoiceId: number, refreshKey = 0) { + const [doc, setDoc] = useState(null) + const [loadError, setLoadError] = useState(null) + const error = Number.isFinite(purchaseInvoiceId) ? loadError : `Invalid purchase invoice id '${purchaseInvoiceId}'.` + + useEffect(() => { + if (!Number.isFinite(purchaseInvoiceId)) return + let cancelled = false + purchaseInvoicesApi + .get(purchaseInvoiceId) + .then(async ({ data: invoice }) => { + const [vendorRes, payments, companyRes, templateRes] = await Promise.all([ + vendorsApi.get(invoice.vendorId).catch(() => null), + invoice.status === "Posted" ? purchaseInvoicesApi.listPayments(purchaseInvoiceId).catch(() => []) : Promise.resolve([]), + companyProfileApi.get().catch(() => ({ data: null, etag: null })), + invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })), + ]) + if (cancelled) return + const company = companyRes.data + const template = templateRes.data + setDoc({ + invoice, + vendor: vendorRes?.data ?? null, + payments, + company, + template, + notes: templateNotes(template?.termsAndConditions, template?.footerNote), + showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl, + }) + }) + .catch((err) => { + if (!cancelled) setLoadError(errorMessage(err)) + }) + return () => { + cancelled = true + } + // refreshKey: bumped by the caller after a payment so the sheet shows the new state. + }, [purchaseInvoiceId, refreshKey]) + + return { doc, error } +} + +function Sheet({ size, doc }: { size: PaperSize; doc: PurchaseInvoiceDoc }) { + return size === "A4" ? : +} + +/** Purchase invoice preview + paper-size bar underneath + Share / Download / Print. */ +export function PurchaseInvoicePrintPreview({ + purchaseInvoiceId, + className, + refreshKey, +}: { + purchaseInvoiceId: number + className?: string + refreshKey?: number +}) { + const { doc, error } = usePurchaseInvoiceDoc(purchaseInvoiceId, refreshKey) + return ( + : null} + error={error} + docNo={doc?.invoice.docNo ?? `purchase-invoice-${purchaseInvoiceId}`} + shareTitle={`Purchase invoice ${doc?.invoice.docNo ?? ""}`.trim()} + sharePath={`/dashboard/procurement/purchase-invoices/${purchaseInvoiceId}`} + className={className} + /> + ) +} + +/** Popup form of the print preview — same frame as SalesInvoicePrintDialog. Opens on A4 every time. */ +export function PurchaseInvoicePrintDialog({ + purchaseInvoiceId, + invoiceNo, + open, + onOpenChange, + actions, + refreshKey, +}: { + purchaseInvoiceId: number | null + invoiceNo?: string + open: boolean + onOpenChange: (open: boolean) => void + /** Optional buttons shown in the header (e.g. Record payment). */ + actions?: ReactNode + /** Bump to reload the invoice (after a payment, …). */ + refreshKey?: number +}) { + return ( + + +
+
+ Purchase invoice{invoiceNo ? ` ${invoiceNo}` : ""} + Preview, pick a paper size below, then print. +
+ {actions &&
{actions}
} +
+ {purchaseInvoiceId !== null && ( + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/components/products/BrandPanel.tsx b/Frontend/erp-system/components/products/BrandPanel.tsx index 82df0b3..7a888ce 100644 --- a/Frontend/erp-system/components/products/BrandPanel.tsx +++ b/Frontend/erp-system/components/products/BrandPanel.tsx @@ -49,7 +49,10 @@ export function BrandPanel() { async function handleAdd() { const nextErrors = validateBrandName(name) setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not create brand", Object.values(nextErrors)[0]) + return + } setSubmitting(true) try { @@ -91,7 +94,10 @@ export function BrandPanel() { if (!editing) return const nextErrors = validateBrandName(editName) setEditErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not update brand", Object.values(nextErrors)[0]) + return + } setEditSubmitting(true) try { @@ -116,7 +122,10 @@ export function BrandPanel() {
setName(e.target.value)} + onChange={(e) => { + setName(e.target.value) + setErrors({}) + }} placeholder="New brand name, e.g. Bosch" className="h-12 flex-1 text-base" aria-label="New brand name" @@ -178,11 +187,14 @@ export function BrandPanel() { - Name + Name setEditName(e.target.value)} + onChange={(e) => { + setEditName(e.target.value) + setEditErrors({}) + }} aria-invalid={!!editErrors.name} /> diff --git a/Frontend/erp-system/components/products/CategoryPanel.tsx b/Frontend/erp-system/components/products/CategoryPanel.tsx index 14a618a..90c402a 100644 --- a/Frontend/erp-system/components/products/CategoryPanel.tsx +++ b/Frontend/erp-system/components/products/CategoryPanel.tsx @@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" type SortOrder = "asc" | "desc" type SortKey = "categoryId" | "name" | "status" | "createdAt" @@ -145,7 +146,10 @@ export function CategoryPanel() { async function handleSubmit() { const nextErrors = validateCategoryName(name) setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error(editing ? "Could not update category" : "Could not create category", Object.values(nextErrors)[0]) + return + } setSubmitting(true) try { @@ -219,8 +223,8 @@ export function CategoryPanel() { - Name - setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> + Name + { setName(e.target.value); setErrors({}) }} placeholder="Fasteners" aria-invalid={!!errors.name} /> @@ -385,39 +389,45 @@ export function CategoryPanel() { {/* Subcategories are their own resource now, not a nested tree. Hidden while the feature is switched off in Product Configuration. */} {config?.subcategoriesEnabled && ( - - - + + + + + )} - + + + {/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */} - - } - > - {c.status === "Active" ? : } - + + + } + > + {c.status === "Active" ? : } + + + + {label} + + ) +} + function SortableHeader({ label, active, diff --git a/Frontend/erp-system/components/products/UomPanel.tsx b/Frontend/erp-system/components/products/UomPanel.tsx index 23ec659..84c22e3 100644 --- a/Frontend/erp-system/components/products/UomPanel.tsx +++ b/Frontend/erp-system/components/products/UomPanel.tsx @@ -53,7 +53,10 @@ export function UomPanel() { async function handleAdd() { const nextErrors = validateUomName(name) setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not create UOM", Object.values(nextErrors)[0]) + return + } setSubmitting(true) try { @@ -76,7 +79,10 @@ export function UomPanel() {
setName(e.target.value)} + onChange={(e) => { + setName(e.target.value) + setErrors({}) + }} placeholder="New UOM name, e.g. Box-12" className="h-12 flex-1 text-base" aria-label="New UOM name" diff --git a/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx b/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx index d877630..aea8d05 100644 --- a/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx +++ b/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx @@ -1,8 +1,6 @@ "use client" -import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react" -import { createPortal } from "react-dom" -import { Download, Loader2, Printer, Share2 } from "lucide-react" +import { ReactNode, useEffect, useState } from "react" import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" @@ -10,55 +8,29 @@ import { companyProfileApi } from "@/lib/api/company-profile" import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Skeleton } from "@/components/ui/skeleton" -import { toast } from "@/components/ui/toast" +import { + A4_SHEET_CLASS, + ACCENT, + formatDate, + InvoicePreviewFrame, + money, + PaperSize, + printColorExact, + qty, + RECEIPT_SHEET_BASE_CLASS, + RECEIPT_SHEET_CLASS, + ReceiptRow, + ReceiptRule, + ReceiptWidth, + SectionTitle, + templateNotes, +} from "@/components/invoice-template/InvoicePrintKit" import { CompanyProfile } from "@/types/company-profile" import { Customer } from "@/types/customers" import { InvoiceTemplateConfig } from "@/types/invoice-template" import { SalesInvoice, SalesInvoicePayment } from "@/types/sales" -// Deep-red accent from the reference layout — used for the heading underlines and header rule. -const ACCENT = "#8b1c24" - -type PaperSize = "A4" | "80mm" | "58mm" -type ReceiptWidth = Exclude - -const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [ - { value: "A4", label: "A4", hint: "210 × 297 mm" }, - { value: "80mm", label: "80mm receipt", hint: "Thermal roll" }, - { value: "58mm", label: "58mm receipt", hint: "Small thermal roll" }, -] - -/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */ -const PAGE_RULES: Record = { - A4: "@page { size: A4; margin: 12mm; }", - "80mm": "@page { size: 80mm auto; margin: 3mm; }", - "58mm": "@page { size: 58mm auto; margin: 2mm; }", -} - -/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */ -const RECEIPT_SHEET_CLASS: Record = { - "80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]", - "58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]", -} - -function money(n: number) { - return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - -function qty(n: number) { - return n.toLocaleString(undefined, { maximumFractionDigits: 3 }) -} - -/** dd/MM/yyyy, as on the reference invoice. */ -function formatDate(value: string | Date) { - const d = new Date(value) - return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}` -} - /** Everything both layouts render, resolved once. */ interface InvoiceDoc { invoice: SalesInvoice @@ -72,26 +44,11 @@ interface InvoiceDoc { showBank: boolean } -const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const - -/** Section heading with the short accent underline. */ -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
-

{children}

-
-
- ) -} - function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) { const t = invoice.totals return ( // On screen: a true 210 × 297 mm page. In print the @page margin does the spacing. -
+
{/* Title + meta */}
@@ -262,31 +219,11 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note ) } -/** Dashed divider used between receipt sections. */ -function ReceiptRule() { - return
-} - -function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { - return ( -
- {label} - {value} -
- ) -} - /** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */ function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) { const t = invoice.totals return ( -
+
{showLogo && ( // eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed @@ -405,9 +342,7 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) { company, template, dueDate, - notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")] - .map((l) => l.trim()) - .filter(Boolean), + notes: templateNotes(template?.termsAndConditions, template?.footerNote), showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl, showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber), }) @@ -424,186 +359,22 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) { return { doc, error } } -const subscribeNoop = () => () => {} - -/** True only on the client — the print copy is portalled into document.body. */ -function useIsClient() { - return useSyncExternalStore(subscribeNoop, () => true, () => false) -} - -/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */ -const PAPER_WIDTH_MM: Record = { A4: 210, "80mm": 80, "58mm": 58 } -const A4_HEIGHT_MM = 297 - -/** - * Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured - * as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split - * across as many 297mm pages as needed; a receipt is one page as long as the receipt itself. - * Both libraries are loaded on demand. - */ -async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise { - const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")]) - // The sheet's drop shadow / ring are screen decoration only. - const dataUrl = await toPng(sheet, { - pixelRatio: 2, - backgroundColor: "#ffffff", - style: { boxShadow: "none", margin: "0" }, - // A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF. - imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", - }) - const img = new Image() - img.src = dataUrl - await img.decode() - - const widthMm = PAPER_WIDTH_MM[size] - const imgHeightMm = (img.height * widthMm) / img.width - if (size !== "A4") { - const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] }) - pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm) - return pdf.output("blob") - } - const pdf = new jsPDF({ unit: "mm", format: "a4" }) - // Same image on every page, shifted up one page-height each time; the page box crops the rest. - for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) { - if (page > 0) pdf.addPage() - pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm) - } - return pdf.output("blob") -} - -function saveBlob(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = filename - a.click() - setTimeout(() => URL.revokeObjectURL(url), 1000) -} - function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) { return size === "A4" ? : } -/** - * Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet - * portalled straight into (`.invoice-print-root`, see globals.css) so the printout is - * just the invoice, whether this is rendered in a page or inside a dialog. - */ +/** Invoice preview + paper-size bar underneath + Share / Download / Print (see InvoicePreviewFrame). */ export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) { const { doc, error } = useInvoiceDoc(invoiceId, refreshKey) - const [paperSize, setPaperSize] = useState("A4") - const isClient = useIsClient() - const previewRef = useRef(null) - const [busy, setBusy] = useState<"download" | "share" | null>(null) - - const fileName = `${doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf` - - async function makePdf(): Promise { - const sheet = previewRef.current?.querySelector(".invoice-sheet") - if (!sheet) throw new Error("The invoice is still loading.") - return buildInvoicePdf(sheet, paperSize) - } - - async function handleDownload() { - setBusy("download") - try { - saveBlob(await makePdf(), fileName) - } catch (err) { - toast.error("Could not create the PDF", errorMessage(err)) - } finally { - setBusy(null) - } - } - - // Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge); - // otherwise shares — or, failing that, copies — a link to this invoice's print page. - async function handleShare() { - const title = `Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim() - const link = `${window.location.origin}/dashboard/sales/invoices/${invoiceId}/print` - setBusy("share") - try { - const file = new File([await makePdf()], fileName, { type: "application/pdf" }) - if (navigator.canShare?.({ files: [file] })) { - await navigator.share({ files: [file], title, text: title }) - } else if (navigator.share) { - await navigator.share({ title, text: title, url: link }) - } else { - await navigator.clipboard.writeText(link) - toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.") - } - } catch (err) { - // Closing the share sheet isn't an error. - if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err)) - } finally { - setBusy(null) - } - } - - if (error && !doc) { - return
{error}
- } - return ( -
- {/* Preview window — scrolls so the page keeps its true size. */} -
- {doc ? ( - - ) : ( - - )} -
- - {/* Paper size selection, under the invoice. */} -
-
- - - value={paperSize} - onValueChange={(v) => v && setPaperSize(v)} - items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))} - > - - - - - {PAPER_SIZES.map((s) => ( - - - {s.label} - {s.hint} - - - ))} - - -
-
- - - -
-
- - {isClient && - doc && - createPortal( -
- - -
, - document.body - )} -
+ : null} + error={error} + docNo={doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`} + shareTitle={`Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()} + sharePath={`/dashboard/sales/invoices/${invoiceId}/print`} + className={className} + /> ) } diff --git a/Frontend/erp-system/components/ui/bar-chart.tsx b/Frontend/erp-system/components/ui/bar-chart.tsx index 92232bd..84806b6 100644 --- a/Frontend/erp-system/components/ui/bar-chart.tsx +++ b/Frontend/erp-system/components/ui/bar-chart.tsx @@ -35,7 +35,8 @@ type Props = { labels: string[] datasets: Array<{ label?: string - data: number[] + /** `null` leaves an empty slot — used to pad the axis so a few bars sit at the start. */ + data: (number | null)[] backgroundColor?: string | string[] }> /** Container height in px — defaults to the original 260 so existing callers are unaffected. */ diff --git a/Frontend/erp-system/components/ui/label.tsx b/Frontend/erp-system/components/ui/label.tsx index 74da65c..36a64c3 100644 --- a/Frontend/erp-system/components/ui/label.tsx +++ b/Frontend/erp-system/components/ui/label.tsx @@ -4,7 +4,13 @@ import * as React from "react" import { cn } from "@/lib/utils" -function Label({ className, ...props }: React.ComponentProps<"label">) { +/** `required` appends a red asterisk to mark a mandatory field. */ +function Label({ + className, + required, + children, + ...props +}: React.ComponentProps<"label"> & { required?: boolean }) { return ( ) } diff --git a/Frontend/erp-system/components/vendors/EditVendorDialog.tsx b/Frontend/erp-system/components/vendors/EditVendorDialog.tsx new file mode 100644 index 0000000..dd0e058 --- /dev/null +++ b/Frontend/erp-system/components/vendors/EditVendorDialog.tsx @@ -0,0 +1,185 @@ +"use client" + +import { useEffect, useState } from "react" + +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { generateVendorCode } from "@/lib/vendor-code" +import { Vendor } from "@/types/master-data" + +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +/** + * Edit popup for one vendor, opened from the vendors list. Mount it with `key={vendorId}` + * so each open starts clean. The list rows carry no ETag, so the vendor is re-read here + * for a fresh If-Match before anything can be saved. + */ +export function EditVendorDialog({ + vendorId, + existingCodes, + onClose, + onSaved, +}: { + vendorId: number + /** Every loaded vendor code, to de-dupe the regenerated code against (this vendor's own is skipped). */ + existingCodes: string[] + onClose: () => void + onSaved: (vendor: Vendor) => void +}) { + const [vendor, setVendor] = useState(null) + const [etag, setEtag] = useState(null) + const [loadError, setLoadError] = useState(null) + + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [terms, setTerms] = useState("") + const [taxReg, setTaxReg] = useState("") + const [currency, setCurrency] = useState("") + const [errors, setErrors] = useState>({}) + const [saving, setSaving] = useState(false) + + function applyVendor(data: Vendor, tag: string | null) { + setVendor(data) + setEtag(tag) + setCode(data.code) + setName(data.name) + setTerms(data.terms ?? "") + setTaxReg(data.taxReg ?? "") + setCurrency(data.currency) + } + + function reload() { + return vendorsApi.get(vendorId).then(({ data, etag: tag }) => applyVendor(data, tag)) + } + + useEffect(() => { + reload().catch((err) => setLoadError(errorMessage(err))) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vendorId]) + + /** Drops fields' errors once the user starts editing them. */ + function clearErrors(...keys: string[]) { + setErrors((prev) => { + if (!keys.some((k) => prev[k])) return prev + const next = { ...prev } + for (const k of keys) delete next[k] + return next + }) + } + + function handleNameChange(value: string) { + setName(value) + const otherCodes = existingCodes.filter((c) => c !== vendor?.code) + setCode(value.trim() ? generateVendorCode(value, otherCodes) : "") + // The code is regenerated from the name, so its error is stale too. + clearErrors("name", "code") + } + + async function handleSave() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Vendor code is required" + if (!name.trim()) nextErrors.name = "Vendor name is required" + if (!currency.trim()) nextErrors.currency = "Currency is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not save vendor", Object.values(nextErrors)[0]) + return + } + if (!vendor || !etag) return + + setSaving(true) + try { + const result = await vendorsApi.update( + vendorId, + { code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId }, + etag + ) + toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`) + onSaved(result.data) + } catch (err) { + if ((err as { code?: string })?.code === "CONCURRENCY_CONFLICT") { + // Someone else saved first: show their version so the user can redo the edit on top of it. + toast.error("Vendor was changed by someone else", "The latest details have been loaded — make your edit again.") + await reload().catch(() => {}) + return + } + const fe = fieldErrors(err) + if (fe?.code) setErrors({ code: fe.code }) + toast.error("Could not save vendor", errorMessage(err)) + } finally { + setSaving(false) + } + } + + // Laid out to match the "New vendor" dialog (app/dashboard/vendors/page.tsx). + return ( + !open && onClose()}> + + + Edit vendor + Update this supplier record. Its code is regenerated from the name. + + + {loadError ? ( +
{loadError}
+ ) : !vendor ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + + Name + handleNameChange(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} /> + + + + Code (auto-generated) + + + + + Payment terms (optional) + setTerms(e.target.value)} placeholder="NET30" /> + + + Tax registration (optional) + setTaxReg(e.target.value)} placeholder="134567890-7000" /> + + + Currency + { + setCurrency(e.target.value) + clearErrors("currency") + }} + placeholder="LKR" + maxLength={3} + aria-invalid={!!errors.currency} + /> + + + + )} + +
+ + +
+
+
+ ) +} diff --git a/Frontend/erp-system/lib/api/vendors.ts b/Frontend/erp-system/lib/api/vendors.ts index ab6961b..f83e303 100644 --- a/Frontend/erp-system/lib/api/vendors.ts +++ b/Frontend/erp-system/lib/api/vendors.ts @@ -1,6 +1,6 @@ // One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md -// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters are -// deactivated, not hard-deleted, FR-MD-08). +// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate, DELETE for a +// hard delete (409 MASTER_IN_USE while any purchase document or item references it). import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" import { ApiResult, EntityStatus, PagedResponse } from "@/types/common" import { CreateVendorRequest, UpdateVendorRequest, Vendor } from "@/types/master-data" @@ -33,4 +33,8 @@ export const vendorsApi = { updateStatus(vendorId: number, status: EntityStatus): Promise { return apiRequest(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } }) }, + + delete(vendorId: number): Promise { + return apiRequest(`/vendors/${vendorId}`, { method: "DELETE" }) + }, } diff --git a/Frontend/erp-system/types/purchase-invoices.ts b/Frontend/erp-system/types/purchase-invoices.ts index 4c56ddb..d6a57ba 100644 --- a/Frontend/erp-system/types/purchase-invoices.ts +++ b/Frontend/erp-system/types/purchase-invoices.ts @@ -64,6 +64,8 @@ export interface CreatePurchaseInvoicePaymentRequest { amount: number glBankAccountId: number reference?: string | null + /** `YYYY-MM-DD`; when the vendor was paid (also the GL entry date). Omitted ⇒ today; never future. */ + paymentDate?: string } export interface CreatePurchaseInvoiceRequest { -- 2.52.0 From a788349138735e38281357183bb9888f2c451463 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Wed, 7 Oct 2026 14:00:32 +0530 Subject: [PATCH 5/7] serial numbers --- Backend/ERPCore.Tests/ItemTrackingTests.cs | 366 + .../ERPCore/Controllers/SerialsController.cs | 41 + .../ERPCore/Controllers/StockController.cs | 15 +- .../ERPCore/Controllers/TrackingController.cs | 64 + .../Controllers/WarrantiesController.cs | 30 + Backend/ERPCore/Domain/Entities/GrnLine.cs | 8 + .../ERPCore/Domain/Entities/GrnLineSerial.cs | 22 + .../Domain/Entities/SalesInvoiceLine.cs | 7 + .../Domain/Entities/SalesLineAllocation.cs | 35 + .../Domain/Entities/SalesLineSerial.cs | 29 + .../Domain/Entities/SalesReturnLine.cs | 21 +- .../ERPCore/Domain/Entities/SalesSlipLine.cs | 7 + Backend/ERPCore/Domain/Entities/Serial.cs | 34 +- .../ERPCore/Domain/Entities/SerialMovement.cs | 46 + .../Domain/Entities/StockAdjustmentLine.cs | 1 + .../Domain/Entities/StockTransferLine.cs | 23 + .../Domain/Entities/WarrantyRegistration.cs | 42 + Backend/ERPCore/Domain/Enums/SerialStatus.cs | 25 + .../ERPCore/Domain/Enums/WarrantyStatus.cs | 10 + Backend/ERPCore/Dtos/Grn/GrnDtos.cs | 14 +- .../Dtos/Procurement/PurchaseReturnDtos.cs | 6 + .../ERPCore/Dtos/Sales/SalesInvoiceDtos.cs | 13 +- Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs | 30 +- Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs | 9 +- Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs | 12 +- Backend/ERPCore/Dtos/Stock/TrackingDtos.cs | 40 + Backend/ERPCore/Dtos/Stock/TransferDtos.cs | 14 +- .../BatchSerialConfiguration.cs | 9 +- .../Configurations/GrnConfiguration.cs | 20 + .../ItemTrackingConfiguration.cs | 118 + .../SalesInvoiceConfiguration.cs | 5 + .../SalesReturnConfiguration.cs | 15 + .../StockAdjustmentConfiguration.cs | 2 +- .../ERPCore/Infra/Persistence/ErpDbContext.cs | 8 + ...AddItemTrackingSerialsWarranty.Designer.cs | 9481 +++++++++++++++++ ...07054513_AddItemTrackingSerialsWarranty.cs | 635 ++ .../Migrations/ErpDbContextModelSnapshot.cs | 567 +- Backend/ERPCore/Program.cs | 2 + Backend/ERPCore/Services/AdjustmentService.cs | 37 +- Backend/ERPCore/Services/CountService.cs | 43 +- Backend/ERPCore/Services/GrnService.cs | 131 +- .../Interfaces/IFifoCostingService.cs | 24 +- .../Interfaces/IItemTrackingService.cs | 46 + .../Interfaces/ISalesDomainService.cs | 13 + .../Interfaces/ISerialTrackingService.cs | 55 + .../Services/Interfaces/IStockMutator.cs | 12 +- Backend/ERPCore/Services/ItemService.cs | 19 +- .../ERPCore/Services/PurchaseReturnService.cs | 56 +- .../Services/SalesDocumentWorkflowService.cs | 4 +- .../ERPCore/Services/SalesDomainService.cs | 74 +- .../ERPCore/Services/SalesInvoiceService.cs | 12 +- .../ERPCore/Services/SalesMappingService.cs | 6 +- .../ERPCore/Services/SalesPostingService.cs | 165 +- .../ERPCore/Services/SalesReturnService.cs | 234 +- Backend/ERPCore/Services/SalesSlipService.cs | 23 +- .../Services/Stock/FifoCostingService.cs | 117 +- .../Services/Stock/ItemTrackingService.cs | 263 + .../Services/Stock/SerialTrackingService.cs | 156 + .../ERPCore/Services/Stock/StockMutator.cs | 26 +- Backend/ERPCore/Services/TransferService.cs | 131 +- Backend/ERPCore/System/Errors/ErrorCodes.cs | 5 + .../procurement/purchase-returns/new/page.tsx | 40 +- .../app/dashboard/products/settings/page.tsx | 5 +- .../app/dashboard/receiving/grn/[id]/page.tsx | 32 +- .../app/dashboard/receiving/grn/new/page.tsx | 256 +- .../dashboard/sales/free-issues/new/page.tsx | 7 +- .../dashboard/sales/invoices/[id]/page.tsx | 37 +- .../app/dashboard/sales/invoices/new/page.tsx | 66 +- .../app/dashboard/sales/warranties/page.tsx | 147 + .../dashboard/stock/adjustments/new/page.tsx | 60 +- .../app/dashboard/stock/batches/page.tsx | 147 + .../app/dashboard/stock/serials/page.tsx | 244 + .../dashboard/stock/transfers/new/page.tsx | 42 +- .../components/Layouts/AppSidebar.tsx | 14 +- .../components/Layouts/Breadcrumbs.tsx | 3 + .../erp-system/components/Layouts/Header.tsx | 3 + .../components/sales/LineTrackingPicker.tsx | 227 + .../components/sales/SalesReturnForm.tsx | 103 +- .../components/ui/suggest-input.tsx | 29 +- .../erp-system/hooks/use-tracking-settings.ts | 36 + Frontend/erp-system/lib/api/tracking.ts | 89 + Frontend/erp-system/lib/tracking-tags.ts | 15 + Frontend/erp-system/lib/validations/grn.ts | 19 +- Frontend/erp-system/types/grn.ts | 24 +- Frontend/erp-system/types/procurement.ts | 2 + Frontend/erp-system/types/sales.ts | 37 + Frontend/erp-system/types/stock.ts | 12 + Frontend/erp-system/types/tracking.ts | 120 + docs/ITEM_TRACKING_PLAN.md | 242 + 89 files changed, 15320 insertions(+), 216 deletions(-) create mode 100644 Backend/ERPCore.Tests/ItemTrackingTests.cs create mode 100644 Backend/ERPCore/Controllers/SerialsController.cs create mode 100644 Backend/ERPCore/Controllers/TrackingController.cs create mode 100644 Backend/ERPCore/Controllers/WarrantiesController.cs create mode 100644 Backend/ERPCore/Domain/Entities/GrnLineSerial.cs create mode 100644 Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs create mode 100644 Backend/ERPCore/Domain/Entities/SalesLineSerial.cs create mode 100644 Backend/ERPCore/Domain/Entities/SerialMovement.cs create mode 100644 Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs create mode 100644 Backend/ERPCore/Domain/Enums/SerialStatus.cs create mode 100644 Backend/ERPCore/Domain/Enums/WarrantyStatus.cs create mode 100644 Backend/ERPCore/Dtos/Stock/TrackingDtos.cs create mode 100644 Backend/ERPCore/Infra/Persistence/Configurations/ItemTrackingConfiguration.cs create mode 100644 Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.Designer.cs create mode 100644 Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.cs create mode 100644 Backend/ERPCore/Services/Interfaces/IItemTrackingService.cs create mode 100644 Backend/ERPCore/Services/Interfaces/ISerialTrackingService.cs create mode 100644 Backend/ERPCore/Services/Stock/ItemTrackingService.cs create mode 100644 Backend/ERPCore/Services/Stock/SerialTrackingService.cs create mode 100644 Frontend/erp-system/app/dashboard/sales/warranties/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/batches/page.tsx create mode 100644 Frontend/erp-system/app/dashboard/stock/serials/page.tsx create mode 100644 Frontend/erp-system/components/sales/LineTrackingPicker.tsx create mode 100644 Frontend/erp-system/hooks/use-tracking-settings.ts create mode 100644 Frontend/erp-system/lib/api/tracking.ts create mode 100644 Frontend/erp-system/lib/tracking-tags.ts create mode 100644 Frontend/erp-system/types/tracking.ts create mode 100644 docs/ITEM_TRACKING_PLAN.md diff --git a/Backend/ERPCore.Tests/ItemTrackingTests.cs b/Backend/ERPCore.Tests/ItemTrackingTests.cs new file mode 100644 index 0000000..3b83fd2 --- /dev/null +++ b/Backend/ERPCore.Tests/ItemTrackingTests.cs @@ -0,0 +1,366 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Auth; +using ERPCore.Infra.Persistence; +using ERPCore.Repositories; +using ERPCore.Services; +using ERPCore.Services.Stock; +using ERPCore.System.Errors; +using ERPCore.Tests.TestSupport; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ERPCore.Tests; + +/// +/// Serial/batch rules from docs/ITEM_TRACKING_PLAN.md that live outside the MariaDB-only FIFO +/// SQL: the sales-line check () and the +/// shared stock-document rules (). +/// +public class ItemTrackingTests +{ + private static readonly InMemoryDatabaseRoot Root = new(); + + private const int Wh1 = 1; + private const int Wh2 = 2; + private const int SerialItemId = 10; + private const int BatchItemId = 20; + private const int PlainItemId = 30; + + // --- sales line validation -------------------------------------------------------------- + + [Fact] + public async Task SalesLine_SerialItem_RequiresOneSerialPerUnit() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1 }, new HashSet())); + + Assert.Equal(ErrorCodes.TrackingRequired, ex.Code); + } + + [Fact] + public async Task SalesLine_SerialItem_WithoutSerials_IsRejected_WhenNoPreTrackingStockIsLeft() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, null, new HashSet())); + + Assert.Equal(ErrorCodes.TrackingRequired, ex.Code); + } + + [Fact] + public async Task SalesLine_SerialItem_WithoutSerials_IsAllowed_FromPreTrackingStock() + { + await using var db = await SeedAsync(); + // D6: stock received before serial capture — a layer with no serial. + db.StockLayers.Add(new StockLayer { ItemId = SerialItemId, WarehouseId = Wh1, QtyReceived = 3, QtyRemaining = 3, UnitCost = 10, ReceiptDate = DateTime.UtcNow }); + await db.SaveChangesAsync(); + var sales = CreateSalesDomain(db); + + var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, null, new HashSet()); + + Assert.Empty(serials); + } + + [Fact] + public async Task SalesLine_Serial_AtAnotherWarehouse_IsNotAvailable() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + // Serial 3 is in stock at warehouse 2. + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 3 }, new HashSet())); + + Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code); + } + + [Fact] + public async Task SalesLine_SoldSerial_IsNotAvailable() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 4 }, new HashSet())); + + Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code); + } + + [Fact] + public async Task SalesLine_SameSerialOnTwoLines_IsADuplicate() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + var documentSerials = new HashSet(); + + await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials); + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, documentSerials)); + + Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code); + } + + [Fact] + public async Task SalesLine_ValidSerials_AreReturned() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 2, null, new[] { 1, 2 }, new HashSet()); + + Assert.Equal(new[] { 1, 2 }, serials.Select(s => s.SerialId).OrderBy(i => i)); + } + + [Fact] + public async Task SalesLine_UntrackedItem_RejectsSerials() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, null, new[] { 1 }, new HashSet())); + + Assert.Equal(ErrorCodes.Validation, ex.Code); + } + + [Fact] + public async Task SalesLine_ExpiredBatch_IsBlocked() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(BatchItemId, Wh1, 1, 101, null, new HashSet())); + + Assert.Equal(ErrorCodes.ExpiredBatchBlocked, ex.Code); + } + + [Fact] + public async Task SalesLine_BatchOfAnotherItem_IsRejected() + { + await using var db = await SeedAsync(); + var sales = CreateSalesDomain(db); + + var ex = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(PlainItemId, Wh1, 1, 100, null, new HashSet())); + + Assert.Equal(ErrorCodes.Validation, ex.Code); + } + + // --- stock documents (transfers / adjustments / purchase returns) ------------------------ + + [Fact] + public async Task Outbound_DamagedSerial_OnlyWhenAllowed() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + // Serial 5 is Damaged at warehouse 1: a transfer (InStock only) refuses it... + var ex = await Assert.ThrowsAsync(() => tracking.ResolveOutboundAsync( + item, Wh1, 1, new[] { 5 }, new HashSet(), new[] { SerialStatus.InStock })); + Assert.Equal(ErrorCodes.SerialNotAvailable, ex.Code); + + // ...a write-off or purchase return (InStock or Damaged) takes it. + var serials = await tracking.ResolveOutboundAsync( + item, Wh1, 1, new[] { 5 }, new HashSet(), new[] { SerialStatus.InStock, SerialStatus.Damaged }); + Assert.Single(serials); + } + + [Fact] + public async Task Inbound_SerialAlreadyInStock_IsADuplicate() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + var ex = await Assert.ThrowsAsync(() => tracking.ResolveInboundAsync( + item, Wh1, 1, new[] { "SN-1" }, new HashSet())); + + Assert.Equal(ErrorCodes.SerialDuplicate, ex.Code); + } + + [Fact] + public async Task Inbound_FractionalQty_IsRejected_ForSerialItems() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + var ex = await Assert.ThrowsAsync(() => tracking.ResolveInboundAsync( + item, Wh1, 1.5m, new[] { "SN-NEW" }, new HashSet())); + + Assert.Equal(ErrorCodes.Validation, ex.Code); + } + + [Fact] + public async Task Inbound_NewAndSoldSerials_AreCreatedOrBroughtBack() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + // SN-4 is Sold (out of stock) — it comes back; SN-NEW does not exist — it is created. + var serials = await tracking.ResolveInboundAsync(item, Wh1, 2, new[] { "SN-4", "SN-NEW" }, new HashSet()); + await tracking.MoveAsync(serials, SerialStatus.InStock, Wh1, null, "ADJ", 1, DateTime.UtcNow); + await db.SaveChangesAsync(); + + var all = await db.Serials.Where(s => s.ItemId == SerialItemId && (s.SerialNo == "SN-4" || s.SerialNo == "SN-NEW")).ToListAsync(); + Assert.All(all, s => Assert.Equal(SerialStatus.InStock, s.Status)); + Assert.All(all, s => Assert.Equal(Wh1, s.WarehouseId)); + Assert.Equal(2, await db.SerialMovements.CountAsync(m => m.SourceDocType == "ADJ")); + } + + [Fact] + public async Task Move_RecordsHistory_WithPreviousWarehouse() + { + await using var db = await SeedAsync(); + var tracking = CreateSerialTracking(db); + var serial = await db.Serials.FirstAsync(s => s.SerialId == 1); + + await tracking.MoveAsync(new[] { serial }, SerialStatus.InTransit, null, null, "TRF", 7, DateTime.UtcNow); + await db.SaveChangesAsync(); + + var movement = await db.SerialMovements.SingleAsync(m => m.SerialId == 1); + Assert.Equal(SerialStatus.InTransit, movement.Status); + Assert.Equal(Wh1, movement.FromWarehouseId); + Assert.Null(movement.WarehouseId); + Assert.Null(serial.WarehouseId); + } + + // --- auto-generated serial numbers ------------------------------------------------------- + + [Fact] + public async Task NextNumbers_ContinueAfterHighestUsed_AndSkipDraftGrnNumbers() + { + await using var db = await SeedAsync(); + db.Serials.Add(new Serial { SerialId = 50, ItemId = SerialItemId, SerialNo = "PH-0041", Status = SerialStatus.Sold }); + // A draft GRN line already holds PH-0042 (not yet a Serial). + db.GrnLines.Add(new GrnLine { GrnLineId = 900, GrnId = 1, ItemId = SerialItemId, Qty = 1, + Serials = { new GrnLineSerial { SerialNo = "PH-0042" } } }); + await db.SaveChangesAsync(); + var tracking = new ItemTrackingService(db, null!); + + var next = await tracking.NextSerialNumbersAsync(SerialItemId, "PH-", 3); + + Assert.Equal(new[] { "PH-0043", "PH-0044", "PH-0045" }, next); + } + + [Fact] + public async Task NextNumbers_StartAtOne_ForANewPrefix_AndIgnoreOtherItems() + { + await using var db = await SeedAsync(); + db.Serials.Add(new Serial { SerialId = 51, ItemId = PlainItemId, SerialNo = "NEW-0099", Status = SerialStatus.InStock }); + await db.SaveChangesAsync(); + var tracking = new ItemTrackingService(db, null!); + + var next = await tracking.NextSerialNumbersAsync(SerialItemId, "NEW-", 2); + + Assert.Equal(new[] { "NEW-0001", "NEW-0002" }, next); + } + + [Fact] + public async Task NextNumbers_RejectsTooMany() + { + await using var db = await SeedAsync(); + var tracking = new ItemTrackingService(db, null!); + + var ex = await Assert.ThrowsAsync(() => tracking.NextSerialNumbersAsync(SerialItemId, "X-", 5000)); + + Assert.Equal(ErrorCodes.Validation, ex.Code); + } + + // --- fixtures --------------------------------------------------------------------------- + + private static async Task SeedAsync() + { + var db = CreateContext(); + db.Items.AddRange( + new Item { ItemId = SerialItemId, Sku = "SER-1", Name = "Phone", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Serial }, + new Item { ItemId = BatchItemId, Sku = "BAT-1", Name = "Milk", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.Batch }, + new Item { ItemId = PlainItemId, Sku = "PLN-1", Name = "Bolt", CategoryId = 1, BaseUomId = 1, StockNature = StockNature.Stocked, TrackingMode = TrackingMode.None }); + db.Batches.AddRange( + new Batch { BatchId = 100, ItemId = BatchItemId, BatchNo = "B-OK", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(30) }, + new Batch { BatchId = 101, ItemId = BatchItemId, BatchNo = "B-EXP", ExpiryDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1) }); + db.Serials.AddRange( + new Serial { SerialId = 1, ItemId = SerialItemId, SerialNo = "SN-1", Status = SerialStatus.InStock, WarehouseId = Wh1 }, + new Serial { SerialId = 2, ItemId = SerialItemId, SerialNo = "SN-2", Status = SerialStatus.InStock, WarehouseId = Wh1 }, + new Serial { SerialId = 3, ItemId = SerialItemId, SerialNo = "SN-3", Status = SerialStatus.InStock, WarehouseId = Wh2 }, + new Serial { SerialId = 4, ItemId = SerialItemId, SerialNo = "SN-4", Status = SerialStatus.Sold, WarehouseId = null }, + new Serial { SerialId = 5, ItemId = SerialItemId, SerialNo = "SN-5", Status = SerialStatus.Damaged, WarehouseId = Wh1 }); + await db.SaveChangesAsync(); + return db; + } + + private static ErpDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N"), Root) + .Options; + var currentUser = new TestCurrentUser(); + return new ErpDbContext(options, currentUser, new CurrentBranchContext(currentUser)); + } + + // Pricing is never touched by the tracking check, so it is not wired. + private static SalesDomainService CreateSalesDomain(ErpDbContext db) => new( + new Repository(db), new Repository(db), new Repository(db), + new Repository(db), new Repository(db), null!, new Repository(db), + new Repository(db), new Repository(db), new Repository(db), + CreateSerialTracking(db)); + + private static SerialTrackingService CreateSerialTracking(ErpDbContext db) => new( + new Repository(db), new Repository(db), new Repository(db), + new Repository(db), new TestCurrentUser()); + + private static async Task SwitchSerialNumbersAsync(ErpDbContext db, bool enabled) + { + db.ProductConfig.Add(new ProductConfig { ConfigId = ProductConfig.SingletonId, SerialNumbersEnabled = enabled }); + await db.SaveChangesAsync(); + } + + // --- Product Configuration "Serial numbers" switch --------------------------------------- + + [Fact] + public async Task SwitchOff_SerialItemSells_WithoutSerials() + { + await using var db = await SeedAsync(); + await SwitchSerialNumbersAsync(db, enabled: false); + var sales = CreateSalesDomain(db); + + // No pre-tracking stock exists, yet nothing is asked: the item is plain stock now. + var serials = await sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 5, null, null, new HashSet()); + + Assert.Empty(serials); + } + + [Fact] + public async Task SwitchOff_SerialsSent_AreRefused() + { + await using var db = await SeedAsync(); + await SwitchSerialNumbersAsync(db, enabled: false); + var sales = CreateSalesDomain(db); + var tracking = CreateSerialTracking(db); + var item = await db.Items.FirstAsync(i => i.ItemId == SerialItemId); + + var sale = await Assert.ThrowsAsync(() => + sales.ValidateLineTrackingAsync(SerialItemId, Wh1, 1, null, new[] { 1 }, new HashSet())); + var adjustment = await Assert.ThrowsAsync(() => + tracking.ResolveInboundAsync(item, Wh1, 1, new[] { "SN-X" }, new HashSet())); + + Assert.Equal(ErrorCodes.ConfigDisabled, sale.Code); + Assert.Equal(ErrorCodes.ConfigDisabled, adjustment.Code); + } + + [Fact] + public async Task SwitchOn_IsTheDefault_WhenConfigRowIsMissing() + { + await using var db = await SeedAsync(); + Assert.True(await CreateSerialTracking(db).IsEnabledAsync()); + } +} diff --git a/Backend/ERPCore/Controllers/SerialsController.cs b/Backend/ERPCore/Controllers/SerialsController.cs new file mode 100644 index 0000000..ba90c36 --- /dev/null +++ b/Backend/ERPCore/Controllers/SerialsController.cs @@ -0,0 +1,41 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// Serial register and per-serial history (docs/ITEM_TRACKING_PLAN.md Phase 9). +[Route("api/v1/serials")] +[Authorize(Policy = NavPolicies.Prefix + "stock")] +public sealed class SerialsController : ApiControllerBase +{ + private readonly IItemTrackingService _tracking; + + public SerialsController(IItemTrackingService tracking) => _tracking = tracking; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] SerialStatus? status, + [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _tracking.ListSerialsAsync(query, itemId, warehouseId, status, ct)); + + /// Exact lookup by serial number or barcode, across items. + [HttpGet("lookup")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> Lookup([FromQuery] string serialNo, CancellationToken ct) + => Ok(await _tracking.FindSerialAsync(serialNo, ct)); + + [HttpGet("{serialId:int}")] + [ProducesResponseType(typeof(SerialDetailDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int serialId, CancellationToken ct) + { + var dto = await _tracking.GetSerialAsync(serialId, ct); + return dto is null ? NotFound() : Ok(dto); + } +} diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs index 7e4436a..01e5363 100644 --- a/Backend/ERPCore/Controllers/StockController.cs +++ b/Backend/ERPCore/Controllers/StockController.cs @@ -15,13 +15,26 @@ public sealed class StockController : ApiControllerBase { private readonly IStockService _stock; private readonly IReorderService _reorder; + private readonly IItemTrackingService _tracking; - public StockController(IStockService stock, IReorderService reorder) + public StockController(IStockService stock, IReorderService reorder, IItemTrackingService tracking) { _stock = stock; _reorder = reorder; + _tracking = tracking; } + /// + /// Stock on hand per batch with expiry (docs/ITEM_TRACKING_PLAN.md Phase 9), soonest expiry + /// first. expiringWithinDays keeps batches expiring within that window, expired included. + /// + [HttpGet("batches")] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> Batches( + [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] int? expiringWithinDays, + [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _tracking.ListBatchStockAsync(query, itemId, warehouseId, expiringWithinDays, ct)); + [HttpGet("on-hand")] [ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)] public async Task> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) diff --git a/Backend/ERPCore/Controllers/TrackingController.cs b/Backend/ERPCore/Controllers/TrackingController.cs new file mode 100644 index 0000000..5c2a14f --- /dev/null +++ b/Backend/ERPCore/Controllers/TrackingController.cs @@ -0,0 +1,64 @@ +using ERPCore.Dtos.Stock; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Serial/batch pickers used across GRN, sales, returns and stock screens +/// (docs/ITEM_TRACKING_PLAN.md Phase 8). Read-only lookups like the item scan, so they need +/// only ERP access — not one module's nav permission — as every one of those screens calls them. +/// +[Route("api/v1/tracking")] +public sealed class TrackingController : ApiControllerBase +{ + private readonly IItemTrackingService _tracking; + private readonly ISerialTrackingService _serialTracking; + + public TrackingController(IItemTrackingService tracking, ISerialTrackingService serialTracking) + { + _tracking = tracking; + _serialTracking = serialTracking; + } + + /// + /// Which tracking features are switched on (Product Configuration) — readable by every + /// screen, unlike /product-config which needs the Products permission. + /// + [HttpGet("settings")] + [ProducesResponseType(typeof(TrackingSettingsDto), StatusCodes.Status200OK)] + public async Task> Settings(CancellationToken ct) + => Ok(new TrackingSettingsDto(await _serialTracking.IsEnabledAsync(ct))); + + /// + /// Auto-generate: the next count free serial numbers for an item under prefix + /// (continues the highest number already used). Nothing is reserved — the GRN still checks + /// uniqueness when it is saved and confirmed. + /// + [HttpGet("serials/next-numbers")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> NextSerialNumbers( + [FromQuery] int itemId, [FromQuery] string? prefix, [FromQuery] int count = 1, CancellationToken ct = default) + => Ok(await _tracking.NextSerialNumbersAsync(itemId, prefix ?? string.Empty, count, ct)); + + /// Issuable serials of an item at a warehouse (search by number/barcode with q). + [HttpGet("serials/available")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> AvailableSerials( + [FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] string? q, [FromQuery] int take = 200, CancellationToken ct = default) + => Ok(await _tracking.GetAvailableSerialsAsync(itemId, warehouseId, q, take, ct)); + + /// Issuable stock per batch with expiry, soonest expiry first. + [HttpGet("batches/available")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> AvailableBatches( + [FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct) + => Ok(await _tracking.GetAvailableBatchesAsync(itemId, warehouseId, ct)); + + /// The FEFO pick posting would make for qty when no batch is chosen. + [HttpGet("batches/suggest")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> SuggestBatches( + [FromQuery] int itemId, [FromQuery] int warehouseId, [FromQuery] decimal qty, CancellationToken ct) + => Ok(await _tracking.SuggestBatchesAsync(itemId, warehouseId, qty, ct)); +} diff --git a/Backend/ERPCore/Controllers/WarrantiesController.cs b/Backend/ERPCore/Controllers/WarrantiesController.cs new file mode 100644 index 0000000..0a9cb25 --- /dev/null +++ b/Backend/ERPCore/Controllers/WarrantiesController.cs @@ -0,0 +1,30 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; +using ERPCore.Infra.Auth; +using ERPCore.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ERPCore.Controllers; + +/// +/// Customer warranty register (docs/ITEM_TRACKING_PLAN.md Phase 5). Warranties are created by +/// posting a sale, never directly — this controller only reads. +/// +[Route("api/v1/warranties")] +[Authorize(Policy = NavPolicies.Prefix + "sales")] +public sealed class WarrantiesController : ApiControllerBase +{ + private readonly IItemTrackingService _tracking; + + public WarrantiesController(IItemTrackingService tracking) => _tracking = tracking; + + [HttpGet] + [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] int? customerId, [FromQuery] int? itemId, [FromQuery] string? serialNo, + [FromQuery] WarrantyStatus? status, [FromQuery] int? expiringWithinDays, [FromQuery] bool? expired, + [FromQuery] PageQuery query, CancellationToken ct) + => Ok(await _tracking.ListWarrantiesAsync(query, customerId, itemId, serialNo, status, expiringWithinDays, expired, ct)); +} diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs index 74ed6d1..f451205 100644 --- a/Backend/ERPCore/Domain/Entities/GrnLine.cs +++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs @@ -65,4 +65,12 @@ public class GrnLine /// Empty for a non-warranty item. ///
public ICollection WarrantyNumbers { get; set; } = new List(); + + /// + /// One row per received unit when is + /// — count must equal . For a serial item + /// under warranty the serial is the warranty identity, so it carries no + /// . Empty otherwise. + /// + public ICollection Serials { get; set; } = new List(); } diff --git a/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs b/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs new file mode 100644 index 0000000..e1bfca6 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/GrnLineSerial.cs @@ -0,0 +1,22 @@ +namespace ERPCore.Domain.Entities; + +/// +/// A serial number captured on a Draft GRN line for a serial-tracked item +/// ( = ). The line must +/// carry exactly of these. The draft holds the numbers only — the +/// rows (and their qty-1 FIFO layers) are created when the GRN is +/// confirmed, so a draft that is never confirmed reserves nothing (docs/ITEM_TRACKING_PLAN.md Phase 2). +/// +public class GrnLineSerial +{ + public int GrnLineSerialId { get; set; } + + public int GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public string SerialNo { get; set; } = string.Empty; + + /// Set on confirm — the created for this number. + public int? SerialId { get; set; } + public Serial? Serial { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs index d8ff592..27aad21 100644 --- a/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs +++ b/Backend/ERPCore/Domain/Entities/SalesInvoiceLine.cs @@ -19,6 +19,13 @@ public class SalesInvoiceLine public int WarehouseId { get; set; } public Warehouse? Warehouse { get; set; } + /// Pins the line to one batch; null lets posting pick batches FEFO. + public int? BatchId { get; set; } + public Batch? Batch { get; set; } + + /// Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty). + public ICollection Serials { get; set; } = new List(); + public decimal UnitPrice { get; set; } public decimal BaseCost { get; set; } public string PriceSource { get; set; } = string.Empty; diff --git a/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs b/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs new file mode 100644 index 0000000..d545915 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesLineAllocation.cs @@ -0,0 +1,35 @@ +namespace ERPCore.Domain.Entities; + +/// +/// What a posted sales line actually issued: one row per consumed FIFO segment, carrying the +/// batch (FEFO-picked when the line named none) and, for serial items, the serial. The source +/// line is referenced polymorphically ( is a +/// value) — same convention as — +/// so invoices, slips and bundles share one table. Returns read it to restock into the same +/// batch/serial (docs/ITEM_TRACKING_PLAN.md Phases 4 and 6). +/// +public class SalesLineAllocation +{ + public int SalesLineAllocationId { get; set; } + + public string SourceDocType { get; set; } = string.Empty; + public int SourceDocId { get; set; } + public int SourceLineId { get; set; } + + public int ItemId { get; set; } + public int WarehouseId { get; set; } + + public int? BatchId { get; set; } + public Batch? Batch { get; set; } + + public int? SerialId { get; set; } + public Serial? Serial { get; set; } + + public decimal Qty { get; set; } + public decimal UnitCost { get; set; } + + /// Quantity of this allocation already taken back by sales returns. + public decimal QtyReturned { get; set; } + + public DateTime CreatedAt { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs b/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs new file mode 100644 index 0000000..691019d --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SalesLineSerial.cs @@ -0,0 +1,29 @@ +namespace ERPCore.Domain.Entities; + +/// +/// A serial chosen on a Draft sales-invoice line for a serial-tracked item — one row per unit +/// (count = Qty + FreeQty). Choosing does not reserve: posting re-checks each serial is still +/// in stock and consumes exactly its layer (docs/ITEM_TRACKING_PLAN.md Phase 4, D4). +/// +public class SalesInvoiceLineSerial +{ + public int SalesInvoiceLineSerialId { get; set; } + + public int SalesInvoiceLineId { get; set; } + public SalesInvoiceLine? SalesInvoiceLine { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } +} + +/// Sales-slip counterpart of . +public class SalesSlipLineSerial +{ + public int SalesSlipLineSerialId { get; set; } + + public int SalesSlipLineId { get; set; } + public SalesSlipLine? SalesSlipLine { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } +} diff --git a/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs b/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs index 191adc0..d59214d 100644 --- a/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs +++ b/Backend/ERPCore/Domain/Entities/SalesReturnLine.cs @@ -2,7 +2,9 @@ namespace ERPCore.Domain.Entities; /// /// Sales-return line referencing the original sales invoice line for traceability. -/// is in base UOM. +/// is in base UOM. is the batch the goods are restocked +/// into — the batch they were sold from (docs/ITEM_TRACKING_PLAN.md Phase 6); +/// lists the returned units of a serial-tracked item. /// public class SalesReturnLine { @@ -18,4 +20,21 @@ public class SalesReturnLine public Item? Item { get; set; } public decimal Qty { get; set; } + + public int? BatchId { get; set; } + public Batch? Batch { get; set; } + + public ICollection Serials { get; set; } = new List(); +} + +/// One serial taken back on a . +public class SalesReturnLineSerial +{ + public int SalesReturnLineSerialId { get; set; } + + public int ReturnLineId { get; set; } + public SalesReturnLine? ReturnLine { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs index 5497afe..3570250 100644 --- a/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs +++ b/Backend/ERPCore/Domain/Entities/SalesSlipLine.cs @@ -22,9 +22,16 @@ public class SalesSlipLine public int? BatchId { get; set; } public Batch? Batch { get; set; } + /// + /// Legacy single-serial pin, from before a line could carry several. Kept for old drafts and + /// still set when exactly one serial is chosen; is authoritative. + /// public int? SerialId { get; set; } public Serial? Serial { get; set; } + /// Chosen serials for a serial-tracked item (one per unit of Qty + FreeQty). + public ICollection Serials { get; set; } = new List(); + public decimal UnitPrice { get; set; } public decimal BaseCost { get; set; } public string PriceSource { get; set; } = string.Empty; diff --git a/Backend/ERPCore/Domain/Entities/Serial.cs b/Backend/ERPCore/Domain/Entities/Serial.cs index dbbf7a2..2276703 100644 --- a/Backend/ERPCore/Domain/Entities/Serial.cs +++ b/Backend/ERPCore/Domain/Entities/Serial.cs @@ -1,8 +1,13 @@ +using ERPCore.Domain.Enums; + namespace ERPCore.Domain.Entities; /// /// Serial number for a serial-tracked item across its lifecycle (FR-WH-04). -/// Model: docs/10 Part C.4. +/// Created when the GRN that received it is confirmed; each in-stock serial owns exactly one +/// FIFO layer of qty 1 (), so issuing a serial consumes that +/// layer. / are its current location and are +/// cleared once it leaves stock. Model: docs/10 Part C.4, docs/ITEM_TRACKING_PLAN.md Phase 1. /// public class Serial { @@ -12,8 +17,33 @@ public class Serial public Item? Item { get; set; } public string SerialNo { get; set; } = string.Empty; - public string Status { get; set; } = "InStock"; + public SerialStatus Status { get; set; } = SerialStatus.InStock; /// Optional scan code for barcode/RFID lookup (unique when set). public string? Barcode { get; set; } + + /// Current warehouse while in stock; null once sold/returned to vendor/scrapped. + public int? WarehouseId { get; set; } + public Warehouse? Warehouse { get; set; } + + public int? BinId { get; set; } + public Bin? Bin { get; set; } + + /// The GRN line that received this unit (provenance); null for count/adjustment finds. + public int? GrnLineId { get; set; } + public GrnLine? GrnLine { get; set; } + + public DateTime? ReceivedAt { get; set; } + + /// + /// Warranty coverage snapshotted from at receipt + /// (same rule as ); null for a + /// non-warranty item. The clock does not start here — it starts at sale + /// (). + /// + public int? WarrantyPeriodMonths { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + public uint RowVersion { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/SerialMovement.cs b/Backend/ERPCore/Domain/Entities/SerialMovement.cs new file mode 100644 index 0000000..a872c00 --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/SerialMovement.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// Append-only history of one serial: a row each time a document changes its status or +/// location (receipt, sale, return, transfer, adjustment, count). and +/// are the serial's state after the movement. The source +/// document is referenced polymorphically, as on . This is where serial +/// history is read from — the stock ledger keeps one row per document line, not per unit +/// (docs/ITEM_TRACKING_PLAN.md Phases 2 and 9). +/// +public class SerialMovement +{ + public int SerialMovementId { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } + + public string SourceDocType { get; set; } = string.Empty; + public int SourceDocId { get; set; } + + public SerialStatus Status { get; set; } + public int? FromWarehouseId { get; set; } + public int? WarehouseId { get; set; } + + public int UserId { get; set; } + public DateTime CreatedAt { get; set; } + + /// + /// Records 's current state — call after updating it. Linked by + /// navigation, so it works for a serial inserted in the same save. + /// + public static SerialMovement Of(Serial serial, int? fromWarehouseId, string sourceDocType, int sourceDocId, int userId, DateTime at) => new() + { + Serial = serial, + SerialId = serial.SerialId, + SourceDocType = sourceDocType, + SourceDocId = sourceDocId, + Status = serial.Status, + FromWarehouseId = fromWarehouseId, + WarehouseId = serial.WarehouseId, + UserId = userId, + CreatedAt = at + }; +} diff --git a/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs index 8bbc1ad..bcbcc84 100644 --- a/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs +++ b/Backend/ERPCore/Domain/Entities/StockAdjustmentLine.cs @@ -18,6 +18,7 @@ public class StockAdjustmentLine public int? BinId { get; set; } public int? BatchId { get; set; } public int? SerialId { get; set; } + public Serial? Serial { get; set; } public decimal QtyDelta { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/StockTransferLine.cs b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs index 240d815..c4266a3 100644 --- a/Backend/ERPCore/Domain/Entities/StockTransferLine.cs +++ b/Backend/ERPCore/Domain/Entities/StockTransferLine.cs @@ -32,4 +32,27 @@ public class StockTransferLine /// Quantity already received at the destination (partial-receive support). public decimal QtyReceived { get; set; } + + /// The units moved, for a serial-tracked item (one per unit of ). + public ICollection Serials { get; set; } = new List(); +} + +/// +/// One serial on a transfer line. / are captured from +/// its own source layer at dispatch, so the destination layer is recreated exactly; +/// is set when it arrives (partial receive names which serials). +/// +public class StockTransferLineSerial +{ + public int StockTransferLineSerialId { get; set; } + + public int TransferLineId { get; set; } + public StockTransferLine? TransferLine { get; set; } + + public int SerialId { get; set; } + public Serial? Serial { get; set; } + + public int? BatchId { get; set; } + public decimal? UnitCost { get; set; } + public DateTime? ReceivedAt { get; set; } } diff --git a/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs b/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs new file mode 100644 index 0000000..e5c2c3c --- /dev/null +++ b/Backend/ERPCore/Domain/Entities/WarrantyRegistration.cs @@ -0,0 +1,42 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Domain.Entities; + +/// +/// A customer's warranty, started when the sale that issued the goods is posted +/// (docs/ITEM_TRACKING_PLAN.md Phase 5, D3/D7) — stock waiting in a warehouse never consumes +/// warranty. For a serial item there is one row per serial ( set, +/// = 1); for a warranty item that is not serial-tracked there is one row per +/// sales line covering its . is the coverage +/// snapshotted at receipt (), else the item's period. +/// +public class WarrantyRegistration +{ + public int WarrantyRegistrationId { get; set; } + + public int ItemId { get; set; } + public Item? Item { get; set; } + + public int? SerialId { get; set; } + public Serial? Serial { get; set; } + + public int CustomerId { get; set; } + public Customer? Customer { get; set; } + + public string SourceDocType { get; set; } = string.Empty; + public int SourceDocId { get; set; } + public int SourceLineId { get; set; } + + public decimal Qty { get; set; } + + public DateOnly StartDate { get; set; } + public int PeriodMonths { get; set; } + public DateOnly EndDate { get; set; } + + public WarrantyStatus Status { get; set; } = WarrantyStatus.Active; + + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + + public uint RowVersion { get; set; } +} diff --git a/Backend/ERPCore/Domain/Enums/SerialStatus.cs b/Backend/ERPCore/Domain/Enums/SerialStatus.cs new file mode 100644 index 0000000..32c43db --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/SerialStatus.cs @@ -0,0 +1,25 @@ +namespace ERPCore.Domain.Enums; + +/// +/// Lifecycle state of one serial-tracked unit (FR-WH-04). Stored as a string — the values +/// InStock/Sold written before this enum existed stay valid as-is. +/// Only is issuable. +/// +public enum SerialStatus +{ + InStock, + Reserved, + /// Dispatched on a stock transfer, not yet received — in no warehouse. + InTransit, + Sold, + ReturnedByCustomer, + ReturnedToVendor, + Damaged, + Scrapped, + /// + /// Left stock without being named, while serial numbers were switched off in Product + /// Configuration — the unit was issued oldest-first like untracked stock, so which + /// document took it is not recorded against the serial. + /// + IssuedUntracked +} diff --git a/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs b/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs new file mode 100644 index 0000000..ab53b54 --- /dev/null +++ b/Backend/ERPCore/Domain/Enums/WarrantyStatus.cs @@ -0,0 +1,10 @@ +namespace ERPCore.Domain.Enums; + +/// State of a . Stored as a string. +public enum WarrantyStatus +{ + Active, + /// The unit came back on a sales return — the warranty no longer applies. + Voided, + Claimed +} diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs index 84b47cd..9fad1dc 100644 --- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs +++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs @@ -7,12 +7,16 @@ namespace ERPCore.Dtos.Grn; public sealed record GrnLineWarrantyNumberDto(string WarrantyNo, int WarrantyPeriodMonths); +/// A serial captured on a GRN line; is set once the GRN is confirmed. +public sealed record GrnLineSerialDto(string SerialNo, int? SerialId); + public sealed record GrnLineDto( int GrnLineId, int? PoLineId, int ItemId, int? BinId, decimal Qty, decimal UnitCost, decimal? PoUnitPrice, decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount, decimal ReceivedValue, decimal LineTotal, decimal PriceVariance, - HoldStatus HoldStatus, int? BatchId, IReadOnlyList WarrantyNumbers); + HoldStatus HoldStatus, int? BatchId, IReadOnlyList WarrantyNumbers, + string? BatchNo, DateOnly? BatchExpiryDate, IReadOnlyList Serials); public sealed record GrnDto( int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, int BranchId, GrnStatus Status, @@ -70,9 +74,15 @@ public sealed class CreateGrnLineInput public BatchInput? Batch { get; set; } /// /// Required, one per unit (count must equal ), when the item is - /// warranty-tracked (Item.Warranty == Warranty.Warranty). Ignored otherwise. + /// warranty-tracked (Item.Warranty == Warranty.Warranty) and not serial-tracked — + /// a serial item's serial is its warranty identity. Ignored otherwise. /// public List? WarrantyNumbers { get; set; } + /// + /// Required, one per unit (count must equal , so Qty must be whole), when the + /// item is serial-tracked (Item.TrackingMode == Serial). Rejected for any other item. + /// + public List? SerialNumbers { get; set; } } public sealed class CreateGrnRequest diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs index d6fc74a..b8591c8 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs @@ -25,6 +25,12 @@ public sealed class CreatePurchaseReturnLineInput public int? GrnLineId { get; set; } [Required] public int ItemId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// + /// Serials sent back, for a serial-tracked item — one per unit, each at the return warehouse + /// (InStock or Damaged; for a rejected GRN line, received on that line). They become + /// ReturnedToVendor. Rejected for any other item. + /// + public List? SerialIds { get; set; } } public sealed class CreatePurchaseReturnRequest diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index 302b04f..52a51b2 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -7,7 +7,11 @@ public sealed record SalesInvoiceLineDto( int SalesInvoiceLineId, int ItemId, string Description, decimal Qty, decimal FreeQty, int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, - decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId); + decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId, + int? BatchId, IReadOnlyList Serials); + +/// A serial chosen on (or sold by) a sales line. +public sealed record SalesLineSerialDto(int SerialId, string SerialNo); public sealed record SalesInvoiceTotalsDto( decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, @@ -95,6 +99,13 @@ public sealed class CreateSalesInvoiceLineRequest [Range(0, 100)] public decimal? TaxPct { get; set; } public bool IsFreeIssue { get; set; } public int? ParentLineId { get; set; } + /// Pins the line to one batch (must belong to the item, not expired). Omit to let posting pick FEFO. + public int? BatchId { get; set; } + /// + /// Serials sold on this line — required for a serial-tracked item, one per unit of + /// Qty + FreeQty, each InStock at the line warehouse. Rejected for any other item. + /// + public List? SerialIds { get; set; } } public sealed class CreateSalesInvoiceRequest diff --git a/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs index b619d4f..3908393 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesReturnDtos.cs @@ -5,7 +5,17 @@ namespace ERPCore.Dtos.Sales; // Responses ----------------------------------------------------------------- -public sealed record SalesReturnLineDto(int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty); +public sealed record SalesReturnLineDto( + int ReturnLineId, int? SalesInvoiceLineId, int ItemId, decimal Qty, + int? BatchId, IReadOnlyList Serials); + +/// +/// What one posted sales invoice line issued, net of returns — the batches/serials a return +/// against it can take back. +/// +public sealed record SalesLineAllocationDto( + int? BatchId, string? BatchNo, DateOnly? ExpiryDate, int? SerialId, string? SerialNo, + decimal Qty, decimal QtyReturned); public sealed record SalesReturnDto( int ReturnId, string DocNo, int CustomerId, int WarehouseId, int ReasonCodeId, ReturnStatus Status, @@ -22,7 +32,8 @@ public sealed record SalesReturnSummaryDto( /// whatever has already been returned against it. The invoice line's own Qty /// is never mutated by a return, so this is computed on read from return history. ///
-public sealed record SalesInvoiceLineRemainingDto(int SalesInvoiceLineId, decimal RemainingQty); +public sealed record SalesInvoiceLineRemainingDto( + int SalesInvoiceLineId, decimal RemainingQty, IReadOnlyList Allocations); // Requests -------------------------------------------------------------------- @@ -32,6 +43,16 @@ public sealed class CreateSalesReturnLineInput public int? SalesInvoiceLineId { get; set; } [Required] public int ItemId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// + /// Batch to restock into. Optional when the invoice line issued a single batch (it is used); + /// required for a batch-tracked item otherwise, and must be one the line issued. + /// + public int? BatchId { get; set; } + /// + /// Returned serials of a serial-tracked item — one per unit, each Sold (and, with an invoice + /// line, sold on that line). Rejected for any other item. + /// + public List? SerialIds { get; set; } } public sealed class CreateSalesReturnRequest @@ -40,5 +61,10 @@ public sealed class CreateSalesReturnRequest [Required] public int WarehouseId { get; set; } /// Nullable so an omitted value is a distinct REASON_CODE_REQUIRED error. public int? ReasonCodeId { get; set; } + /// + /// Returned serials become Damaged (not resaleable) instead of InStock. Omit to + /// derive it from the reason: DEF (Defective) and QREJ (Quality Reject) mean damaged. + /// + public bool? MarkDamaged { get; set; } [Required, MinLength(1)] public List Lines { get; set; } = new(); } diff --git a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs index a65bd4e..9c80883 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesSlipDtos.cs @@ -8,7 +8,7 @@ public sealed record SalesSlipLineDto( int WarehouseId, decimal UnitPrice, decimal BaseCost, string PriceSource, decimal DiscountPct, decimal DiscountAmount, SalesDiscountMode DiscountMode, decimal NetUnitPrice, decimal LineTotal, decimal TaxPct, decimal TaxAmount, bool IsFreeIssue, int? ParentLineId, - int? BatchId, int? SerialId); + int? BatchId, int? SerialId, IReadOnlyList Serials); public sealed record SalesSlipTotalsDto( decimal Subtotal, decimal DiscountTotal, decimal FreeQtyTotal, decimal TaxTotal, decimal GrandTotal, @@ -62,8 +62,13 @@ public sealed class CreateSalesSlipLineRequest public int? ParentLineId { get; set; } /// Pins the line to a specific batch (must belong to ), consumed via FIFO. public int? BatchId { get; set; } - /// Pins the line to a specific serial (must belong to ); marked Sold on posting. + /// Legacy single-serial pin — treated as SerialIds = [SerialId] when is empty. public int? SerialId { get; set; } + /// + /// Serials sold on this line — required for a serial-tracked item, one per unit of + /// Qty + FreeQty, each InStock at the line warehouse. Marked Sold on posting. + /// + public List? SerialIds { get; set; } } public sealed class CreateSalesSlipRequest diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs index 0163c66..1efd1a1 100644 --- a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs @@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Stock; // Responses (docs/11 §5.5) ------------------------------------------------------ -public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta); +public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta, int? SerialId); public sealed record AdjustmentDto( int AdjustmentId, string DocNo, int WarehouseId, int BranchId, int ReasonCodeId, AdjustmentStatus Status, @@ -26,6 +26,16 @@ public sealed class CreateAdjustmentLineInput public int? BatchId { get; set; } /// Signed base-UOM delta: negative consumes FIFO layers, positive adds stock. public decimal QtyDelta { get; set; } + /// + /// Negative delta on a serial-tracked item: the serials written off (InStock or Damaged at + /// the warehouse, one per unit) — they become Scrapped. + /// + public List? SerialIds { get; set; } + /// + /// Positive delta on a serial-tracked item: the serial numbers found (one per unit) — new + /// numbers are created, a known serial that is out of stock is brought back InStock. + /// + public List? SerialNos { get; set; } } public sealed class CreateAdjustmentRequest diff --git a/Backend/ERPCore/Dtos/Stock/TrackingDtos.cs b/Backend/ERPCore/Dtos/Stock/TrackingDtos.cs new file mode 100644 index 0000000..c39ee57 --- /dev/null +++ b/Backend/ERPCore/Dtos/Stock/TrackingDtos.cs @@ -0,0 +1,40 @@ +using ERPCore.Domain.Enums; + +namespace ERPCore.Dtos.Stock; + +// Item tracking lookups and registers (docs/ITEM_TRACKING_PLAN.md Phases 5, 8, 9). + +/// Product Configuration tracking switches, as every screen needs them. +public sealed record TrackingSettingsDto(bool SerialNumbersEnabled); + +/// An issuable serial, for sale/transfer/return pickers. +public sealed record AvailableSerialDto(int SerialId, string SerialNo, int? BatchId, string? BatchNo, SerialStatus Status); + +/// Issuable stock of one batch at a warehouse (BatchId null = unbatched stock). +public sealed record AvailableBatchDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry); + +/// A FEFO pick: of . +public sealed record BatchSuggestionDto(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty); + +public sealed record SerialSummaryDto( + int SerialId, int ItemId, string Sku, string ItemName, string SerialNo, SerialStatus Status, + int? WarehouseId, string? WarehouseName, int? BinId, DateTime? ReceivedAt, + int? GrnId, string? GrnDocNo, int? WarrantyPeriodMonths); + +public sealed record SerialMovementDto( + int SerialMovementId, string SourceDocType, int SourceDocId, SerialStatus Status, + int? FromWarehouseId, int? WarehouseId, int UserId, DateTime CreatedAt); + +public sealed record SerialDetailDto( + SerialSummaryDto Serial, IReadOnlyList History, IReadOnlyList Warranties); + +public sealed record WarrantyRegistrationDto( + int WarrantyRegistrationId, int ItemId, string Sku, string ItemName, int? SerialId, string? SerialNo, + int CustomerId, string CustomerName, string SourceDocType, int SourceDocId, int SourceLineId, + decimal Qty, DateOnly StartDate, int PeriodMonths, DateOnly EndDate, WarrantyStatus Status, + int DaysRemaining, bool IsExpired); + +/// Stock on hand per batch (with expiry), for the batch / expiry report. +public sealed record BatchStockDto( + int ItemId, string Sku, string ItemName, int WarehouseId, string WarehouseName, + int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty, int? DaysToExpiry, bool IsExpired); diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs index 08cf57f..ad6c02e 100644 --- a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs +++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs @@ -6,7 +6,11 @@ namespace ERPCore.Dtos.Stock; // Responses (docs/11 §5.4) ------------------------------------------------------ public sealed record TransferLineDto( - int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived); + int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived, + IReadOnlyList Serials); + +/// A serial on a transfer line; once it has arrived. +public sealed record TransferLineSerialDto(int SerialId, string SerialNo, bool Received); public sealed record TransferDto( int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId, int BranchId, @@ -34,8 +38,11 @@ public sealed class CreateTransferLineInput [Required] public int ItemId { get; set; } public int? SrcBinId { get; set; } public int? DestBinId { get; set; } + /// Batch to move. Omitted for a batch-tracked item, the line is split FEFO across batches. public int? BatchId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// Serials to move — required for a serial-tracked item, one per unit, each InStock at the source. + public List? SerialIds { get; set; } } public sealed class CreateTransferRequest @@ -52,6 +59,11 @@ public sealed class ReceiveTransferLineInput { [Required] public int TransferLineId { get; set; } [Range(0.0001, double.MaxValue)] public decimal Qty { get; set; } + /// + /// For a serial line: which in-transit serials arrived (count = Qty). May be omitted when + /// receiving everything still outstanding on the line. + /// + public List? SerialIds { get; set; } } public sealed class ReceiveTransferRequest diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs index db9ea87..6ea7b5b 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/BatchSerialConfiguration.cs @@ -34,7 +34,9 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration builder.HasKey(s => s.SerialId); builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100); - builder.Property(s => s.Status).IsRequired().HasMaxLength(20); + // String-stored enum: rows written as "InStock"/"Sold" before SerialStatus existed + // read back unchanged. + builder.Property(s => s.Status).HasConversion().IsRequired().HasMaxLength(20); builder.Property(s => s.Barcode).HasMaxLength(100); builder.HasIndex(s => s.Barcode).IsUnique(); @@ -43,7 +45,12 @@ public sealed class SerialConfiguration : IEntityTypeConfiguration .WithMany() .HasForeignKey(s => s.ItemId) .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(s => s.Warehouse).WithMany().HasForeignKey(s => s.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(s => s.Bin).WithMany().HasForeignKey(s => s.BinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(s => s.GrnLine).WithMany().HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Restrict); builder.HasIndex(s => new { s.ItemId, s.SerialNo }).IsUnique(); + builder.HasIndex(s => new { s.ItemId, s.Status }); + builder.HasIndex(s => new { s.WarehouseId, s.ItemId, s.Status }); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs index ea1c232..fc71fe8 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs @@ -74,3 +74,23 @@ public sealed class GrnLineWarrantyNumberConfiguration : IEntityTypeConfiguratio builder.HasIndex(w => new { w.GrnLineId, w.WarrantyNo }).IsUnique(); } } + +public sealed class GrnLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("grn_line_serials"); + builder.HasKey(s => s.GrnLineSerialId); + + builder.Property(s => s.SerialNo).IsRequired().HasMaxLength(100); + + builder.HasOne(s => s.GrnLine).WithMany(l => l.Serials) + .HasForeignKey(s => s.GrnLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(s => s.Serial).WithMany() + .HasForeignKey(s => s.SerialId).OnDelete(DeleteBehavior.Restrict); + + // Global uniqueness lives on serials (ItemId, SerialNo); this only stops a number + // being entered twice on one line. + builder.HasIndex(s => new { s.GrnLineId, s.SerialNo }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTrackingConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTrackingConfiguration.cs new file mode 100644 index 0000000..d60ea1e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTrackingConfiguration.cs @@ -0,0 +1,118 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +// Sales-side serial/batch tracking and warranty (docs/ITEM_TRACKING_PLAN.md Phases 4–5). + +public sealed class SalesInvoiceLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_invoice_line_serials"); + builder.HasKey(x => x.SalesInvoiceLineSerialId); + + builder.HasOne(x => x.SalesInvoiceLine).WithMany(l => l.Serials) + .HasForeignKey(x => x.SalesInvoiceLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(x => x.Serial).WithMany() + .HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => new { x.SalesInvoiceLineId, x.SerialId }).IsUnique(); + } +} + +public sealed class SalesSlipLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_slip_line_serials"); + builder.HasKey(x => x.SalesSlipLineSerialId); + + builder.HasOne(x => x.SalesSlipLine).WithMany(l => l.Serials) + .HasForeignKey(x => x.SalesSlipLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(x => x.Serial).WithMany() + .HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => new { x.SalesSlipLineId, x.SerialId }).IsUnique(); + } +} + +public sealed class SalesLineAllocationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_line_allocations"); + builder.HasKey(x => x.SalesLineAllocationId); + + builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50); + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.QtyReturned).HasPrecision(18, 4); + builder.Property(x => x.UnitCost).HasPrecision(18, 6); + + builder.HasOne(x => x.Batch).WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId, x.SourceLineId }); + builder.HasIndex(x => x.SerialId); + builder.HasIndex(x => x.BatchId); + } +} + +public sealed class StockTransferLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stock_transfer_line_serials"); + builder.HasKey(x => x.StockTransferLineSerialId); + + builder.Property(x => x.UnitCost).HasPrecision(18, 6); + + builder.HasOne(x => x.TransferLine).WithMany(l => l.Serials) + .HasForeignKey(x => x.TransferLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(x => x.Serial).WithMany() + .HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne().WithMany().HasForeignKey(x => x.BatchId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => new { x.TransferLineId, x.SerialId }).IsUnique(); + } +} + +public sealed class SerialMovementConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("serial_movements"); + builder.HasKey(x => x.SerialMovementId); + + builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50); + builder.Property(x => x.Status).HasConversion().IsRequired().HasMaxLength(20); + + builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => new { x.SerialId, x.SerialMovementId }); + builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId }); + } +} + +public sealed class WarrantyRegistrationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("warranty_registrations"); + builder.HasKey(x => x.WarrantyRegistrationId); + + builder.Property(x => x.SourceDocType).IsRequired().HasMaxLength(50); + builder.Property(x => x.Qty).HasPrecision(18, 4); + builder.Property(x => x.Status).HasConversion().IsRequired().HasMaxLength(20); + + builder.HasOne(x => x.Item).WithMany().HasForeignKey(x => x.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Serial).WithMany().HasForeignKey(x => x.SerialId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(x => x.SerialId); + builder.HasIndex(x => x.CustomerId); + builder.HasIndex(x => new { x.SourceDocType, x.SourceDocId }); + builder.HasIndex(x => new { x.Status, x.EndDate }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs index f759bdc..4c71e15 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesInvoiceConfiguration.cs @@ -111,6 +111,11 @@ public sealed class SalesInvoiceLineConfiguration : IEntityTypeConfiguration x.WarehouseId) .OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Batch) + .WithMany() + .HasForeignKey(x => x.BatchId) + .OnDelete(DeleteBehavior.Restrict); + builder.Property(x => x.PriceSource).HasMaxLength(50); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs index f6d0c34..b569eec 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalesReturnConfiguration.cs @@ -37,5 +37,20 @@ public sealed class SalesReturnLineConfiguration : IEntityTypeConfiguration l.Return).WithMany(r => r.Lines).HasForeignKey(l => l.ReturnId).OnDelete(DeleteBehavior.Cascade); builder.HasOne(l => l.SalesInvoiceLine).WithMany().HasForeignKey(l => l.SalesInvoiceLineId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Batch).WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class SalesReturnLineSerialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("sales_return_line_serials"); + builder.HasKey(s => s.SalesReturnLineSerialId); + + builder.HasOne(s => s.ReturnLine).WithMany(l => l.Serials).HasForeignKey(s => s.ReturnLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(s => s.Serial).WithMany().HasForeignKey(s => s.SerialId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(s => new { s.ReturnLineId, s.SerialId }).IsUnique(); } } diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs index 549b0af..2299db2 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/StockAdjustmentConfiguration.cs @@ -40,6 +40,6 @@ public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration< builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict); builder.HasOne().WithMany().HasForeignKey(l => l.BinId).OnDelete(DeleteBehavior.Restrict); builder.HasOne().WithMany().HasForeignKey(l => l.BatchId).OnDelete(DeleteBehavior.Restrict); - builder.HasOne().WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(l => l.Serial).WithMany().HasForeignKey(l => l.SerialId).OnDelete(DeleteBehavior.Restrict); } } diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index fef8e6e..95105d4 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -93,6 +93,14 @@ public class ErpDbContext : DbContext // --- Batch / Serial (docs/10 Part C.4) --- public DbSet Batches => Set(); public DbSet Serials => Set(); + public DbSet GrnLineSerials => Set(); + public DbSet SalesInvoiceLineSerials => Set(); + public DbSet SalesSlipLineSerials => Set(); + public DbSet SalesLineAllocations => Set(); + public DbSet WarrantyRegistrations => Set(); + public DbSet SalesReturnLineSerials => Set(); + public DbSet SerialMovements => Set(); + public DbSet StockTransferLineSerials => Set(); // --- Stock core: FIFO layers + immutable ledger (docs/10 Part C.5) --- public DbSet StockLayers => Set(); diff --git a/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.Designer.cs b/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.Designer.cs new file mode 100644 index 0000000..6b767ef --- /dev/null +++ b/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.Designer.cs @@ -0,0 +1,9481 @@ +// +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("20261007054513_AddItemTrackingSerialsWarranty")] + partial class AddItemTrackingSerialsWarranty + { + /// + 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("PostalCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ProfilePhotoPath") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReportingManagerId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("TaxIdentificationNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WorkShiftId") + .HasColumnType("int"); + + b.HasKey("EmployeeId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("DesignationId"); + + b.HasIndex("Email"); + + b.HasIndex("EmployeeCode") + .IsUnique(); + + b.HasIndex("EmploymentTypeId"); + + b.HasIndex("ReportingManagerId"); + + b.HasIndex("Status"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("WorkShiftId"); + + b.ToTable("hr_employees", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.Property("EmployeeBankDetailId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeBankDetailId")); + + b.Property("AccountHolderName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BankName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("BranchName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("SwiftCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("EmployeeBankDetailId"); + + b.HasIndex("EmployeeId"); + + b.ToTable("hr_employee_bank_details", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.Property("EmployeeDocumentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeDocumentId")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("ExpiryDate") + .HasColumnType("datetime(6)"); + + b.Property("HrDocumentTypeId") + .HasColumnType("int"); + + b.Property("IssueDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UploadedBy") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime(6)"); + + b.Property("VerifiedBy") + .HasColumnType("int"); + + b.HasKey("EmployeeDocumentId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("ExpiryDate"); + + b.HasIndex("HrDocumentTypeId"); + + b.ToTable("hr_employee_documents", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Property("EmployeeLoanId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeLoanId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("InstallmentAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("InterestRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("LoanKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("NumberOfInstallments") + .HasColumnType("int"); + + b.Property("OutstandingBalance") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StartMonth") + .HasColumnType("int"); + + b.Property("StartYear") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("EmployeeLoanId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("Status"); + + b.ToTable("hr_employee_loans", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Property("EmployeeSalaryStructureId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeSalaryStructureId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("BasicSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("varchar(3)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("EmployeeSalaryStructureId"); + + b.HasIndex("EmployeeId", "EffectiveTo"); + + b.ToTable("hr_employee_salary_structures", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.Property("EmployeeSalaryStructureLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmployeeSalaryStructureLineId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmployeeSalaryStructureId") + .HasColumnType("int"); + + b.Property("SalaryComponentId") + .HasColumnType("int"); + + b.HasKey("EmployeeSalaryStructureLineId"); + + b.HasIndex("EmployeeSalaryStructureId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_employee_salary_structure_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmploymentType", b => + { + b.Property("EmploymentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EmploymentTypeId")); + + 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("EmploymentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_employment_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Property("GrnId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("PoId") + .HasColumnType("int"); + + b.Property("PostedAt") + .HasColumnType("datetime(6)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("GrnId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("PoId"); + + b.HasIndex("PurchaseInvoiceId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("grns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("AllowDiscounts") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowVat") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowWarehouseSelection") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("DefaultWarehouseId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("ConfigId"); + + b.HasIndex("DefaultWarehouseId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("grn_config", null, t => + { + t.HasCheckConstraint("ck_grn_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Property("GrnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("GrnId") + .HasColumnType("int"); + + b.Property("HoldStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("PoLineId") + .HasColumnType("int"); + + b.Property("PoUnitPrice") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceivedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("VatAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VatPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.HasKey("GrnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("GrnId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoLineId"); + + b.ToTable("grn_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b => + { + b.Property("GrnLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineSerialId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("GrnLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("GrnLineId", "SerialNo") + .IsUnique(); + + b.ToTable("grn_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => + { + b.Property("GrnLineWarrantyNumberId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineWarrantyNumberId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("WarrantyNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + + b.HasKey("GrnLineWarrantyNumberId"); + + b.HasIndex("GrnLineId", "WarrantyNo") + .IsUnique(); + + b.ToTable("grn_line_warranty_numbers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b => + { + b.Property("HrDocumentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HrDocumentTypeId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpiryTracked") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequiredAtOnboarding") + .HasColumnType("tinyint(1)"); + + 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("HrDocumentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_document_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Property("ItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemId")); + + b.Property("Barcode") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("BaseUomId") + .HasColumnType("int"); + + b.Property("BrandId") + .HasColumnType("int"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContentBaseQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ContentBaseUnit") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ContentQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ContentUnit") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DefaultVendorId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SellingFlatPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SellingMaxPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SellingMinPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Sku") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("StockNature") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SubCategoryId") + .HasColumnType("int"); + + b.Property("TaxRateId") + .HasColumnType("int"); + + b.Property("TrackingMode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Warranty") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("NonWarranty"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + + b.HasKey("ItemId"); + + b.HasIndex("Barcode") + .IsUnique(); + + b.HasIndex("BaseUomId"); + + b.HasIndex("BrandId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("DefaultVendorId"); + + b.HasIndex("Sku") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("SubCategoryId"); + + b.HasIndex("TaxRateId"); + + b.ToTable("items", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b => + { + b.Property("ItemImageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemImageId")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("varchar(260)"); + + b.Property("UploadedAt") + .HasColumnType("datetime(6)"); + + b.Property("UploadedBy") + .HasColumnType("int"); + + b.HasKey("ItemImageId"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("item_images", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.Property("ReorderId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReorderId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("ReorderPoint") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReorderQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("ReorderId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId") + .IsUnique(); + + b.ToTable("item_reorders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b => + { + b.Property("ItemTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ItemTypeId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsMeasurable") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + 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("ItemTypeId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("item_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b => + { + b.Property("JournalId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("JournalId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CreditAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("DebitAccount") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.HasKey("JournalId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("journal_entry_stubs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.Property("LeaveBalanceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveBalanceId")); + + b.Property("AdjustmentDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("CarriedForwardDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EntitledDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("LeaveTypeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("TakenDays") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Year") + .HasColumnType("int"); + + b.HasKey("LeaveBalanceId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("EmployeeId", "LeaveTypeId", "Year") + .IsUnique(); + + b.ToTable("hr_leave_balances", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.Property("LeaveRequestId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveRequestId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DaysCount") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("LeaveTypeId") + .HasColumnType("int"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StartDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("LeaveRequestId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("Status"); + + b.HasIndex("StartDate", "EndDate"); + + b.ToTable("hr_leave_requests", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveType", b => + { + b.Property("LeaveTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LeaveTypeId")); + + b.Property("AccrualPerYear") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("CarryForwardAllowed") + .HasColumnType("tinyint(1)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CountsAsNoPay") + .HasColumnType("tinyint(1)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsPaid") + .HasColumnType("tinyint(1)"); + + b.Property("MaxCarryForwardDays") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RequiresApproval") + .HasColumnType("tinyint(1)"); + + 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("LeaveTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_leave_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.Property("LoanInstallmentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LoanInstallmentId")); + + b.Property("DueMonth") + .HasColumnType("int"); + + b.Property("DueYear") + .HasColumnType("int"); + + b.Property("EmployeeLoanId") + .HasColumnType("int"); + + b.Property("InstallmentNumber") + .HasColumnType("int"); + + b.Property("PaidAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("ScheduledAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("LoanInstallmentId"); + + b.HasIndex("EmployeeLoanId"); + + b.HasIndex("PayrollRunId"); + + b.HasIndex("DueYear", "DueMonth"); + + b.ToTable("hr_loan_installments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b => + { + b.Property("LoginActivityId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LoginActivityId")); + + b.Property("DeviceLabel") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("LoginAt") + .HasColumnType("datetime(6)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("LoginActivityId"); + + b.HasIndex("UserId", "LoginAt"); + + b.ToTable("login_activities", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Property("NavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.HasKey("NavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("nav_items", (string)null); + + b.HasData( + new + { + NavItemId = 1, + Code = "dashboard", + Href = "/dashboard", + Label = "Dashboard", + SortOrder = 1, + Status = "Active" + }, + new + { + NavItemId = 2, + Code = "products", + Href = "/dashboard/products", + Label = "Products", + SortOrder = 2, + Status = "Active" + }, + new + { + NavItemId = 4, + Code = "procurement", + Href = "/dashboard/procurement", + Label = "Procurement", + SortOrder = 4, + Status = "Active" + }, + new + { + NavItemId = 5, + Code = "receiving", + Href = "/dashboard/receiving/grn", + Label = "Receiving", + SortOrder = 5, + Status = "Active" + }, + new + { + NavItemId = 6, + Code = "stock", + Href = "/dashboard/stock", + Label = "Stock", + SortOrder = 6, + Status = "Active" + }, + new + { + NavItemId = 7, + Code = "warehouses", + Href = "/dashboard/warehouse", + Label = "Warehouses", + SortOrder = 7, + Status = "Active" + }, + new + { + NavItemId = 8, + Code = "orders", + Href = "/dashboard/orders", + Label = "Orders", + SortOrder = 8, + Status = "Active" + }, + new + { + NavItemId = 9, + Code = "settings", + Href = "/dashboard/settings", + Label = "Settings", + SortOrder = 9, + Status = "Active" + }, + new + { + NavItemId = 10, + Code = "help", + Href = "/dashboard/help", + Label = "Help", + SortOrder = 10, + Status = "Active" + }, + new + { + NavItemId = 11, + Code = "ledgers", + Href = "/dashboard/ledgers", + Label = "Ledgers", + SortOrder = 11, + Status = "Active" + }, + new + { + NavItemId = 12, + Code = "accounts", + Href = "/dashboard/accounts", + Label = "Accounts", + SortOrder = 12, + Status = "Active" + }, + new + { + NavItemId = 13, + Code = "sales", + Href = "/dashboard/sales", + Label = "Sales", + SortOrder = 13, + Status = "Active" + }, + new + { + NavItemId = 14, + Code = "production", + Href = "/dashboard/production", + Label = "Production", + SortOrder = 14, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Notification", b => + { + b.Property("NotificationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ModuleKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RelatedEntityId") + .HasColumnType("int"); + + b.Property("RelatedEntityType") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("NotificationId"); + + b.HasIndex("CreatedAt"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.Property("NotificationConfigId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("NotificationConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("notification_config", null, t => + { + t.HasCheckConstraint("ck_notification_config_singleton", "`NotificationConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b => + { + b.Property("NotificationConfigModuleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationConfigModuleId")); + + b.Property("EmailEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("InAppEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ModuleKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NotificationConfigId") + .HasColumnType("int"); + + b.Property("SmsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.HasKey("NotificationConfigModuleId"); + + b.HasIndex("ModuleKey") + .IsUnique(); + + b.HasIndex("NotificationConfigId"); + + b.ToTable("notification_config_modules", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationRecipient", b => + { + b.Property("NotificationRecipientId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("NotificationRecipientId")); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("NotificationId") + .HasColumnType("int"); + + b.Property("ReadAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("NotificationRecipientId"); + + b.HasIndex("NotificationId"); + + b.HasIndex("UserId", "IsRead", "NotificationId"); + + b.ToTable("notification_recipients", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.Property("SequenceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SequenceId")); + + b.Property("BranchId") + .HasColumnType("int") + .HasColumnName("branch_id"); + + b.Property("DocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)") + .HasColumnName("doc_type"); + + b.Property("LastNumber") + .HasColumnType("int") + .HasColumnName("last_number"); + + b.Property("Year") + .HasColumnType("int") + .HasColumnName("year"); + + b.HasKey("SequenceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocType", "BranchId", "Year") + .IsUnique(); + + b.ToTable("number_sequences", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Property("PayrollLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollLineId")); + + b.Property("AbsentDays") + .HasColumnType("int"); + + b.Property("BasicSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("EpfEmployeeAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EpfEmployerAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("EtfEmployerAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("GrossSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("LateDeductionAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("LateMinutesTotal") + .HasColumnType("int"); + + b.Property("LeaveDays") + .HasColumnType("int"); + + b.Property("LoanDeductionAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("NetSalary") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("NoPayAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("OtMinutesTotal") + .HasColumnType("int"); + + b.Property("OtherDeductionsAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("OvertimeAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("int"); + + b.Property("PresentDays") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("TaxAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("TotalAllowances") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("WorkingDays") + .HasColumnType("int"); + + b.HasKey("PayrollLineId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("PayrollRunId", "EmployeeId") + .IsUnique(); + + b.ToTable("hr_payroll_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.Property("PayrollLineComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollLineComponentId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ComponentCategory") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("PayrollLineId") + .HasColumnType("int"); + + b.Property("SalaryComponentId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("PayrollLineComponentId"); + + b.HasIndex("PayrollLineId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_payroll_line_components", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Property("PayrollRunId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollRunId")); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GeneratedAt") + .HasColumnType("datetime(6)"); + + b.Property("GeneratedBy") + .HasColumnType("int"); + + b.Property("LockedAt") + .HasColumnType("datetime(6)"); + + b.Property("LockedBy") + .HasColumnType("int"); + + b.Property("PeriodMonth") + .HasColumnType("int"); + + b.Property("PeriodYear") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UnlockReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("UnlockedAt") + .HasColumnType("datetime(6)"); + + b.Property("UnlockedBy") + .HasColumnType("int"); + + b.HasKey("PayrollRunId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("PeriodYear", "PeriodMonth", "BranchId"); + + b.ToTable("hr_payroll_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollStatutorySetting", b => + { + b.Property("PayrollStatutorySettingId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayrollStatutorySettingId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("EpfEmployeeRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("EpfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("EtfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("OtMultiplierDefault") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.HasKey("PayrollStatutorySettingId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_payroll_statutory_settings", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.Property("PayslipId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PayslipId")); + + b.Property("GeneratedAt") + .HasColumnType("datetime(6)"); + + b.Property("PayrollLineId") + .HasColumnType("int"); + + b.Property("ReleasedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReleasedBy") + .HasColumnType("int"); + + b.HasKey("PayslipId"); + + b.HasIndex("PayrollLineId") + .IsUnique(); + + b.ToTable("hr_payslips", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.Property("PermissionId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PermissionId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("NavItemId") + .HasColumnType("int"); + + b.Property("SubNavItemId") + .HasColumnType("int"); + + b.HasKey("PermissionId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.HasIndex("SubNavItemId"); + + b.ToTable("permissions", (string)null); + + b.HasData( + new + { + PermissionId = 1, + Code = "NAV:dashboard", + NavItemId = 1 + }, + new + { + PermissionId = 2, + Code = "NAV:products", + NavItemId = 2 + }, + new + { + PermissionId = 3, + Code = "NAV:procurement.vendors", + SubNavItemId = 30 + }, + new + { + PermissionId = 4, + Code = "NAV:procurement", + NavItemId = 4 + }, + new + { + PermissionId = 5, + Code = "NAV:receiving", + NavItemId = 5 + }, + new + { + PermissionId = 6, + Code = "NAV:stock", + NavItemId = 6 + }, + new + { + PermissionId = 7, + Code = "NAV:warehouses", + NavItemId = 7 + }, + new + { + PermissionId = 8, + Code = "NAV:orders", + NavItemId = 8 + }, + new + { + PermissionId = 9, + Code = "NAV:settings", + NavItemId = 9 + }, + new + { + PermissionId = 10, + Code = "NAV:help", + NavItemId = 10 + }, + new + { + PermissionId = 11, + Code = "NAV:products.item", + SubNavItemId = 1 + }, + new + { + PermissionId = 12, + Code = "NAV:products.category", + SubNavItemId = 2 + }, + new + { + PermissionId = 13, + Code = "NAV:products.brand", + SubNavItemId = 3 + }, + new + { + PermissionId = 14, + Code = "NAV:products.item-type", + SubNavItemId = 4 + }, + new + { + PermissionId = 15, + Code = "NAV:products.uom", + SubNavItemId = 5 + }, + new + { + PermissionId = 16, + Code = "NAV:products.configuration", + SubNavItemId = 6 + }, + new + { + PermissionId = 17, + Code = "NAV:settings.roles", + SubNavItemId = 7 + }, + new + { + PermissionId = 18, + Code = "NAV:settings.users", + SubNavItemId = 8 + }, + new + { + PermissionId = 28, + Code = "NAV:procurement.requisitions", + SubNavItemId = 17 + }, + new + { + PermissionId = 29, + Code = "NAV:procurement.rfqs", + SubNavItemId = 18 + }, + new + { + PermissionId = 30, + Code = "NAV:procurement.purchase-orders", + SubNavItemId = 19 + }, + new + { + PermissionId = 31, + Code = "NAV:procurement.purchase-returns", + SubNavItemId = 20 + }, + new + { + PermissionId = 19, + Code = "NAV:ledgers", + NavItemId = 11 + }, + new + { + PermissionId = 20, + Code = "NAV:ledgers.trial-balance", + SubNavItemId = 9 + }, + new + { + PermissionId = 21, + Code = "NAV:ledgers.balance-sheet", + SubNavItemId = 10 + }, + new + { + PermissionId = 22, + Code = "NAV:ledgers.general-ledger", + SubNavItemId = 11 + }, + new + { + PermissionId = 23, + Code = "NAV:ledgers.profit-and-loss", + SubNavItemId = 12 + }, + new + { + PermissionId = 24, + Code = "NAV:ledgers.cash-flow", + SubNavItemId = 13 + }, + new + { + PermissionId = 25, + Code = "NAV:ledgers.budget-vs-actual", + SubNavItemId = 14 + }, + new + { + PermissionId = 27, + Code = "NAV:ledgers.tax-report", + SubNavItemId = 16 + }, + new + { + PermissionId = 26, + Code = "NAV:accounts.bank-accounts", + SubNavItemId = 15 + }, + new + { + PermissionId = 32, + Code = "NAV:accounts", + NavItemId = 12 + }, + new + { + PermissionId = 33, + Code = "NAV:accounts.cheque-books", + SubNavItemId = 21 + }, + new + { + PermissionId = 34, + Code = "NAV:accounts.received-cheques", + SubNavItemId = 22 + }, + new + { + PermissionId = 35, + Code = "NAV:production", + NavItemId = 14 + }, + new + { + PermissionId = 36, + Code = "NAV:production.templates", + SubNavItemId = 27 + }, + new + { + PermissionId = 37, + Code = "NAV:production.runs", + SubNavItemId = 25 + }, + new + { + PermissionId = 38, + Code = "NAV:production.produced", + SubNavItemId = 26 + }, + new + { + PermissionId = 39, + Code = "NAV:procurement.purchase-invoices", + SubNavItemId = 28 + }, + new + { + PermissionId = 40, + Code = "NAV:settings.branches", + SubNavItemId = 29 + }, + new + { + PermissionId = 41, + Code = "NAV:stock.ledger", + SubNavItemId = 31 + }, + new + { + PermissionId = 42, + Code = "NAV:stock.enquiry", + SubNavItemId = 32 + }, + new + { + PermissionId = 43, + Code = "NAV:stock.valuation", + SubNavItemId = 33 + }, + new + { + PermissionId = 44, + Code = "NAV:stock.transfers", + SubNavItemId = 34 + }, + new + { + PermissionId = 45, + Code = "NAV:stock.adjustments", + SubNavItemId = 35 + }, + new + { + PermissionId = 46, + Code = "NAV:stock.counts", + SubNavItemId = 36 + }, + new + { + PermissionId = 47, + Code = "NAV:stock.reorder-alerts", + SubNavItemId = 37 + }, + new + { + PermissionId = 48, + Code = "NAV:stock.wastage", + SubNavItemId = 38 + }, + new + { + PermissionId = 49, + Code = "NAV:sales.bundle-templates", + SubNavItemId = 23 + }, + new + { + PermissionId = 50, + Code = "NAV:sales.invoices", + SubNavItemId = 40 + }, + new + { + PermissionId = 51, + Code = "NAV:sales.free-issues", + SubNavItemId = 41 + }, + new + { + PermissionId = 52, + Code = "NAV:sales.day-end", + SubNavItemId = 42 + }, + new + { + PermissionId = 53, + Code = "NAV:products.tax-rates", + SubNavItemId = 43 + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.Property("PoLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PoLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PoId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Tax") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("PoLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("PoId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("po_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("BatchListsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("BrandsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CustomVariantsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("CustomWarrantyPeriodsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ItemImagesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("ItemTypesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SellingPricesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("SerialNumbersEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("SubcategoriesEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.Property("WarrantyPeriodsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.HasKey("ConfigId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("product_config", null, t => + { + t.HasCheckConstraint("ck_product_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.Property("RunId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunId")); + + b.Property("CancelReasonCodeId") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OutputBinId") + .HasColumnType("int"); + + b.Property("ReworkCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("ScaleFactor") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TargetQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("RunId"); + + b.HasIndex("CancelReasonCodeId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("OutputBinId"); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("TemplateId", "Status"); + + b.ToTable("production_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.Property("TemplateId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TemplateId")); + + b.Property("Annotations") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("TemplateId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.ToTable("production_templates", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.Property("PurchaseInvoiceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoiceId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidAmount") + .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("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VatTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorInvoiceDate") + .HasColumnType("datetime(6)"); + + b.Property("VendorInvoiceNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("VendorSnapshotName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("VendorSnapshotTaxNo") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("PurchaseInvoiceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.HasIndex("VendorId", "VendorInvoiceNo"); + + b.ToTable("purchase_invoices", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b => + { + b.Property("PurchaseInvoiceLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoiceLineId")); + + b.Property("GrnId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("VatTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("PurchaseInvoiceLineId"); + + b.HasIndex("GrnId") + .IsUnique(); + + b.HasIndex("PurchaseInvoiceId"); + + b.ToTable("purchase_invoice_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b => + { + b.Property("PurchaseInvoicePaymentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PurchaseInvoicePaymentId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BankAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("GlBankAccountId") + .HasColumnType("bigint"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PaymentDate") + .HasColumnType("datetime(6)"); + + b.Property("PurchaseInvoiceId") + .HasColumnType("int"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("PurchaseInvoicePaymentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("PurchaseInvoiceId"); + + b.ToTable("purchase_invoice_payments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Property("PoId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PoId")); + + b.Property("ApprovalRequired") + .HasColumnType("tinyint(1)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("ApprovedBy") + .HasColumnType("int"); + + b.Property("ApproverUserId") + .HasColumnType("int"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("PoId"); + + b.HasIndex("ApproverUserId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("purchase_orders", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("purchase_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnLineId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnId") + .HasColumnType("int"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.ToTable("purchase_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b => + { + b.Property("ReasonCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReasonCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Context") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.HasKey("ReasonCodeId"); + + b.HasIndex("Context", "Code") + .IsUnique(); + + b.ToTable("reason_codes", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Property("RequisitionId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RequisitionId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequestedBy") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RequisitionId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequestedBy"); + + b.HasIndex("Status"); + + b.ToTable("requisitions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.Property("ReqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReqLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RequiredBy") + .HasColumnType("date"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + b.HasKey("ReqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RequisitionId"); + + b.ToTable("requisition_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Property("RfqId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RfqId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RequisitionId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RfqId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("RequisitionId"); + + b.ToTable("rfqs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.Property("RfqLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RfqLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RfqId") + .HasColumnType("int"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => + { + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RoleId")); + + b.Property("AuthRoleId") + .HasColumnType("char(36)") + .HasColumnName("auth_role_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsSystemRole") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + 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("RoleId"); + + b.HasIndex("AuthRoleId") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("PermissionId") + .HasColumnType("int"); + + b.Property("Actions") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(31); + + b.HasKey("RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => + { + b.Property("RunEdgeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunEdgeId")); + + b.Property("ChildRunStageId") + .HasColumnType("int"); + + b.Property("ParentRunStageId") + .HasColumnType("int"); + + b.Property("RunId") + .HasColumnType("int"); + + b.HasKey("RunEdgeId"); + + b.HasIndex("ChildRunStageId"); + + b.HasIndex("RunId"); + + b.HasIndex("ParentRunStageId", "ChildRunStageId") + .IsUnique(); + + b.ToTable("run_edges", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.Property("RunStageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunStageId")); + + b.Property("ActualEndAt") + .HasColumnType("datetime(6)"); + + b.Property("ActualStartAt") + .HasColumnType("datetime(6)"); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("FieldDefs") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("FieldValues") + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PosX") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PosY") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoleLabel") + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("RunId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("TemplateStageId") + .HasColumnType("int"); + + b.HasKey("RunStageId"); + + b.HasIndex("TemplateStageId"); + + b.HasIndex("RunId", "Status"); + + b.ToTable("run_stages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EventId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Payload") + .HasColumnType("longtext"); + + b.Property("RunId") + .HasColumnType("int"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("EventId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("UserId"); + + b.HasIndex("RunId", "EventId"); + + b.ToTable("run_stage_events", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => + { + b.Property("RunInputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunInputId")); + + b.Property("ConsumedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ConsumedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DeliveredQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("FromRunOutputId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PlannedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ReturnedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnedValue") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("RunInputId"); + + b.HasIndex("FromRunOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RunStageId"); + + b.ToTable("run_stage_inputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => + { + b.Property("RunOutputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RunOutputId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PlannedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ProducedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunStageId") + .HasColumnType("int"); + + b.Property("ScrapReasonCodeId") + .HasColumnType("int"); + + b.Property("ScrappedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TransferredQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UomId") + .HasColumnType("int"); + + b.HasKey("RunOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("ScrapReasonCodeId"); + + b.HasIndex("UomId"); + + b.ToTable("run_stage_outputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalaryComponent", b => + { + b.Property("SalaryComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalaryComponentId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEpfEtfApplicable") + .HasColumnType("tinyint(1)"); + + b.Property("IsTaxable") + .HasColumnType("tinyint(1)"); + + 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("SalaryComponentId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_salary_components", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b => + { + b.Property("ConfigId") + .HasColumnType("int"); + + b.Property("AllowCashCustomer") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("AllowDiscounts") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowFreeItems") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("AllowOverStockSale") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("AllowWarehouseSelection") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CashCustomerId") + .HasColumnType("int"); + + b.Property("DefaultWarehouseId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("ConfigId"); + + b.HasIndex("CashCustomerId"); + + b.HasIndex("DefaultWarehouseId"); + + b.HasIndex("UpdatedBy"); + + b.ToTable("sales_config", null, t => + { + t.HasCheckConstraint("ck_sales_config_singleton", "`ConfigId` = 1"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.Property("SalesDayEndId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesDayEndId")); + + b.Property("BalanceTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BundleCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("BusinessDate") + .HasColumnType("date"); + + b.Property("CashierUserId") + .HasColumnType("int"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("ClosedBy") + .HasColumnType("int"); + + b.Property("CostOfGoodsSold") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SlipCount") + .HasColumnType("int"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("SalesDayEndId"); + + b.HasIndex("ClosedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("CashierUserId", "BusinessDate") + .IsUnique(); + + b.ToTable("sales_day_ends", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.Property("SalesInvoiceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("CreatorUserId") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("CustomerSnapshotName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CustomerSnapshotTaxNo") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("DiscountTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrandTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("InvoiceDate") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("B2C"); + + b.Property("NetPayable") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PaidAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoundOff") + .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("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatorUserId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("InvoiceDate"); + + b.HasIndex("InvoiceNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_invoices", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.Property("SalesInvoiceLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceLineId")); + + b.Property("BaseCost") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("DiscountAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountMode") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("FreeQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IsFreeIssue") + .HasColumnType("tinyint(1)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ParentLineId") + .HasColumnType("int"); + + b.Property("PriceSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SalesInvoiceId") + .HasColumnType("int"); + + b.Property("TaxAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SalesInvoiceId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_invoice_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b => + { + b.Property("SalesInvoiceLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceLineSerialId")); + + b.Property("SalesInvoiceLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SalesInvoiceLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_invoice_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => + { + b.Property("SalesInvoicePaymentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoicePaymentId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BankAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("GlBankAccountId") + .HasColumnType("bigint"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PaymentDate") + .HasColumnType("datetime(6)"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SalesInvoiceId") + .HasColumnType("int"); + + b.HasKey("SalesInvoicePaymentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("SalesInvoiceId"); + + b.ToTable("sales_invoice_payments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b => + { + b.Property("SalesLineAllocationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesLineAllocationId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReturned") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SourceLineId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesLineAllocationId"); + + b.HasIndex("BatchId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SourceDocType", "SourceDocId", "SourceLineId"); + + b.ToTable("sales_line_allocations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.Property("ReturnId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("ReturnId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_returns", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.Property("ReturnLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReturnId") + .HasColumnType("int"); + + b.Property("SalesInvoiceLineId") + .HasColumnType("int"); + + b.HasKey("ReturnLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("ItemId"); + + b.HasIndex("ReturnId"); + + b.HasIndex("SalesInvoiceLineId"); + + b.ToTable("sales_return_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b => + { + b.Property("SalesReturnLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesReturnLineSerialId")); + + b.Property("ReturnLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesReturnLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("ReturnLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_return_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.Property("SalesSlipId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipId")); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("CashierUserId") + .HasColumnType("int"); + + 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("PaidAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SlipDate") + .HasColumnType("datetime(6)"); + + b.Property("SlipNo") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Draft"); + + b.Property("Subtotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesSlipId"); + + b.HasIndex("CashierUserId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("DayEndId"); + + b.HasIndex("SlipDate"); + + b.HasIndex("SlipNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_slips", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.Property("SalesSlipLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipLineId")); + + b.Property("BaseCost") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("DiscountAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("DiscountMode") + .HasColumnType("int"); + + b.Property("DiscountPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("FreeQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("IsFreeIssue") + .HasColumnType("tinyint(1)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LineTotal") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetUnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ParentLineId") + .HasColumnType("int"); + + b.Property("PriceSource") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SalesSlipId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("TaxAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TaxPct") + .HasPrecision(9, 4) + .HasColumnType("decimal(9,4)"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesSlipLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SalesSlipId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("sales_slip_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b => + { + b.Property("SalesSlipLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipLineSerialId")); + + b.Property("SalesSlipLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesSlipLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SalesSlipLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_slip_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SerialId")); + + b.Property("Barcode") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + + b.HasKey("SerialId"); + + b.HasIndex("Barcode") + .IsUnique(); + + b.HasIndex("BinId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.HasIndex("ItemId", "Status"); + + b.HasIndex("WarehouseId", "ItemId", "Status"); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b => + { + b.Property("SerialMovementId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SerialMovementId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FromWarehouseId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SerialMovementId"); + + b.HasIndex("SerialId", "SerialMovementId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("serial_movements", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => + { + b.Property("EdgeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("EdgeId")); + + b.Property("ChildStageId") + .HasColumnType("int"); + + b.Property("ParentStageId") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("EdgeId"); + + b.HasIndex("ChildStageId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("ParentStageId", "ChildStageId") + .IsUnique(); + + b.ToTable("stage_edges", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => + { + b.Property("InputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("InputId")); + + b.Property("FromOutputId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyPerBatch") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyUnit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("StageId") + .HasColumnType("int"); + + b.HasKey("InputId"); + + b.HasIndex("FromOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("StageId"); + + b.ToTable("stage_inputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => + { + b.Property("OutputId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("OutputId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("QtyPerBatch") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("StageId") + .HasColumnType("int"); + + b.Property("UomId") + .HasColumnType("int"); + + b.HasKey("OutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("StageId"); + + b.HasIndex("UomId"); + + b.ToTable("stage_outputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AdjustmentId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReasonCodeId") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("int"); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CountId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("CountId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CountLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CountId") + .HasColumnType("int"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("datetime(6)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("varchar(5)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TransferId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("DestWarehouseId") + .HasColumnType("int"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SrcWarehouseId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("DestBinId") + .HasColumnType("int"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SrcBinId") + .HasColumnType("int"); + + b.Property("TransferId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b => + { + b.Property("StockTransferLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StockTransferLineSerialId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("TransferLineId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.HasKey("StockTransferLineSerialId"); + + b.HasIndex("BatchId"); + + b.HasIndex("SerialId"); + + b.HasIndex("TransferLineId", "SerialId") + .IsUnique(); + + b.ToTable("stock_transfer_line_serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("int"); + + 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("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.Property("SubNavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SubNavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NavItemId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.HasKey("SubNavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.ToTable("sub_nav_items", (string)null); + + b.HasData( + new + { + SubNavItemId = 1, + Code = "products.item", + Href = "/dashboard/products", + Label = "Item", + NavItemId = 2, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 2, + Code = "products.category", + Href = "/dashboard/products/categories", + Label = "Category", + NavItemId = 2, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 3, + Code = "products.brand", + Href = "/dashboard/products/brands", + Label = "Brand", + NavItemId = 2, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 4, + Code = "products.item-type", + Href = "/dashboard/products/item-types", + Label = "Item Type", + NavItemId = 2, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 5, + Code = "products.uom", + Href = "/dashboard/products/uoms", + Label = "UOM", + NavItemId = 2, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 6, + Code = "products.configuration", + Href = "/dashboard/products/settings", + Label = "Configuration", + NavItemId = 2, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 43, + Code = "products.tax-rates", + Href = "/dashboard/products/tax-rates", + Label = "Tax Rates", + NavItemId = 2, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 29, + Code = "settings.branches", + Href = "/dashboard/settings/branches", + Label = "Branches", + NavItemId = 9, + SortOrder = 0, + Status = "Active" + }, + new + { + SubNavItemId = 7, + Code = "settings.roles", + Href = "/dashboard/settings/roles", + Label = "Roles", + NavItemId = 9, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 8, + Code = "settings.users", + Href = "/dashboard/settings/users", + Label = "Users", + NavItemId = 9, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 24, + Code = "settings.notification-config", + Href = "/dashboard/settings/notification-config", + Label = "Notification Config", + NavItemId = 9, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 40, + Code = "sales.invoices", + Href = "/dashboard/sales/invoices", + Label = "Sales", + NavItemId = 13, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 23, + Code = "sales.bundle-templates", + Href = "/dashboard/sales/bundles/templates", + Label = "Bundle Templates", + NavItemId = 13, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 41, + Code = "sales.free-issues", + Href = "/dashboard/sales/free-issues", + Label = "Free Issues", + NavItemId = 13, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 42, + Code = "sales.day-end", + Href = "/dashboard/sales/day-end", + Label = "Day End", + NavItemId = 13, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 30, + Code = "procurement.vendors", + Href = "/dashboard/vendors", + Label = "Vendors", + NavItemId = 4, + SortOrder = 0, + Status = "Active" + }, + new + { + SubNavItemId = 17, + Code = "procurement.requisitions", + Href = "/dashboard/procurement/requisitions", + Label = "Requisitions", + NavItemId = 4, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 18, + Code = "procurement.rfqs", + Href = "/dashboard/procurement/rfqs", + Label = "RFQs", + NavItemId = 4, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 19, + Code = "procurement.purchase-orders", + Href = "/dashboard/procurement/purchase-orders", + Label = "Purchase Orders", + NavItemId = 4, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 20, + Code = "procurement.purchase-returns", + Href = "/dashboard/procurement/purchase-returns", + Label = "Purchase Returns", + NavItemId = 4, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 28, + Code = "procurement.purchase-invoices", + Href = "/dashboard/procurement/purchase-invoices", + Label = "Purchase Invoices", + NavItemId = 4, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 9, + Code = "ledgers.trial-balance", + Href = "/dashboard/ledgers/trial-balance", + Label = "Trial Balance", + NavItemId = 11, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 10, + Code = "ledgers.balance-sheet", + Href = "/dashboard/ledgers/balance-sheet", + Label = "Balance Sheet", + NavItemId = 11, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 11, + Code = "ledgers.general-ledger", + Href = "/dashboard/ledgers/general-ledger", + Label = "General Ledger", + NavItemId = 11, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 12, + Code = "ledgers.profit-and-loss", + Href = "/dashboard/ledgers/profit-and-loss", + Label = "Profit & Loss", + NavItemId = 11, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 13, + Code = "ledgers.cash-flow", + Href = "/dashboard/ledgers/cash-flow", + Label = "Cash Flow", + NavItemId = 11, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 14, + Code = "ledgers.budget-vs-actual", + Href = "/dashboard/ledgers/budget-vs-actual", + Label = "Budget vs Actual", + NavItemId = 11, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 16, + Code = "ledgers.tax-report", + Href = "/dashboard/ledgers/tax-report", + Label = "Tax Report", + NavItemId = 11, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 15, + Code = "accounts.bank-accounts", + Href = "/dashboard/accounts/bank-accounts", + Label = "Cash / Bank Accounts", + NavItemId = 12, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 21, + Code = "accounts.cheque-books", + Href = "/dashboard/accounts/cheque-books", + Label = "Cheque Books", + NavItemId = 12, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 22, + Code = "accounts.received-cheques", + Href = "/dashboard/accounts/received-cheques", + Label = "Received Cheques", + NavItemId = 12, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 27, + Code = "production.templates", + Href = "/dashboard/production/templates", + Label = "Templates", + NavItemId = 14, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 25, + Code = "production.runs", + Href = "/dashboard/production/runs", + Label = "Runs", + NavItemId = 14, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 26, + Code = "production.produced", + Href = "/dashboard/production/produced", + Label = "Produced", + NavItemId = 14, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 31, + Code = "stock.ledger", + Href = "/dashboard/stock/ledger", + Label = "Stock Ledger", + NavItemId = 6, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 32, + Code = "stock.enquiry", + Href = "/dashboard/stock/enquiry", + Label = "Stock Enquiry", + NavItemId = 6, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 33, + Code = "stock.valuation", + Href = "/dashboard/stock/valuation", + Label = "Valuation", + NavItemId = 6, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 34, + Code = "stock.transfers", + Href = "/dashboard/stock/transfers", + Label = "Transfers", + NavItemId = 6, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 35, + Code = "stock.adjustments", + Href = "/dashboard/stock/adjustments", + Label = "Adjustments", + NavItemId = 6, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 36, + Code = "stock.counts", + Href = "/dashboard/stock/counts", + Label = "Counts", + NavItemId = 6, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 37, + Code = "stock.reorder-alerts", + Href = "/dashboard/stock/reorder-alerts", + Label = "Reorder Alerts", + NavItemId = 6, + SortOrder = 7, + Status = "Active" + }, + new + { + SubNavItemId = 38, + Code = "stock.wastage", + Href = "/dashboard/stock/wastage", + Label = "Wastage", + NavItemId = 6, + SortOrder = 8, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => + { + b.Property("TicketId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TicketId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedBy") + .HasColumnType("int"); + + b.HasKey("TicketId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.ToTable("support_tickets", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicketMessage", b => + { + b.Property("MessageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("MessageId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsFromAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("SenderUserId") + .HasColumnType("int"); + + b.Property("TicketId") + .HasColumnType("int"); + + b.HasKey("MessageId"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("support_ticket_messages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TaxRate", b => + { + b.Property("TaxRateId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TaxRateId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Percentage") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + 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("TaxRateId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("tax_rates", (string)null); + + b.HasData( + new + { + TaxRateId = 1, + Code = "STANDARD", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Standard", + Percentage = 15m, + RowVersion = 1u, + Status = "Active" + }, + new + { + TaxRateId = 2, + Code = "ZERO", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Zero-Rated", + Percentage = 0m, + RowVersion = 1u, + Status = "Active" + }, + new + { + TaxRateId = 3, + Code = "EXEMPT", + CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc), + Name = "Exempt", + Percentage = 0m, + RowVersion = 1u, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TaxSlab", b => + { + b.Property("TaxSlabId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TaxSlabId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveFrom") + .HasColumnType("datetime(6)"); + + b.Property("EffectiveTo") + .HasColumnType("datetime(6)"); + + b.Property("LowerBound") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Rate") + .HasPrecision(6, 4) + .HasColumnType("decimal(6,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("UpperBound") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("TaxSlabId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_tax_slabs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.Property("StageId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StageId")); + + b.Property("EstimatedMinutes") + .HasColumnType("int"); + + b.Property("FieldDefs") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PosX") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("PosY") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RoleLabel") + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("StageId"); + + b.HasIndex("TemplateId"); + + b.ToTable("template_stages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UomId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("char(36)") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("varchar(320)"); + + b.Property("RoleId") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("VendorId")); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("varchar(3)") + .HasDefaultValue("LKR"); + + 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("TaxReg") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("VendorId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("RfqId") + .HasColumnType("int"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("LeadDays") + .HasColumnType("int"); + + b.Property("QuotationId") + .HasColumnType("int"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarehouseId")); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Phone") + .HasColumnType("longtext"); + + b.HasKey("WarehouseId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyPeriod", b => + { + b.Property("WarrantyPeriodId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarrantyPeriodId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Months") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("WarrantyPeriodId"); + + b.HasIndex("Months") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("warranty_periods", null, t => + { + t.HasCheckConstraint("ck_warranty_periods_months", "`Months` BETWEEN 1 AND 600"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b => + { + b.Property("WarrantyRegistrationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarrantyRegistrationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PeriodMonths") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SourceLineId") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("WarrantyRegistrationId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("Status", "EndDate"); + + b.ToTable("warranty_registrations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b => + { + b.Property("WorkShiftId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WorkShiftId")); + + b.Property("BreakMinutes") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndTime") + .HasColumnType("time(6)"); + + b.Property("GraceMinutes") + .HasColumnType("int"); + + b.Property("IsOvernight") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("OtMultiplier") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("StandardWorkingMinutes") + .HasColumnType("int"); + + b.Property("StartTime") + .HasColumnType("time(6)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WorkingDaysMask") + .HasColumnType("int"); + + b.HasKey("WorkShiftId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_work_shifts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => + { + b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch") + .WithMany() + .HasForeignKey("AttendanceUploadBatchId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceUploadBatch"); + + b.Navigation("Employee"); + + b.Navigation("WorkShift"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BranchConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate") + .WithMany() + .HasForeignKey("BundleSaleTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd") + .WithMany("BundleSales") + .HasForeignKey("DayEndId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSaleTemplate"); + + b.Navigation("CashierUser"); + + b.Navigation("Customer"); + + b.Navigation("DayEnd"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleLine", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSale", "BundleSale") + .WithMany("Lines") + .HasForeignKey("BundleSaleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSale"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplateLine", b => + { + b.HasOne("ERPCore.Domain.Entities.BundleSaleTemplate", "BundleSaleTemplate") + .WithMany("Lines") + .HasForeignKey("BundleSaleTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BundleSaleTemplate"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Customer", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Branch"); + + b.Navigation("DefaultWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Employee", "HeadEmployee") + .WithMany() + .HasForeignKey("HeadEmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Department", "ParentDepartment") + .WithMany() + .HasForeignKey("ParentDepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + + b.Navigation("HeadEmployee"); + + b.Navigation("ParentDepartment"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Designation", "Designation") + .WithMany() + .HasForeignKey("DesignationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.EmploymentType", "EmploymentType") + .WithMany() + .HasForeignKey("EmploymentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Employee", "ReportingManager") + .WithMany() + .HasForeignKey("ReportingManagerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Employee", "UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Department"); + + b.Navigation("Designation"); + + b.Navigation("EmploymentType"); + + b.Navigation("ReportingManager"); + + b.Navigation("User"); + + b.Navigation("WorkShift"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.HrDocumentType", "HrDocumentType") + .WithMany() + .HasForeignKey("HrDocumentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("HrDocumentType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeSalaryStructure", "EmployeeSalaryStructure") + .WithMany("Lines") + .HasForeignKey("EmployeeSalaryStructureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EmployeeSalaryStructure"); + + b.Navigation("SalaryComponent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice") + .WithMany() + .HasForeignKey("PurchaseInvoiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("PurchaseInvoice"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("DefaultWarehouse"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany("Serials") + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("GrnLine"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany("WarrantyNumbers") + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .HasForeignKey("DefaultVendorId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TaxRate", "TaxRate") + .WithMany() + .HasForeignKey("TaxRateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); + + b.Navigation("TaxRate"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemImage", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeLoan", "EmployeeLoan") + .WithMany("Installments") + .HasForeignKey("EmployeeLoanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany() + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EmployeeLoan"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfigModule", b => + { + b.HasOne("ERPCore.Domain.Entities.NotificationConfig", "NotificationConfig") + .WithMany("Modules") + .HasForeignKey("NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationRecipient", b => + { + b.HasOne("ERPCore.Domain.Entities.Notification", "Notification") + .WithMany("Recipients") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany("Lines") + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithMany("Components") + .HasForeignKey("PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("PayrollLine"); + + b.Navigation("SalaryComponent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Payslip", "PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PayrollLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany() + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem") + .WithMany() + .HasForeignKey("SubNavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("NavItem"); + + b.Navigation("SubNavItem"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "CancelReason") + .WithMany() + .HasForeignKey("CancelReasonCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Bin", "OutputBin") + .WithMany() + .HasForeignKey("OutputBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Runs") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CancelReason"); + + b.Navigation("Creator"); + + b.Navigation("OutputBin"); + + b.Navigation("Template"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoiceLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany() + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice") + .WithMany("Lines") + .HasForeignKey("PurchaseInvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grn"); + + b.Navigation("PurchaseInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoicePayment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseInvoice", "PurchaseInvoice") + .WithMany("Payments") + .HasForeignKey("PurchaseInvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Approver") + .WithMany() + .HasForeignKey("ApproverUserId"); + + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Approver"); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.HasOne("ERPCore.Domain.Entities.Permission", "Permission") + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => + { + b.HasOne("ERPCore.Domain.Entities.RunStage", "ChildRunStage") + .WithMany() + .HasForeignKey("ChildRunStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "ParentRunStage") + .WithMany() + .HasForeignKey("ParentRunStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Edges") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildRunStage"); + + b.Navigation("ParentRunStage"); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Stages") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "TemplateStage") + .WithMany() + .HasForeignKey("TemplateStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Run"); + + b.Navigation("TemplateStage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Events") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Events") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("RunStage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => + { + b.HasOne("ERPCore.Domain.Entities.RunStageOutput", "FromRunOutput") + .WithMany() + .HasForeignKey("FromRunOutputId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Inputs") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromRunOutput"); + + b.Navigation("Item"); + + b.Navigation("RunStage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Outputs") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ScrapReason") + .WithMany() + .HasForeignKey("ScrapReasonCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Item"); + + b.Navigation("RunStage"); + + b.Navigation("ScrapReason"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.Customer", "CashCustomer") + .WithMany() + .HasForeignKey("CashCustomerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DefaultWarehouse") + .WithMany() + .HasForeignKey("DefaultWarehouseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CashCustomer"); + + b.Navigation("DefaultWarehouse"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "ClosedByUser") + .WithMany() + .HasForeignKey("ClosedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CashierUser"); + + b.Navigation("ClosedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatorUserId"); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Customer"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice") + .WithMany("Lines") + .HasForeignKey("SalesInvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Item"); + + b.Navigation("SalesInvoice"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine") + .WithMany("Serials") + .HasForeignKey("SalesInvoiceLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SalesInvoiceLine"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoice", "SalesInvoice") + .WithMany("Payments") + .HasForeignKey("SalesInvoiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("SalesInvoice"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Customer"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine") + .WithMany() + .HasForeignKey("SalesInvoiceLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Item"); + + b.Navigation("Return"); + + b.Navigation("SalesInvoiceLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesReturnLine", "ReturnLine") + .WithMany("Serials") + .HasForeignKey("ReturnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ReturnLine"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") + .WithMany() + .HasForeignKey("CashierUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesDayEnd", "DayEnd") + .WithMany("Slips") + .HasForeignKey("DayEndId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CashierUser"); + + b.Navigation("Customer"); + + b.Navigation("DayEnd"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalesSlip", "SalesSlip") + .WithMany("Lines") + .HasForeignKey("SalesSlipId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Item"); + + b.Navigation("SalesSlip"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesSlipLine", "SalesSlipLine") + .WithMany("Serials") + .HasForeignKey("SalesSlipLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SalesSlipLine"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Bin"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b => + { + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => + { + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ChildStage") + .WithMany() + .HasForeignKey("ChildStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ParentStage") + .WithMany() + .HasForeignKey("ParentStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Edges") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildStage"); + + b.Navigation("ParentStage"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => + { + b.HasOne("ERPCore.Domain.Entities.StageOutput", "FromOutput") + .WithMany() + .HasForeignKey("FromOutputId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") + .WithMany("Inputs") + .HasForeignKey("StageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromOutput"); + + b.Navigation("Item"); + + b.Navigation("Stage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") + .WithMany("Outputs") + .HasForeignKey("StageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Item"); + + b.Navigation("Stage"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Count"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.StockTransferLine", "TransferLine") + .WithMany("Serials") + .HasForeignKey("TransferLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Serial"); + + b.Navigation("TransferLine"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany("Children") + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NavItem"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicketMessage", b => + { + b.HasOne("ERPCore.Domain.Entities.SupportTicket", "Ticket") + .WithMany("Messages") + .HasForeignKey("TicketId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Ticket"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Stages") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b => + { + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Customer"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSaleTemplate", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Navigation("Installments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.Navigation("Serials"); + + b.Navigation("WarrantyNumbers"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Notification", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.Navigation("Edges"); + + b.Navigation("Events"); + + b.Navigation("Stages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.Navigation("Edges"); + + b.Navigation("Runs"); + + b.Navigation("Stages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseInvoice", b => + { + b.Navigation("Lines"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.Navigation("Events"); + + b.Navigation("Inputs"); + + b.Navigation("Outputs"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesDayEnd", b => + { + b.Navigation("BundleSales"); + + b.Navigation("Slips"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoice", b => + { + b.Navigation("Lines"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.Navigation("Serials"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.Navigation("Serials"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.Navigation("Serials"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Navigation("Serials"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.Navigation("Inputs"); + + b.Navigation("Outputs"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.cs b/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.cs new file mode 100644 index 0000000..b8236d3 --- /dev/null +++ b/Backend/ERPCore/Migrations/20261007054513_AddItemTrackingSerialsWarranty.cs @@ -0,0 +1,635 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ERPCore.Migrations +{ + /// + public partial class AddItemTrackingSerialsWarranty : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BinId", + table: "serials", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CreatedAt", + table: "serials", + type: "datetime(6)", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "GrnLineId", + table: "serials", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "ReceivedAt", + table: "serials", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "serials", + type: "int unsigned", + nullable: false, + defaultValue: 0u); + + migrationBuilder.AddColumn( + name: "UpdatedAt", + table: "serials", + type: "datetime(6)", + nullable: true); + + migrationBuilder.AddColumn( + name: "WarehouseId", + table: "serials", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "WarrantyPeriodMonths", + table: "serials", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "BatchId", + table: "sales_return_lines", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "BatchId", + table: "sales_invoice_lines", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "grn_line_serials", + columns: table => new + { + GrnLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + GrnLineId = table.Column(type: "int", nullable: false), + SerialNo = table.Column(type: "varchar(100)", maxLength: 100, nullable: false, collation: "utf8mb4_unicode_ci"), + SerialId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_grn_line_serials", x => x.GrnLineSerialId); + table.ForeignKey( + name: "FK_grn_line_serials_grn_lines_GrnLineId", + column: x => x.GrnLineId, + principalTable: "grn_lines", + principalColumn: "GrnLineId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_grn_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "sales_invoice_line_serials", + columns: table => new + { + SalesInvoiceLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SalesInvoiceLineId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sales_invoice_line_serials", x => x.SalesInvoiceLineSerialId); + table.ForeignKey( + name: "FK_sales_invoice_line_serials_sales_invoice_lines_SalesInvoiceL~", + column: x => x.SalesInvoiceLineId, + principalTable: "sales_invoice_lines", + principalColumn: "SalesInvoiceLineId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_sales_invoice_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "sales_line_allocations", + columns: table => new + { + SalesLineAllocationId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SourceDocType = table.Column(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"), + SourceDocId = table.Column(type: "int", nullable: false), + SourceLineId = table.Column(type: "int", nullable: false), + ItemId = table.Column(type: "int", nullable: false), + WarehouseId = table.Column(type: "int", nullable: false), + BatchId = table.Column(type: "int", nullable: true), + SerialId = table.Column(type: "int", nullable: true), + Qty = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + UnitCost = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: false), + QtyReturned = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sales_line_allocations", x => x.SalesLineAllocationId); + table.ForeignKey( + name: "FK_sales_line_allocations_batches_BatchId", + column: x => x.BatchId, + principalTable: "batches", + principalColumn: "BatchId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_sales_line_allocations_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "sales_return_line_serials", + columns: table => new + { + SalesReturnLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + ReturnLineId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sales_return_line_serials", x => x.SalesReturnLineSerialId); + table.ForeignKey( + name: "FK_sales_return_line_serials_sales_return_lines_ReturnLineId", + column: x => x.ReturnLineId, + principalTable: "sales_return_lines", + principalColumn: "ReturnLineId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_sales_return_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "sales_slip_line_serials", + columns: table => new + { + SalesSlipLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SalesSlipLineId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sales_slip_line_serials", x => x.SalesSlipLineSerialId); + table.ForeignKey( + name: "FK_sales_slip_line_serials_sales_slip_lines_SalesSlipLineId", + column: x => x.SalesSlipLineId, + principalTable: "sales_slip_lines", + principalColumn: "SalesSlipLineId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_sales_slip_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "serial_movements", + columns: table => new + { + SerialMovementId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + SerialId = table.Column(type: "int", nullable: false), + SourceDocType = table.Column(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"), + SourceDocId = table.Column(type: "int", nullable: false), + Status = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"), + FromWarehouseId = table.Column(type: "int", nullable: true), + WarehouseId = table.Column(type: "int", nullable: true), + UserId = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_serial_movements", x => x.SerialMovementId); + table.ForeignKey( + name: "FK_serial_movements_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "stock_transfer_line_serials", + columns: table => new + { + StockTransferLineSerialId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + TransferLineId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: false), + BatchId = table.Column(type: "int", nullable: true), + UnitCost = table.Column(type: "decimal(18,6)", precision: 18, scale: 6, nullable: true), + ReceivedAt = table.Column(type: "datetime(6)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_stock_transfer_line_serials", x => x.StockTransferLineSerialId); + table.ForeignKey( + name: "FK_stock_transfer_line_serials_batches_BatchId", + column: x => x.BatchId, + principalTable: "batches", + principalColumn: "BatchId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_stock_transfer_line_serials_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_stock_transfer_line_serials_stock_transfer_lines_TransferLin~", + column: x => x.TransferLineId, + principalTable: "stock_transfer_lines", + principalColumn: "TransferLineId", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateTable( + name: "warranty_registrations", + columns: table => new + { + WarrantyRegistrationId = table.Column(type: "int", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + ItemId = table.Column(type: "int", nullable: false), + SerialId = table.Column(type: "int", nullable: true), + CustomerId = table.Column(type: "int", nullable: false), + SourceDocType = table.Column(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_unicode_ci"), + SourceDocId = table.Column(type: "int", nullable: false), + SourceLineId = table.Column(type: "int", nullable: false), + Qty = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false), + StartDate = table.Column(type: "date", nullable: false), + PeriodMonths = table.Column(type: "int", nullable: false), + EndDate = table.Column(type: "date", nullable: false), + Status = table.Column(type: "varchar(20)", maxLength: 20, nullable: false, collation: "utf8mb4_unicode_ci"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: true), + RowVersion = table.Column(type: "int unsigned", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_warranty_registrations", x => x.WarrantyRegistrationId); + table.ForeignKey( + name: "FK_warranty_registrations_customers_CustomerId", + column: x => x.CustomerId, + principalTable: "customers", + principalColumn: "CustomerId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_warranty_registrations_items_ItemId", + column: x => x.ItemId, + principalTable: "items", + principalColumn: "ItemId", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_warranty_registrations_serials_SerialId", + column: x => x.SerialId, + principalTable: "serials", + principalColumn: "SerialId", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("Relational:Collation", "utf8mb4_unicode_ci"); + + migrationBuilder.CreateIndex( + name: "IX_serials_BinId", + table: "serials", + column: "BinId"); + + migrationBuilder.CreateIndex( + name: "IX_serials_GrnLineId", + table: "serials", + column: "GrnLineId"); + + migrationBuilder.CreateIndex( + name: "IX_serials_ItemId_Status", + table: "serials", + columns: new[] { "ItemId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_serials_WarehouseId_ItemId_Status", + table: "serials", + columns: new[] { "WarehouseId", "ItemId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_sales_return_lines_BatchId", + table: "sales_return_lines", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_invoice_lines_BatchId", + table: "sales_invoice_lines", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_grn_line_serials_GrnLineId_SerialNo", + table: "grn_line_serials", + columns: new[] { "GrnLineId", "SerialNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_grn_line_serials_SerialId", + table: "grn_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_invoice_line_serials_SalesInvoiceLineId_SerialId", + table: "sales_invoice_line_serials", + columns: new[] { "SalesInvoiceLineId", "SerialId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sales_invoice_line_serials_SerialId", + table: "sales_invoice_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_line_allocations_BatchId", + table: "sales_line_allocations", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_line_allocations_SerialId", + table: "sales_line_allocations", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_line_allocations_SourceDocType_SourceDocId_SourceLineId", + table: "sales_line_allocations", + columns: new[] { "SourceDocType", "SourceDocId", "SourceLineId" }); + + migrationBuilder.CreateIndex( + name: "IX_sales_return_line_serials_ReturnLineId_SerialId", + table: "sales_return_line_serials", + columns: new[] { "ReturnLineId", "SerialId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sales_return_line_serials_SerialId", + table: "sales_return_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_sales_slip_line_serials_SalesSlipLineId_SerialId", + table: "sales_slip_line_serials", + columns: new[] { "SalesSlipLineId", "SerialId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sales_slip_line_serials_SerialId", + table: "sales_slip_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_serial_movements_SerialId_SerialMovementId", + table: "serial_movements", + columns: new[] { "SerialId", "SerialMovementId" }); + + migrationBuilder.CreateIndex( + name: "IX_serial_movements_SourceDocType_SourceDocId", + table: "serial_movements", + columns: new[] { "SourceDocType", "SourceDocId" }); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_line_serials_BatchId", + table: "stock_transfer_line_serials", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_line_serials_SerialId", + table: "stock_transfer_line_serials", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_stock_transfer_line_serials_TransferLineId_SerialId", + table: "stock_transfer_line_serials", + columns: new[] { "TransferLineId", "SerialId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_CustomerId", + table: "warranty_registrations", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_ItemId", + table: "warranty_registrations", + column: "ItemId"); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_SerialId", + table: "warranty_registrations", + column: "SerialId"); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_SourceDocType_SourceDocId", + table: "warranty_registrations", + columns: new[] { "SourceDocType", "SourceDocId" }); + + migrationBuilder.CreateIndex( + name: "IX_warranty_registrations_Status_EndDate", + table: "warranty_registrations", + columns: new[] { "Status", "EndDate" }); + + migrationBuilder.AddForeignKey( + name: "FK_sales_invoice_lines_batches_BatchId", + table: "sales_invoice_lines", + column: "BatchId", + principalTable: "batches", + principalColumn: "BatchId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_sales_return_lines_batches_BatchId", + table: "sales_return_lines", + column: "BatchId", + principalTable: "batches", + principalColumn: "BatchId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_serials_bins_BinId", + table: "serials", + column: "BinId", + principalTable: "bins", + principalColumn: "BinId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_serials_grn_lines_GrnLineId", + table: "serials", + column: "GrnLineId", + principalTable: "grn_lines", + principalColumn: "GrnLineId", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_serials_warehouses_WarehouseId", + table: "serials", + column: "WarehouseId", + principalTable: "warehouses", + principalColumn: "WarehouseId", + onDelete: ReferentialAction.Restrict); + + // Backfill serial rows that predate this migration: a real CreatedAt, a live + // concurrency token (0 is never live — see ErpDbContext.BumpRowVersions), and the + // warehouse of an in-stock serial's own open layer, if it has one. + migrationBuilder.Sql(""" + UPDATE `serials` SET `CreatedAt` = UTC_TIMESTAMP(6) WHERE `CreatedAt` < '1900-01-01'; + UPDATE `serials` SET `RowVersion` = 1 WHERE `RowVersion` = 0; + UPDATE `serials` s + JOIN `stock_layers` l ON l.`SerialId` = s.`SerialId` AND l.`QtyRemaining` > 0 + SET s.`WarehouseId` = l.`WarehouseId` + WHERE s.`Status` = 'InStock' AND s.`WarehouseId` IS NULL; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_sales_invoice_lines_batches_BatchId", + table: "sales_invoice_lines"); + + migrationBuilder.DropForeignKey( + name: "FK_sales_return_lines_batches_BatchId", + table: "sales_return_lines"); + + migrationBuilder.DropForeignKey( + name: "FK_serials_bins_BinId", + table: "serials"); + + migrationBuilder.DropForeignKey( + name: "FK_serials_grn_lines_GrnLineId", + table: "serials"); + + migrationBuilder.DropForeignKey( + name: "FK_serials_warehouses_WarehouseId", + table: "serials"); + + migrationBuilder.DropTable( + name: "grn_line_serials"); + + migrationBuilder.DropTable( + name: "sales_invoice_line_serials"); + + migrationBuilder.DropTable( + name: "sales_line_allocations"); + + migrationBuilder.DropTable( + name: "sales_return_line_serials"); + + migrationBuilder.DropTable( + name: "sales_slip_line_serials"); + + migrationBuilder.DropTable( + name: "serial_movements"); + + migrationBuilder.DropTable( + name: "stock_transfer_line_serials"); + + migrationBuilder.DropTable( + name: "warranty_registrations"); + + migrationBuilder.DropIndex( + name: "IX_serials_BinId", + table: "serials"); + + migrationBuilder.DropIndex( + name: "IX_serials_GrnLineId", + table: "serials"); + + migrationBuilder.DropIndex( + name: "IX_serials_ItemId_Status", + table: "serials"); + + migrationBuilder.DropIndex( + name: "IX_serials_WarehouseId_ItemId_Status", + table: "serials"); + + migrationBuilder.DropIndex( + name: "IX_sales_return_lines_BatchId", + table: "sales_return_lines"); + + migrationBuilder.DropIndex( + name: "IX_sales_invoice_lines_BatchId", + table: "sales_invoice_lines"); + + migrationBuilder.DropColumn( + name: "BinId", + table: "serials"); + + migrationBuilder.DropColumn( + name: "CreatedAt", + table: "serials"); + + migrationBuilder.DropColumn( + name: "GrnLineId", + table: "serials"); + + migrationBuilder.DropColumn( + name: "ReceivedAt", + table: "serials"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "serials"); + + migrationBuilder.DropColumn( + name: "UpdatedAt", + table: "serials"); + + migrationBuilder.DropColumn( + name: "WarehouseId", + table: "serials"); + + migrationBuilder.DropColumn( + name: "WarrantyPeriodMonths", + table: "serials"); + + migrationBuilder.DropColumn( + name: "BatchId", + table: "sales_return_lines"); + + migrationBuilder.DropColumn( + name: "BatchId", + table: "sales_invoice_lines"); + } + } +} diff --git a/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs index 59f4860..c32dfc2 100644 --- a/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs @@ -1693,6 +1693,35 @@ namespace ERPCore.Migrations b.ToTable("grn_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b => + { + b.Property("GrnLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("GrnLineSerialId")); + + b.Property("GrnLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("GrnLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("GrnLineId", "SerialNo") + .IsUnique(); + + b.ToTable("grn_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => { b.Property("GrnLineWarrantyNumberId") @@ -4740,6 +4769,9 @@ namespace ERPCore.Migrations .HasPrecision(18, 4) .HasColumnType("decimal(18,4)"); + b.Property("BatchId") + .HasColumnType("int"); + b.Property("Description") .IsRequired() .HasMaxLength(200) @@ -4810,6 +4842,8 @@ namespace ERPCore.Migrations b.HasKey("SalesInvoiceLineId"); + b.HasIndex("BatchId"); + b.HasIndex("ItemId"); b.HasIndex("SalesInvoiceId"); @@ -4819,6 +4853,30 @@ namespace ERPCore.Migrations b.ToTable("sales_invoice_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b => + { + b.Property("SalesInvoiceLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesInvoiceLineSerialId")); + + b.Property("SalesInvoiceLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesInvoiceLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SalesInvoiceLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_invoice_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => { b.Property("SalesInvoicePaymentId") @@ -4868,6 +4926,63 @@ namespace ERPCore.Migrations b.ToTable("sales_invoice_payments", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b => + { + b.Property("SalesLineAllocationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesLineAllocationId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QtyReturned") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SourceLineId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SalesLineAllocationId"); + + b.HasIndex("BatchId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SourceDocType", "SourceDocId", "SourceLineId"); + + b.ToTable("sales_line_allocations", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => { b.Property("ReturnId") @@ -4932,6 +5047,9 @@ namespace ERPCore.Migrations MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ReturnLineId")); + b.Property("BatchId") + .HasColumnType("int"); + b.Property("ItemId") .HasColumnType("int"); @@ -4947,6 +5065,8 @@ namespace ERPCore.Migrations b.HasKey("ReturnLineId"); + b.HasIndex("BatchId"); + b.HasIndex("ItemId"); b.HasIndex("ReturnId"); @@ -4956,6 +5076,30 @@ namespace ERPCore.Migrations b.ToTable("sales_return_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b => + { + b.Property("SalesReturnLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesReturnLineSerialId")); + + b.Property("ReturnLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesReturnLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("ReturnLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_return_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => { b.Property("SalesSlipId") @@ -5151,6 +5295,30 @@ namespace ERPCore.Migrations b.ToTable("sales_slip_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b => + { + b.Property("SalesSlipLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SalesSlipLineSerialId")); + + b.Property("SalesSlipLineId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.HasKey("SalesSlipLineSerialId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SalesSlipLineId", "SerialId") + .IsUnique(); + + b.ToTable("sales_slip_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { b.Property("SerialId") @@ -5163,9 +5331,25 @@ namespace ERPCore.Migrations .HasMaxLength(100) .HasColumnType("varchar(100)"); + b.Property("BinId") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GrnLineId") + .HasColumnType("int"); + b.Property("ItemId") .HasColumnType("int"); + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + b.Property("SerialNo") .IsRequired() .HasMaxLength(100) @@ -5176,17 +5360,79 @@ namespace ERPCore.Migrations .HasMaxLength(20) .HasColumnType("varchar(20)"); + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.Property("WarrantyPeriodMonths") + .HasColumnType("int"); + b.HasKey("SerialId"); b.HasIndex("Barcode") .IsUnique(); + b.HasIndex("BinId"); + + b.HasIndex("GrnLineId"); + b.HasIndex("ItemId", "SerialNo") .IsUnique(); + b.HasIndex("ItemId", "Status"); + + b.HasIndex("WarehouseId", "ItemId", "Status"); + b.ToTable("serials", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b => + { + b.Property("SerialMovementId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SerialMovementId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FromWarehouseId") + .HasColumnType("int"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("WarehouseId") + .HasColumnType("int"); + + b.HasKey("SerialMovementId"); + + b.HasIndex("SerialId", "SerialMovementId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.ToTable("serial_movements", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => { b.Property("EdgeId") @@ -5744,6 +5990,42 @@ namespace ERPCore.Migrations b.ToTable("stock_transfer_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b => + { + b.Property("StockTransferLineSerialId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StockTransferLineSerialId")); + + b.Property("BatchId") + .HasColumnType("int"); + + b.Property("ReceivedAt") + .HasColumnType("datetime(6)"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("TransferLineId") + .HasColumnType("int"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("decimal(18,6)"); + + b.HasKey("StockTransferLineSerialId"); + + b.HasIndex("BatchId"); + + b.HasIndex("SerialId"); + + b.HasIndex("TransferLineId", "SerialId") + .IsUnique(); + + b.ToTable("stock_transfer_line_serials", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => { b.Property("SubCategoryId") @@ -6787,6 +7069,77 @@ namespace ERPCore.Migrations }); }); + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b => + { + b.Property("WarrantyRegistrationId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("WarrantyRegistrationId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ItemId") + .HasColumnType("int"); + + b.Property("PeriodMonths") + .HasColumnType("int"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SerialId") + .HasColumnType("int"); + + b.Property("SourceDocId") + .HasColumnType("int"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SourceLineId") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("WarrantyRegistrationId"); + + b.HasIndex("CustomerId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("Status", "EndDate"); + + b.ToTable("warranty_registrations", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b => { b.Property("WorkShiftId") @@ -7306,6 +7659,24 @@ namespace ERPCore.Migrations b.Navigation("PoLine"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany("Serials") + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("GrnLine"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLineWarrantyNumber", b => { b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") @@ -8143,6 +8514,11 @@ namespace ERPCore.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.Item", "Item") .WithMany() .HasForeignKey("ItemId") @@ -8161,6 +8537,8 @@ namespace ERPCore.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.Navigation("Batch"); + b.Navigation("Item"); b.Navigation("SalesInvoice"); @@ -8168,6 +8546,25 @@ namespace ERPCore.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesInvoiceLine", "SalesInvoiceLine") + .WithMany("Serials") + .HasForeignKey("SalesInvoiceLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SalesInvoiceLine"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoicePayment", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -8187,6 +8584,23 @@ namespace ERPCore.Migrations b.Navigation("SalesInvoice"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesLineAllocation", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Batch"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -8224,6 +8638,11 @@ namespace ERPCore.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.Item", "Item") .WithMany() .HasForeignKey("ItemId") @@ -8241,6 +8660,8 @@ namespace ERPCore.Migrations .HasForeignKey("SalesInvoiceLineId") .OnDelete(DeleteBehavior.Restrict); + b.Navigation("Batch"); + b.Navigation("Item"); b.Navigation("Return"); @@ -8248,6 +8669,25 @@ namespace ERPCore.Migrations b.Navigation("SalesInvoiceLine"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesReturnLine", "ReturnLine") + .WithMany("Serials") + .HasForeignKey("ReturnLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ReturnLine"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => { b.HasOne("ERPCore.Domain.Entities.User", "CashierUser") @@ -8323,15 +8763,66 @@ namespace ERPCore.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.SalesSlipLine", "SalesSlipLine") + .WithMany("Serials") + .HasForeignKey("SalesSlipLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("SalesSlipLine"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("ERPCore.Domain.Entities.Item", "Item") .WithMany() .HasForeignKey("ItemId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Bin"); + + b.Navigation("GrnLine"); + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SerialMovement", b => + { + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Serial"); }); modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => @@ -8470,7 +8961,7 @@ namespace ERPCore.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("ERPCore.Domain.Entities.Serial", null) + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") .WithMany() .HasForeignKey("SerialId") .OnDelete(DeleteBehavior.Restrict); @@ -8478,6 +8969,8 @@ namespace ERPCore.Migrations b.Navigation("Adjustment"); b.Navigation("Item"); + + b.Navigation("Serial"); }); modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => @@ -8695,6 +9188,30 @@ namespace ERPCore.Migrations b.Navigation("Transfer"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLineSerial", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.StockTransferLine", "TransferLine") + .WithMany("Serials") + .HasForeignKey("TransferLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Serial"); + + b.Navigation("TransferLine"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => { b.HasOne("ERPCore.Domain.Entities.Category", "Category") @@ -8809,6 +9326,32 @@ namespace ERPCore.Migrations b.Navigation("Branch"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.WarrantyRegistration", b => + { + b.HasOne("ERPCore.Domain.Entities.Customer", "Customer") + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Customer"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.BundleSale", b => { b.Navigation("Lines"); @@ -8841,6 +9384,8 @@ namespace ERPCore.Migrations modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => { + b.Navigation("Serials"); + b.Navigation("WarrantyNumbers"); }); @@ -8944,16 +9489,31 @@ namespace ERPCore.Migrations b.Navigation("Payments"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesInvoiceLine", b => + { + b.Navigation("Serials"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturn", b => { b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesReturnLine", b => + { + b.Navigation("Serials"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlip", b => { b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SalesSlipLine", b => + { + b.Navigation("Serials"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => { b.Navigation("Lines"); @@ -8969,6 +9529,11 @@ namespace ERPCore.Migrations b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Navigation("Serials"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.SupportTicket", b => { b.Navigation("Messages"); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 5ab668c..fe20411 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -166,6 +166,8 @@ builder.Services.AddScoped(); // Stock transactions + reference data (docs/11 §5–6) builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs index 0bda2f7..7791412 100644 --- a/Backend/ERPCore/Services/AdjustmentService.cs +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -36,6 +36,7 @@ public sealed class AdjustmentService : IAdjustmentService private readonly IBranchConfigService _branchConfig; private readonly INotificationService _notifications; private readonly IUnitOfWork _uow; + private readonly ISerialTrackingService _serialTracking; private readonly IGeneralLedgerService _gl; private readonly IGlAccountConfigService _glAccounts; @@ -45,6 +46,7 @@ public sealed class AdjustmentService : IAdjustmentService INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { + _serialTracking = serialTracking; _adjustments = adjustments; _warehouses = warehouses; _items = items; @@ -155,17 +157,24 @@ public sealed class AdjustmentService : IAdjustmentService if (reason.Context != ReasonContext.Adjustment) throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not an Adjustment reason.", 422); + // Serials per line: outbound ones are validated as on hand; inbound ones are created (or + // re-found) in the context now and get their ids on the flush below. + var serialsByLine = new List>(); + var documentSerials = new HashSet(); + var documentSerialNos = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var line in request.Lines) { if (line.QtyDelta == 0) throw new DomainException(ErrorCodes.Validation, "qtyDelta cannot be zero.", 422); - if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + serialsByLine.Add(line.QtyDelta < 0 + ? await _serialTracking.ResolveOutboundAsync(item, request.WarehouseId, -line.QtyDelta, line.SerialIds, + documentSerials, new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct) + : await _serialTracking.ResolveInboundAsync(item, request.WarehouseId, line.QtyDelta, line.SerialNos, documentSerialNos, ct)); } var now = DateTime.UtcNow; - var deltas = request.Lines - .Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(); var (adjustment, ledgerRefs) = await _uow.ExecuteInTransactionAsync(async token => { @@ -179,19 +188,31 @@ public sealed class AdjustmentService : IAdjustmentService Status = AdjustmentStatus.Posted, CreatedBy = _currentUser.AuditUserId, CreatedAt = now, - Lines = request.Lines.Select(l => new StockAdjustmentLine + Lines = request.Lines.Select((l, i) => new StockAdjustmentLine { ItemId = l.ItemId, BinId = l.BinId, BatchId = l.BatchId, - QtyDelta = l.QtyDelta + QtyDelta = l.QtyDelta, + Serial = serialsByLine[i].Count == 1 ? serialsByLine[i][0] : null }).ToList() }; await _adjustments.AddAsync(entity, token); - await _uow.SaveChangesAsync(token); // flush so AdjustmentId is a valid ledger sourceDocId + await _uow.SaveChangesAsync(token); // flush so AdjustmentId (and new SerialIds) are valid + var deltas = request.Lines.Select((l, i) => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.QtyDelta, + serialsByLine[i].Select(s => s.SerialId).ToList())).ToList(); var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.Adjustment, entity.AdjustmentId, now, deltas, token); + for (var i = 0; i < request.Lines.Count; i++) + { + var line = request.Lines[i]; + await _serialTracking.MoveAsync(serialsByLine[i], + line.QtyDelta < 0 ? SerialStatus.Scrapped : SerialStatus.InStock, + line.QtyDelta < 0 ? null : request.WarehouseId, line.QtyDelta < 0 ? null : line.BinId, + DocumentTypes.Adjustment, entity.AdjustmentId, now, token); + } + entity.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token); if (entity.GlJournalNo is not null) entity.GlPostedAt = now; @@ -207,6 +228,6 @@ public sealed class AdjustmentService : IAdjustmentService a.AdjustmentId, a.DocNo, a.WarehouseId, a.BranchId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt, a.GlJournalNo, a.GlPostedAt, a.Lines.OrderBy(l => l.AdjLineId) - .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(), + .Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta, l.SerialId)).ToList(), ledgerRefs); } diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index 0f68255..1011121 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -50,6 +50,7 @@ public sealed class CountService : ICountService IGeneralLedgerService gl, IRepository stockLayers, IRepository batches, IRepository serials, IGlAccountConfigService glAccounts) { + _serialTracking = serialTracking; _counts = counts; _adjustments = adjustments; _warehouses = warehouses; @@ -171,17 +172,32 @@ public sealed class CountService : ICountService .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; lines.Add(new StockCountLine { ItemId = id, BatchId = batch.BatchId, SystemQty = systemQty }); } + // Stock that reached the item without a batch (e.g. an adjustment) is counted on its own line. + var unbatched = await _stockLayers.Query().AsNoTracking() + .Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.BatchId == null) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + if (unbatched != 0) + lines.Add(new StockCountLine { ItemId = id, BatchId = null, SystemQty = unbatched }); } - else if (item.TrackingMode == TrackingMode.Serial) + else if (await _serialTracking.IsSerialTrackedAsync(item, ct)) // off: counted as one aggregate line { + // Serials that should be at this warehouse (one line each, counted 0 or 1), plus + // one aggregate line for any pre-tracking serial-less stock still here (D6). var serials = await _serials.Query().AsNoTracking() - .Where(s => s.ItemId == id && s.Status == "InStock").ToListAsync(ct); + .Where(s => s.ItemId == id && s.WarehouseId == request.WarehouseId + && (s.Status == SerialStatus.InStock || s.Status == SerialStatus.Damaged)) + .OrderBy(s => s.SerialNo).ToListAsync(ct); foreach (var serial in serials) { var open = await _stockLayers.Query().AsNoTracking().AnyAsync( l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == serial.SerialId && l.QtyRemaining > 0, ct); lines.Add(new StockCountLine { ItemId = id, SerialId = serial.SerialId, SystemQty = open ? 1 : 0 }); } + var untracked = await _stockLayers.Query().AsNoTracking() + .Where(l => l.ItemId == id && l.WarehouseId == request.WarehouseId && l.SerialId == null) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + if (untracked != 0 || serials.Count == 0) + lines.Add(new StockCountLine { ItemId = id, SystemQty = untracked }); } else { @@ -225,6 +241,8 @@ public sealed class CountService : ICountService { var line = count.Lines.FirstOrDefault(l => l.CountLineId == input.CountLineId) ?? throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is not on count {countId}.", 422); + if (line.SerialId is not null && input.CountedQty is not (0m or 1m)) + throw new DomainException(ErrorCodes.Validation, $"Count line {input.CountLineId} is one serial — count it as 0 or 1.", 422); line.CountedQty = input.CountedQty; line.Variance = input.CountedQty - line.SystemQty; } @@ -246,9 +264,11 @@ public sealed class CountService : ICountService if (count.Status != CountStatus.Counted) throw new ConflictException($"Count {countId} has no entered counts to post."); - var deltas = count.Lines - .Where(l => l.Variance.HasValue && l.Variance.Value != 0) - .Select(l => new StockDelta(l.ItemId, l.BinId, null, l.Variance!.Value)) + // Batch and serial lines adjust exactly their own lot/unit. + var varianceLines = count.Lines.Where(l => l.Variance.HasValue && l.Variance.Value != 0).ToList(); + var deltas = varianceLines + .Select(l => new StockDelta(l.ItemId, l.BinId, l.BatchId, l.Variance!.Value, + l.SerialId is int serialId ? new List { serialId } : null)) .ToList(); // No variances → just close the count, no adjustment. @@ -280,6 +300,8 @@ public sealed class CountService : ICountService { ItemId = d.ItemId, BinId = d.BinId, + BatchId = d.BatchId, + SerialId = d.SerialIds?.FirstOrDefault(), QtyDelta = d.QtyDelta }).ToList() }; @@ -288,6 +310,17 @@ public sealed class CountService : ICountService var refs = await _mutator.ApplyAsync(count.WarehouseId, DocumentTypes.Adjustment, adjustment.AdjustmentId, now, deltas, token); + // A serial counted missing is written off (Scrapped); one found keeps/regains InStock here. + foreach (var line in varianceLines.Where(l => l.SerialId is not null)) + { + var serial = await _serials.Query().FirstAsync(s => s.SerialId == line.SerialId, token); + var found = line.Variance > 0; + await _serialTracking.MoveAsync(new[] { serial }, + found ? SerialStatus.InStock : SerialStatus.Scrapped, + found ? count.WarehouseId : null, found ? line.BinId : null, + DocumentTypes.Count, count.CountId, now, token); + } + adjustment.GlJournalNo = await PostAdjustmentJournalAsync(docNo, now, refs, token); if (adjustment.GlJournalNo is not null) adjustment.GlPostedAt = now; diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 570a994..4e8bf23 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -34,6 +34,9 @@ public sealed class GrnService : IGrnService private readonly IRepository _bins; private readonly IRepository _vendors; private readonly IRepository _batches; + private readonly IRepository _serials; + private readonly IRepository _serialMovements; + private readonly ISerialTrackingService _serialTracking; private readonly IRepository _layers; private readonly IRepository _ledger; private readonly IRepository _taxRates; @@ -50,7 +53,7 @@ public sealed class GrnService : IGrnService public GrnService( IRepository grns, IRepository pos, IRepository poLines, IRepository items, IRepository warehouses, - IRepository bins, IRepository vendors, IRepository batches, + IRepository bins, IRepository vendors, IRepository batches, IRepository serials, IRepository serialMovements, ISerialTrackingService serialTracking, IRepository layers, IRepository ledger, IRepository taxRates, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig, IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts) @@ -63,6 +66,9 @@ public sealed class GrnService : IGrnService _bins = bins; _vendors = vendors; _batches = batches; + _serials = serials; + _serialMovements = serialMovements; + _serialTracking = serialTracking; _layers = layers; _ledger = ledger; _taxRates = taxRates; @@ -110,6 +116,8 @@ public sealed class GrnService : IGrnService { var grn = await _grns.Query().AsNoTracking() .Include(g => g.Lines).ThenInclude(l => l.WarrantyNumbers) + .Include(g => g.Lines).ThenInclude(l => l.Serials) + .Include(g => g.Lines).ThenInclude(l => l.Batch) .Include(g => g.PurchaseInvoice) .FirstOrDefaultAsync(g => g.GrnId == grnId, ct); return grn is null ? null : Map(grn); @@ -155,6 +163,7 @@ public sealed class GrnService : IGrnService var lines = new List(); var batchCache = new Dictionary<(int ItemId, string BatchNo), Batch>(); + var requestSerials = new HashSet<(int ItemId, string SerialNo)>(); foreach (var input in request.Lines) { @@ -203,7 +212,9 @@ public sealed class GrnService : IGrnService var vatAmount = Math.Round(receivedValue * vatPct / 100m, 4, MidpointRounding.AwayFromZero); var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct); - var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty); + var warrantyNumbers = ResolveWarrantyNumbers(item, input.WarrantyNumbers, input.Qty, + serialTracked: await _serialTracking.IsSerialTrackedAsync(item, ct)); + var serials = await ResolveSerialNumbersAsync(item, input.SerialNumbers, input.Qty, requestSerials, ct); lines.Add(new GrnLine { @@ -221,7 +232,8 @@ public sealed class GrnService : IGrnService ReceivedValue = receivedValue, LineTotal = receivedValue + vatAmount, HoldStatus = input.HoldStatus, - WarrantyNumbers = warrantyNumbers + WarrantyNumbers = warrantyNumbers, + Serials = serials }); } @@ -252,7 +264,7 @@ public sealed class GrnService : IGrnService public async Task ConfirmAsync(int grnId, string? idempotencyKey, CancellationToken ct = default) { var grn = await _grns.Query() - .Include(g => g.Lines) + .Include(g => g.Lines).ThenInclude(l => l.Serials) .FirstOrDefaultAsync(g => g.GrnId == grnId, ct) ?? throw new NotFoundException($"GRN {grnId} was not found."); @@ -278,10 +290,21 @@ public sealed class GrnService : IGrnService var qtyBase = line.Qty; var unitCostBase = line.NetUnitCost; - var layer = await _fifo.CreateInboundLayerAsync( - line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, - qtyBase, unitCostBase, now, token); - createdLayers.Add(layer); + if (line.Serials.Count > 0) + { + // Serial-tracked: one Serial and one qty-1 layer per unit, so a sale can later + // consume exactly the serial it names. The ledger stays one row per line + // (below): every ledger row also posts a GL entry, and per-unit rows would mean + // one GL call per serial — serial history is read from the document tables. + createdLayers.AddRange(await ReceiveSerialsAsync(grn, line, unitCostBase, now, token)); + } + else + { + var layer = await _fifo.CreateInboundLayerAsync( + line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, + qtyBase, unitCostBase, now, token); + createdLayers.Add(layer); + } var key = (line.ItemId, grn.WarehouseId); if (!runningBalance.TryGetValue(key, out var bal)) @@ -411,12 +434,16 @@ public sealed class GrnService : IGrnService /// period is not entered at receipt; it is snapshotted from , /// which the item must have been given at creation ( enforces that). ///
- private static List ResolveWarrantyNumbers(Item item, List? numbers, decimal qty) + private static List ResolveWarrantyNumbers(Item item, List? numbers, decimal qty, bool serialTracked) { if (item.Warranty != Warranty.Warranty) return new List(); if (item.WarrantyPeriodMonths is null) throw new DomainException( ErrorCodes.Validation, $"Item {item.Sku} is under warranty but has no warranty period configured.", 422); + // A serial item's serial number is its warranty identity (docs/ITEM_TRACKING_PLAN.md D1); + // its coverage is snapshotted onto each Serial at confirm instead. With serial numbers + // switched off the item has no serials, so it takes warranty numbers like any other. + if (serialTracked) return new List(); var trimmed = (numbers ?? new List()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); if (trimmed.Count != qty) @@ -429,6 +456,88 @@ public sealed class GrnService : IGrnService return trimmed.Select(n => new GrnLineWarrantyNumber { WarrantyNo = n, WarrantyPeriodMonths = item.WarrantyPeriodMonths.Value }).ToList(); } + /// + /// A serial-tracked item requires exactly one serial number per received unit — so the + /// quantity must be whole. Numbers must be unique within the request and must not already + /// exist for the item. Any other item must not carry serial numbers. + /// + private async Task> ResolveSerialNumbersAsync( + Item item, List? numbers, decimal qty, HashSet<(int, string)> requestSerials, CancellationToken ct) + { + var trimmed = (numbers ?? new List()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); + if (!await _serialTracking.IsSerialTrackedAsync(item, ct)) + { + _serialTracking.RejectSerialsWhileDisabled(item, trimmed.Count); + if (trimmed.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serial numbers are not accepted.", 422); + return new List(); + } + + if (qty != decimal.Truncate(qty)) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is serial-tracked; the quantity must be a whole number.", 422); + if (trimmed.Count != qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; provide exactly {qty} serial number(s), got {trimmed.Count}.", 422); + if (trimmed.Any(n => n.Length > 100)) + throw new DomainException(ErrorCodes.Validation, "A serial number cannot be longer than 100 characters.", 422); + + // Case-insensitive, matching the utf8mb4_unicode_ci unique index on serials (ItemId, SerialNo). + foreach (var n in trimmed) + if (!requestSerials.Add((item.ItemId, n.ToUpperInvariant()))) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number {n} for item {item.Sku} is entered more than once.", 422); + + await EnsureSerialsFreeAsync(item.ItemId, item.Sku, trimmed, ct); + return trimmed.Select(n => new GrnLineSerial { SerialNo = n }).ToList(); + } + + private async Task EnsureSerialsFreeAsync(int itemId, string sku, List numbers, CancellationToken ct) + { + var taken = await _serials.Query().AsNoTracking() + .Where(s => s.ItemId == itemId && numbers.Contains(s.SerialNo)) + .Select(s => s.SerialNo).Take(5).ToListAsync(ct); + if (taken.Count > 0) + throw new DomainException(ErrorCodes.SerialDuplicate, + $"Serial number(s) already exist for item {sku}: {string.Join(", ", taken)}.", 409); + } + + /// + /// Confirm-time half of serial capture: re-checks the numbers are still free (another GRN + /// may have confirmed them since this draft was created — the unique index is the final + /// guard), then creates each with its own qty-1 FIFO layer. + /// + private async Task> ReceiveSerialsAsync(Grn grn, GrnLine line, decimal unitCost, DateTime now, CancellationToken ct) + { + var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, ct); + await EnsureSerialsFreeAsync(item.ItemId, item.Sku, line.Serials.Select(s => s.SerialNo).ToList(), ct); + + var warrantyMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null; + var layers = new List(); + foreach (var captured in line.Serials.OrderBy(s => s.GrnLineSerialId)) + { + var serial = new Serial + { + ItemId = line.ItemId, + SerialNo = captured.SerialNo, + Status = SerialStatus.InStock, + WarehouseId = grn.WarehouseId, + BinId = line.BinId, + GrnLineId = line.GrnLineId, + ReceivedAt = now, + CreatedAt = now, + WarrantyPeriodMonths = warrantyMonths + }; + await _serials.AddAsync(serial, ct); + captured.Serial = serial; // FK fixed up on SaveChanges + await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.Grn, grn.GrnId, _currentUser.AuditUserId, now), ct); + + var layer = await _fifo.CreateInboundLayerAsync( + line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId, 1m, unitCost, now, ct); + layer.Serial = serial; + layers.Add(layer); + } + return layers; + } + private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct) { if (poId is null) return; @@ -472,5 +581,7 @@ public sealed class GrnService : IGrnService l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal, l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero), l.HoldStatus, l.BatchId, - l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList())).ToList()); + l.WarrantyNumbers.Select(w => new GrnLineWarrantyNumberDto(w.WarrantyNo, w.WarrantyPeriodMonths)).ToList(), + l.Batch?.BatchNo, l.Batch?.ExpiryDate, + l.Serials.OrderBy(s => s.GrnLineSerialId).Select(s => new GrnLineSerialDto(s.SerialNo, s.SerialId)).ToList())).ToList()); } diff --git a/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs b/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs index ac7e043..be04655 100644 --- a/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs +++ b/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs @@ -31,9 +31,26 @@ public interface IFifoCostingService /// Sales only (Sales Configuration "Allow selling beyond stock"): instead of throwing, the /// quantity stock can't cover is recorded as a negative layer at the last known unit cost. /// + /// + /// Serial-tracked issue: consume exactly these serials' qty-1 layers (count must equal + /// ), or throw SERIAL_NOT_AVAILABLE. When null, serial layers + /// are never touched — serial stock only leaves by naming its serial — so only untracked + /// (legacy, pre-serial-capture) layers are issued. Shortfall is never allowed with serials. + /// + /// + /// Issue order is FEFO then FIFO: layers whose batch expires soonest go first, then oldest + /// receipt. Items without batches therefore keep plain FIFO. + /// Task> ConsumeAsync( int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default, - bool allowShortfall = false); + bool allowShortfall = false, IReadOnlyCollection? serialIds = null); + + /// + /// Read-only (no locks) FEFO preview of how would be issued across + /// batches — for UI suggestions. Expired, on-hold and serial layers are excluded. + /// + Task> SuggestBatchesAsync( + int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default); /// /// Append an immutable ledger entry. Value defaults to round(qtyBase × unitCost, 4). @@ -60,4 +77,7 @@ public interface IFifoCostingService } /// A quantity consumed from one FIFO layer at that layer's unit cost. -public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost); +public sealed record ConsumedSegment(int LayerId, decimal Qty, decimal UnitCost, int? BatchId = null, int? SerialId = null); + +/// A suggested batch pick: of (null = unbatched stock). +public sealed record BatchAllocation(int? BatchId, string? BatchNo, DateOnly? ExpiryDate, decimal Qty); diff --git a/Backend/ERPCore/Services/Interfaces/IItemTrackingService.cs b/Backend/ERPCore/Services/Interfaces/IItemTrackingService.cs new file mode 100644 index 0000000..9d9b32c --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IItemTrackingService.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; + +namespace ERPCore.Services.Interfaces; + +/// Read side of item tracking: pickers, serial and warranty registers, batch stock. +public interface IItemTrackingService +{ + /// + /// The next free serial numbers for an item under + /// : continues after the highest number already used with that + /// prefix (received serials and numbers on draft GRNs), zero-padded to at least 4 digits — + /// e.g. prefix "PH-" after PH-0041 gives PH-0042, PH-0043, … + /// + Task> NextSerialNumbersAsync(int itemId, string prefix, int count, CancellationToken ct = default); + + /// In-stock serials of an item at a warehouse, optionally filtered by number. + Task> GetAvailableSerialsAsync(int itemId, int warehouseId, string? q, int take, CancellationToken ct = default); + + /// Issuable (not expired, not on hold) stock per batch, soonest expiry first. + Task> GetAvailableBatchesAsync(int itemId, int warehouseId, CancellationToken ct = default); + + /// How posting would pick FEFO across batches. + Task> SuggestBatchesAsync(int itemId, int warehouseId, decimal qty, CancellationToken ct = default); + + Task> ListSerialsAsync( + PageQuery query, int? itemId, int? warehouseId, SerialStatus? status, CancellationToken ct = default); + + Task GetSerialAsync(int serialId, CancellationToken ct = default); + + /// Exact serial-number lookup (all items) — e.g. a warranty desk scanning a unit. + Task> FindSerialAsync(string serialNo, CancellationToken ct = default); + + /// + /// Warranty register. keeps active warranties ending within + /// that many days; true/false keeps only expired / unexpired ones. + /// + Task> ListWarrantiesAsync( + PageQuery query, int? customerId, int? itemId, string? serialNo, WarrantyStatus? status, + int? expiringWithinDays, bool? expired, CancellationToken ct = default); + + /// Batch stock on hand (positive), optionally only batches expiring within N days (expired included). + Task> ListBatchStockAsync( + PageQuery query, int? itemId, int? warehouseId, int? expiringWithinDays, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs index 2344e57..0f41825 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesDomainService.cs @@ -1,3 +1,4 @@ +using ERPCore.Domain.Entities; using ERPCore.Domain.Enums; using ERPCore.Dtos.Sales; @@ -63,6 +64,18 @@ public interface ISalesDomainService bool isFreeIssue); Task IsStockedItemAsync(int itemId, CancellationToken ct = default); + + /// + /// Batch/serial rules for one sales line (docs/ITEM_TRACKING_PLAN.md Phase 4). A serial-tracked + /// item needs one serial per unit of , each InStock at + /// and not already used on another line of the same document + /// () — unless enough pre-tracking (serial-less) stock is + /// still on hand there (D6). A pinned batch must belong to the item and not be expired. + /// Returns the chosen serials as tracked entities, for the line's serial rows. + /// + Task> ValidateLineTrackingAsync( + int itemId, int warehouseId, decimal totalQty, int? batchId, IReadOnlyCollection? serialIds, + ISet documentSerials, CancellationToken ct = default); } public sealed record SalesLineComputation( diff --git a/Backend/ERPCore/Services/Interfaces/ISerialTrackingService.cs b/Backend/ERPCore/Services/Interfaces/ISerialTrackingService.cs new file mode 100644 index 0000000..20ac2bf --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISerialTrackingService.cs @@ -0,0 +1,55 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; + +namespace ERPCore.Services.Interfaces; + +/// +/// Shared serial rules for stock documents other than GRN and sales — purchase returns, +/// transfers, adjustments and counts (docs/ITEM_TRACKING_PLAN.md Phases 6–7). Validation is +/// done before the document's transaction; runs inside it. +/// +public interface ISerialTrackingService +{ + /// + /// Product Configuration "Serial numbers" switch (). + /// Off: no document asks for or accepts serials, and serial-mode items are handled as + /// untracked stock. Defaults to on when the config row is missing. + /// + Task IsEnabledAsync(CancellationToken ct = default); + + /// True when is serial-tracked and the switch is on. + Task IsSerialTrackedAsync(Item item, CancellationToken ct = default); + + /// + /// Throws CONFIG_DISABLED when serials were sent for a serial-mode item while the switch + /// is off — they are refused rather than silently dropped. + /// + void RejectSerialsWhileDisabled(Item item, int serialCount); + + /// + /// Serials leaving . For a serial-tracked item: one per unit of + /// , each currently at that warehouse in one of + /// and not used elsewhere on the same document + /// (); with none named, only pre-tracking (serial-less) + /// stock may go (D6). Any other item must name none. Returns tracked entities. + /// + Task> ResolveOutboundAsync( + Item item, int warehouseId, decimal qty, IReadOnlyCollection? serialIds, + ISet documentSerials, IReadOnlyCollection allowedStatuses, CancellationToken ct = default); + + /// + /// Serials arriving by adjustment or count: each number must be new for the item (it is + /// added to the context, unsaved and without a location — save before building + /// s, which need its id) or an existing serial + /// that is not currently in stock anywhere (it is brought back). One per unit of + /// ; required for a serial-tracked item, rejected otherwise. + /// + Task> ResolveInboundAsync( + Item item, int warehouseId, decimal qty, IReadOnlyCollection? serialNos, + ISet documentSerialNos, CancellationToken ct = default); + + /// Sets status and location on each serial and appends its . + Task MoveAsync( + IEnumerable serials, SerialStatus status, int? warehouseId, int? binId, + string sourceDocType, int sourceDocId, DateTime now, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IStockMutator.cs b/Backend/ERPCore/Services/Interfaces/IStockMutator.cs index 1614d19..829f763 100644 --- a/Backend/ERPCore/Services/Interfaces/IStockMutator.cs +++ b/Backend/ERPCore/Services/Interfaces/IStockMutator.cs @@ -2,8 +2,16 @@ using ERPCore.Domain.Entities; namespace ERPCore.Services.Interfaces; -/// A signed base-UOM change to an item's stock at one warehouse. -public sealed record StockDelta(int ItemId, int? BinId, int? BatchId, decimal QtyDelta); +/// +/// A signed base-UOM change to an item's stock at one warehouse. +/// (serial-tracked items): an outbound delta consumes exactly those +/// serials' layers; an inbound delta creates one qty-1 layer per serial. Either way the count +/// must equal ||, and the caller owns the serials' status/location. +/// overrides the inbound cost (default: last cost at the warehouse). +/// +public sealed record StockDelta( + int ItemId, int? BinId, int? BatchId, decimal QtyDelta, + IReadOnlyList? SerialIds = null, decimal? UnitCost = null); /// /// Shared poster for stock-affecting documents (adjustments, count variances, diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index a6d13b4..944d6e6 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -298,7 +298,7 @@ public sealed class ItemService : IItemService await ValidateReferencesAsync( request.CategoryId, request.SubCategoryId, request.BrandId, request.BaseUomId, request.DefaultVendorId, request.TaxRateId, config, ct); - ValidateFeatureConfiguration(request, config); + ValidateFeatureConfiguration(request, config, item.TrackingMode); // The base UOM is the sole meaning of every quantity recorded against this item — // stock layers, ledger rows and document lines are all plain counts of it. Once any @@ -490,15 +490,20 @@ public sealed class ItemService : IItemService request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, request.TrackingMode, request.Warranty, config); - private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config) + /// + /// An item keeps the tracking mode it already has even after that feature is switched off — + /// otherwise it could not be edited at all; only a change to a disabled mode is refused. + /// + private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config, TrackingMode currentTrackingMode) => ValidateFeatureConfiguration( request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice, - request.TrackingMode, request.Warranty, config); + request.TrackingMode, request.Warranty, config, currentTrackingMode); private static void ValidateFeatureConfiguration( decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice, - TrackingMode trackingMode, Warranty warranty, ProductConfigDto config) + TrackingMode trackingMode, Warranty warranty, ProductConfigDto config, TrackingMode? currentTrackingMode = null) { + var trackingUnchanged = trackingMode == currentTrackingMode; if (!config.SellingPricesEnabled && (sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null)) throw new DomainException( @@ -506,13 +511,13 @@ public sealed class ItemService : IItemService "Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.", 422); - if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial) + if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial && !trackingUnchanged) throw new DomainException( ErrorCodes.ConfigDisabled, "Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.", 422); - if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch) + if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch && !trackingUnchanged) throw new DomainException( ErrorCodes.ConfigDisabled, "Batch lists are disabled in the product configuration; trackingMode cannot be Batch.", @@ -620,7 +625,7 @@ public sealed class ItemService : IItemService private static ItemScanResultDto ToScanResult(Serial s) => new( s.Item!.ItemId, s.Item.Sku, s.Item.Name, s.Item.TrackingMode, s.Item.BaseUomId, "Serial", - null, null, null, s.SerialId, s.SerialNo, s.Status); + null, null, null, s.SerialId, s.SerialNo, s.Status.ToString()); private static ItemDetailDto ToDetail(Item i) => new( i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, diff --git a/Backend/ERPCore/Services/PurchaseReturnService.cs b/Backend/ERPCore/Services/PurchaseReturnService.cs index 54581e5..1dff8b2 100644 --- a/Backend/ERPCore/Services/PurchaseReturnService.cs +++ b/Backend/ERPCore/Services/PurchaseReturnService.cs @@ -38,6 +38,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService private readonly IRepository _ledger; private readonly IStockMutator _mutator; private readonly IFifoCostingService _fifo; + private readonly IRepository _serials; + private readonly ISerialTrackingService _serialTracking; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; private readonly INotificationService _notifications; @@ -49,9 +51,12 @@ public sealed class PurchaseReturnService : IPurchaseReturnService IRepository returns, IRepository vendors, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository grnLines, IRepository layers, IRepository ledger, IStockMutator mutator, IFifoCostingService fifo, + IRepository serials, ISerialTrackingService serialTracking, INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { + _serials = serials; + _serialTracking = serialTracking; _returns = returns; _vendors = vendors; _warehouses = warehouses; @@ -130,11 +135,24 @@ public sealed class PurchaseReturnService : IPurchaseReturnService // layer(s) below, not the generic mutator — track which by GrnLineId so the two // paths don't double-handle the same line. var rejectedGrnLineIds = new HashSet(); + var documentSerials = new HashSet(); + var serialsByLine = new Dictionary>(); foreach (var line in request.Lines) { - if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + + // Rejected-line serials are checked against that line's own layers below; anything + // else must name serials actually on hand (D6: or leave only pre-tracking stock). + var isRejectedLine = line.GrnLineId is not null && await _grnLines.Query().AnyAsync(g => g.GrnLineId == line.GrnLineId && g.HoldStatus == HoldStatus.Rejected, ct); + if (!isRejectedLine || line.SerialIds is { Count: > 0 }) + serialsByLine[line] = await _serialTracking.ResolveOutboundAsync( + item, request.WarehouseId, line.Qty, line.SerialIds, documentSerials, + new[] { SerialStatus.InStock, SerialStatus.Damaged }, ct); + if (isRejectedLine && serialsByLine.TryGetValue(line, out var named) && named.Any(s => s.GrnLineId != line.GrnLineId)) + throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials were not received on GRN line {line.GrnLineId}.", 422); + if (line.GrnLineId is not null) { var grnLine = await _grnLines.Query().AsNoTracking().FirstOrDefaultAsync(g => g.GrnLineId == line.GrnLineId, ct) @@ -158,7 +176,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService var actor = _currentUser.AuditUserId; var deltas = request.Lines .Where(l => l.GrnLineId is null || !rejectedGrnLineIds.Contains(l.GrnLineId.Value)) - .Select(l => new StockDelta(l.ItemId, null, null, -l.Qty)).ToList(); + .Select(l => new StockDelta(l.ItemId, null, null, -l.Qty, + serialsByLine.TryGetValue(l, out var s) ? s.Select(x => x.SerialId).ToList() : null)).ToList(); var rejectedLines = request.Lines.Where(l => l.GrnLineId is not null && rejectedGrnLineIds.Contains(l.GrnLineId.Value)).ToList(); var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token => @@ -196,10 +215,31 @@ public sealed class PurchaseReturnService : IPurchaseReturnService .OrderBy(l => l.ReceiptDate).ThenBy(l => l.LayerId) .ToListAsync(token); + // A serial line's layers are one per unit: named serials take exactly theirs; + // with none named, the whole remaining line may go back (every serial on it). + var named = serialsByLine.TryGetValue(line, out var lineSerials) ? lineSerials : Array.Empty(); + if (named.Count > 0) + { + var ids = named.Select(s => s.SerialId).ToHashSet(); + layers = layers.Where(l => l.SerialId != null && ids.Contains(l.SerialId.Value)).ToList(); + if (layers.Count != named.Count) + throw new DomainException(ErrorCodes.SerialNotAvailable, $"Some serials are no longer on GRN line {line.GrnLineId}'s rejected stock.", 409); + } + else if (layers.Any(l => l.SerialId != null) && await _serialTracking.IsEnabledAsync(token)) + { + if (line.Qty != layers.Sum(l => l.QtyRemaining)) + throw new DomainException(ErrorCodes.TrackingRequired, + $"GRN line {line.GrnLineId} is serial-tracked; select which {line.Qty} serial(s) go back.", 422); + named = await _serials.Query() + .Where(s => layers.Select(l => l.SerialId).Contains(s.SerialId)).ToListAsync(token); + serialsByLine[line] = named; + } + if (!balances.TryGetValue(line.ItemId, out var bal)) bal = await _fifo.GetOnHandAsync(line.ItemId, request.WarehouseId, token); var remaining = line.Qty; + var takenSerialIds = new List(); foreach (var layer in layers) { if (remaining <= 0) break; @@ -207,6 +247,7 @@ public sealed class PurchaseReturnService : IPurchaseReturnService layer.QtyRemaining -= take; remaining -= take; bal -= take; + if (layer.SerialId is int takenSerial && layer.QtyRemaining == 0) takenSerialIds.Add(takenSerial); var entry = await _fifo.PostLedgerAsync( layer.ItemId, layer.WarehouseId, null, layer.BatchId, null, actor, @@ -214,8 +255,17 @@ public sealed class PurchaseReturnService : IPurchaseReturnService refs.Add(entry); } balances[line.ItemId] = bal; + + // Serial numbers switched off: serial units went back oldest-first without being + // named — still record them as returned so the register doesn't show them in stock. + if (named.Count == 0 && takenSerialIds.Count > 0) + serialsByLine[line] = await _serials.Query().Where(s => takenSerialIds.Contains(s.SerialId)).ToListAsync(token); } + foreach (var serials in serialsByLine.Values) + await _serialTracking.MoveAsync(serials, SerialStatus.ReturnedToVendor, null, null, + DocumentTypes.PurchaseReturn, ret.ReturnId, now, token); + var totalValue = refs.Sum(r => r.Value); if (totalValue > 0) { diff --git a/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs b/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs index e3a8780..20b54ce 100644 --- a/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs +++ b/Backend/ERPCore/Services/SalesDocumentWorkflowService.cs @@ -20,7 +20,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService public async Task LoadEditableInvoiceAsync(int salesInvoiceId, uint expectedRowVersion, CancellationToken ct = default) { - var invoice = await _invoices.Query().Include(x => x.Lines) + var invoice = await _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); if (invoice.RowVersion != expectedRowVersion) @@ -32,7 +32,7 @@ public sealed class SalesDocumentWorkflowService : ISalesDocumentWorkflowService public async Task LoadEditableSlipAsync(int salesSlipId, uint expectedRowVersion, CancellationToken ct = default) { - var slip = await _slips.Query().Include(x => x.Lines) + var slip = await _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); if (slip.RowVersion != expectedRowVersion) diff --git a/Backend/ERPCore/Services/SalesDomainService.cs b/Backend/ERPCore/Services/SalesDomainService.cs index 8e974b1..a07c8d6 100644 --- a/Backend/ERPCore/Services/SalesDomainService.cs +++ b/Backend/ERPCore/Services/SalesDomainService.cs @@ -16,6 +16,10 @@ public sealed class SalesDomainService : ISalesDomainService private readonly IRepository _taxRates; private readonly ISalesPricingService _pricing; private readonly IRepository _salesConfig; + private readonly IRepository _batches; + private readonly IRepository _serials; + private readonly IRepository _layers; + private readonly ISerialTrackingService _serialTracking; public SalesDomainService( IRepository customers, @@ -24,8 +28,16 @@ public sealed class SalesDomainService : ISalesDomainService IRepository items, IRepository taxRates, ISalesPricingService pricing, - IRepository salesConfig) + IRepository salesConfig, + IRepository batches, + IRepository serials, + IRepository layers, + ISerialTrackingService serialTracking) { + _serialTracking = serialTracking; + _batches = batches; + _serials = serials; + _layers = layers; _customers = customers; _warehouses = warehouses; _users = users; @@ -154,6 +166,66 @@ public sealed class SalesDomainService : ISalesDomainService .Select(x => x.StockNature == StockNature.Stocked) .FirstAsync(ct); + public async Task> ValidateLineTrackingAsync( + int itemId, int warehouseId, decimal totalQty, int? batchId, IReadOnlyCollection? serialIds, + ISet documentSerials, CancellationToken ct = default) + { + var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == itemId, ct); + var ids = serialIds?.ToList() ?? new List(); + + if (batchId is not null) + { + var batch = await _batches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BatchId == batchId && b.ItemId == itemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Batch {batchId} does not belong to item {item.Sku}.", 422); + if (batch.ExpiryDate is DateOnly expiry && expiry < DateOnly.FromDateTime(DateTime.UtcNow)) + throw new DomainException(ErrorCodes.ExpiredBatchBlocked, $"Batch {batch.BatchNo} expired on {expiry:yyyy-MM-dd} and cannot be sold.", 422); + } + + // Serial numbers switched off in Product Configuration: a serial-mode item sells like + // untracked stock (no serials asked, none accepted). + if (!await _serialTracking.IsSerialTrackedAsync(item, ct)) + { + _serialTracking.RejectSerialsWhileDisabled(item, ids.Count); + if (ids.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422); + return Array.Empty(); + } + + if (ids.Count == 0) + { + // D6: stock received before serial capture has no serials — it can still be sold + // without naming any, but only while enough of it is left at this warehouse. + if (item.StockNature != StockNature.Stocked) return Array.Empty(); + var untracked = await _layers.Query().AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.SerialId == null && l.QtyRemaining > 0) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + if (untracked < totalQty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; select {totalQty} serial number(s).", 422); + return Array.Empty(); + } + + if (ids.Count != totalQty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; select exactly {totalQty} serial number(s), got {ids.Count}.", 422); + foreach (var id in ids) + if (!documentSerials.Add(id)) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is selected more than once on this document.", 422); + + // Tracked on purpose: the caller hangs these off the new line's serial rows as navigations. + var serials = await _serials.Query().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct); + foreach (var id in ids) + { + var serial = serials.FirstOrDefault(s => s.SerialId == id); + if (serial is null || serial.ItemId != itemId) + throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422); + if (serial.Status != SerialStatus.InStock || serial.WarehouseId != warehouseId) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial {serial.SerialNo} is {serial.Status}{(serial.Status == SerialStatus.InStock ? " at another warehouse" : string.Empty)} and cannot be sold from warehouse {warehouseId}.", 422); + } + return serials; + } + private static decimal CalculateDiscount(decimal gross, SalesDiscountMode mode, decimal discountPct, decimal discountValue, decimal legacyDiscountAmount) { var computed = mode == SalesDiscountMode.Amount diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 9b4b7bb..23c73b7 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -81,7 +81,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService public async Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default) { - var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); return invoice is null ? null : new ETagged(_mapping.MapInvoice(invoice), invoice.RowVersion); } @@ -155,14 +155,14 @@ public sealed class SalesInvoiceService : ISalesInvoiceService public async Task PostAsync(int salesInvoiceId, CancellationToken ct = default) { await _posting.PostInvoiceAsync(salesInvoiceId, ct); - var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines) + var invoice = await _invoices.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstAsync(x => x.SalesInvoiceId == salesInvoiceId, ct); return _mapping.MapInvoice(invoice); } public async Task CancelAsync(int salesInvoiceId, CancellationToken ct = default) { - var invoice = await _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) + var invoice = await _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct) ?? throw new NotFoundException($"Sales invoice {salesInvoiceId} was not found."); if (invoice.Status != SalesInvoiceStatus.Draft) throw new ConflictException($"Sales invoice {salesInvoiceId} is {invoice.Status} and cannot be cancelled."); @@ -188,10 +188,12 @@ public sealed class SalesInvoiceService : ISalesInvoiceService throw new DomainException(ErrorCodes.Validation, "Free items are turned off in Sales Configuration.", 422); if (requests.Any(r => r.DiscountPct > 0m || r.DiscountAmount > 0m || r.DiscountValue > 0m) && !await _sales.AreDiscountsAllowedAsync(ct)) throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in Sales Configuration.", 422); + var documentSerials = new HashSet(); foreach (var r in requests) { var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, r.SerialIds, documentSerials, ct); var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var unitPrice = resolved.UnitPrice; var priceSource = resolved.PriceSource; @@ -217,7 +219,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService TaxPct = taxPct, TaxAmount = calc.TaxAmount, IsFreeIssue = r.IsFreeIssue, - ParentLineId = r.ParentLineId + ParentLineId = r.ParentLineId, + BatchId = r.BatchId, + Serials = serials.Select(s => new SalesInvoiceLineSerial { SerialId = s.SerialId, Serial = s }).ToList() }); } return lines; diff --git a/Backend/ERPCore/Services/SalesMappingService.cs b/Backend/ERPCore/Services/SalesMappingService.cs index bb1d866..1aafd88 100644 --- a/Backend/ERPCore/Services/SalesMappingService.cs +++ b/Backend/ERPCore/Services/SalesMappingService.cs @@ -38,7 +38,8 @@ public sealed class SalesMappingService : ISalesMappingService invoice.Lines.Select(l => new SalesInvoiceLineDto( l.SalesInvoiceLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, - l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId)).ToList()); + l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, + l.BatchId, l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList()); public SalesSlipDto MapSlip(SalesSlip slip) => new( @@ -49,5 +50,6 @@ public sealed class SalesMappingService : ISalesMappingService l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, - l.BatchId, l.SerialId)).ToList()); + l.BatchId, l.SerialId, + l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())).ToList()); } diff --git a/Backend/ERPCore/Services/SalesPostingService.cs b/Backend/ERPCore/Services/SalesPostingService.cs index adfcd1f..4385931 100644 --- a/Backend/ERPCore/Services/SalesPostingService.cs +++ b/Backend/ERPCore/Services/SalesPostingService.cs @@ -21,6 +21,10 @@ public sealed class SalesPostingService : ISalesPostingService private readonly IRepository _bundles; private readonly IRepository _items; private readonly IRepository _serials; + private readonly IRepository _allocations; + private readonly IRepository _warranties; + private readonly IRepository _serialMovements; + private readonly ISerialTrackingService _serialTracking; private readonly IFifoCostingService _fifo; private readonly ISalesDomainService _sales; private readonly ICurrentUser _currentUser; @@ -34,6 +38,10 @@ public sealed class SalesPostingService : ISalesPostingService IRepository bundles, IRepository items, IRepository serials, + IRepository allocations, + IRepository warranties, + IRepository serialMovements, + ISerialTrackingService serialTracking, IFifoCostingService fifo, ISalesDomainService sales, ICurrentUser currentUser, @@ -46,6 +54,10 @@ public sealed class SalesPostingService : ISalesPostingService _bundles = bundles; _items = items; _serials = serials; + _allocations = allocations; + _warranties = warranties; + _serialMovements = serialMovements; + _serialTracking = serialTracking; _fifo = fifo; _sales = sales; _currentUser = currentUser; @@ -150,15 +162,17 @@ public sealed class SalesPostingService : ISalesPostingService public Task PostInvoiceAsync(int salesInvoiceId, CancellationToken ct = default) => PostAsync( - load: () => _invoices.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct), + load: () => _invoices.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesInvoiceId == salesInvoiceId, ct), notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, null, null)), + getLines: x => x.Lines.Select(l => new PostingLine(l.SalesInvoiceLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, + l.BatchId, l.Serials.Select(s => s.SerialId).ToList())), setPosted: x => x.Status = SalesInvoiceStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.SalesInvoice, getDocId: x => x.SalesInvoiceId, + getCustomerId: x => x.CustomerId, ct: ct, afterConsumption: PostInvoiceRevenueAsync); @@ -208,15 +222,19 @@ public sealed class SalesPostingService : ISalesPostingService public Task PostSlipAsync(int salesSlipId, CancellationToken ct = default) => PostAsync( - load: () => _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct), + load: () => _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct), notFoundMessage: $"Sales slip {salesSlipId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId, l.SerialId)), + // An old draft may still carry only the legacy single SerialId. + getLines: x => x.Lines.Select(l => new PostingLine(l.SalesSlipLineId, l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty, l.BatchId, + l.Serials.Count > 0 ? l.Serials.Select(s => s.SerialId).ToList() + : l.SerialId is int single ? new List { single } : new List())), setPosted: x => x.Status = SalesSlipStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.SalesSlip, getDocId: x => x.SalesSlipId, + getCustomerId: x => x.CustomerId, ct: ct); public Task PostBundleAsync(int bundleSaleId, CancellationToken ct = default) @@ -225,11 +243,12 @@ public sealed class SalesPostingService : ISalesPostingService notFoundMessage: $"Bundle sale {bundleSaleId} was not found.", statusSelector: x => x.Status, ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.", - getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, null)), + getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.BundleSaleLineId, l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m, null, new List())), setPosted: x => x.Status = BundleSaleStatus.Posted, setUpdated: x => x.UpdatedAt = DateTime.UtcNow, sourceDocType: DocumentTypes.BundleSale, getDocId: x => x.BundleSaleId, + getCustomerId: x => x.CustomerId, ct: ct); private async Task PostAsync( @@ -242,6 +261,7 @@ public sealed class SalesPostingService : ISalesPostingService Action setUpdated, string sourceDocType, Func getDocId, + Func getCustomerId, CancellationToken ct, /// Run inside the same transaction, after consumption and status flip, with the /// total COGS this call just consumed — the invoice-only revenue-recognition hook. @@ -262,27 +282,65 @@ public sealed class SalesPostingService : ISalesPostingService await _uow.ExecuteInTransactionAsync(async token => { var totalCogs = 0m; + var now = DateTime.UtcNow; + var docId = getDocId(doc); foreach (var line in getLines(doc)) { if (line.Qty <= 0) continue; - if (!await _sales.IsStockedItemAsync(line.ItemId, token)) + var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token); + if (item.StockNature != StockNature.Stocked) continue; + var serials = await LoadSerialsForPostingAsync(item, line, token); + // Line quantities are already a count of the item's base UOM — sales documents // carry no unit of their own — so this is the quantity FIFO consumes verbatim. - var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, allowShortfall); - var cost = consumed.Count == 0 ? 0m : consumed.Sum(x => x.Qty * x.UnitCost) / consumed.Sum(x => x.Qty); - totalCogs += cost * line.Qty; - await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, line.BatchId, line.SerialId, _currentUser.AuditUserId, - Direction.Out, line.Qty, cost, 0m, sourceDocType, getDocId(doc), DateTime.UtcNow, token); + // Serial lines consume exactly their serials' layers and can never go short; batch + // lines with no pinned batch are picked FEFO (docs/ITEM_TRACKING_PLAN.md Phase 3). + var consumed = await _fifo.ConsumeAsync(line.ItemId, line.WarehouseId, line.BatchId, line.Qty, token, + // With serial numbers switched off, a serial-mode item is plain stock and may go short. + allowShortfall && serials.Count == 0 && !await _serialTracking.IsSerialTrackedAsync(item, token), + serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null); + totalCogs += consumed.Sum(x => x.Qty * x.UnitCost); - // ConsumeAsync has no serial parameter — closing the loop on a serial-pinned - // line means flipping the serial's own status once its stock is actually gone. - if (line.SerialId.HasValue) + // One ledger row per batch actually issued (usually one) — a FEFO pick can span + // several, and each ledger row must carry the batch it moved. + foreach (var group in consumed.GroupBy(x => x.BatchId)) { - var serial = await _serials.Query().FirstOrDefaultAsync(s => s.SerialId == line.SerialId, token); - if (serial is not null) serial.Status = "Sold"; + var qty = group.Sum(x => x.Qty); + var cost = qty == 0 ? 0m : group.Sum(x => x.Qty * x.UnitCost) / qty; + await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, group.Key, + serials.Count == 1 ? serials[0].SerialId : null, _currentUser.AuditUserId, + Direction.Out, qty, cost, 0m, sourceDocType, docId, now, token); } + + foreach (var segment in consumed) + { + await _allocations.AddAsync(new SalesLineAllocation + { + SourceDocType = sourceDocType, + SourceDocId = docId, + SourceLineId = line.LineId, + ItemId = line.ItemId, + WarehouseId = line.WarehouseId, + BatchId = segment.BatchId, + SerialId = segment.SerialId, + Qty = segment.Qty, + UnitCost = segment.UnitCost, + CreatedAt = now + }, token); + } + + foreach (var serial in serials) + { + serial.Status = SerialStatus.Sold; + serial.WarehouseId = null; + serial.BinId = null; + serial.UpdatedAt = now; + await _serialMovements.AddAsync(SerialMovement.Of(serial, line.WarehouseId, sourceDocType, docId, _currentUser.AuditUserId, now), token); + } + + await RegisterWarrantyAsync(item, line, serials, getCustomerId(doc), sourceDocType, docId, now, token); } setPosted(doc); @@ -292,5 +350,78 @@ public sealed class SalesPostingService : ISalesPostingService }, ct); } - private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty, int? BatchId, int? SerialId); + /// + /// Re-checks a line's chosen serials at post time — the draft did not reserve them, so another + /// document may have sold one since — and returns them tracked for the status flip. A + /// serial-tracked line with none chosen may only post from pre-tracking stock (D6); the + /// consume call enforces that by never touching serial layers without serial ids. A bundle + /// line has nowhere to choose serials, so a serial item in a bundle surfaces the same way. + /// + private async Task> LoadSerialsForPostingAsync(Item item, PostingLine line, CancellationToken ct) + { + if (line.SerialIds.Count == 0) + { + // Same D6 rule the draft was checked against (enough serial-less stock left here), + // re-run because that stock may have been issued since. + if (item.TrackingMode == TrackingMode.Serial) + await _sales.ValidateLineTrackingAsync(item.ItemId, line.WarehouseId, line.Qty, null, null, new HashSet(), ct); + return Array.Empty(); + } + + if (line.SerialIds.Count != line.Qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} needs exactly {line.Qty} serial number(s); {line.SerialIds.Count} are selected.", 422); + + var serials = await _serials.Query().Where(s => line.SerialIds.Contains(s.SerialId)).ToListAsync(ct); + var unavailable = serials.Where(s => s.ItemId != item.ItemId || s.Status != SerialStatus.InStock || s.WarehouseId != line.WarehouseId) + .Select(s => s.SerialNo).ToList(); + if (unavailable.Count > 0 || serials.Count != line.SerialIds.Count) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial(s) {string.Join(", ", unavailable)} of item {item.Sku} are no longer in stock at this warehouse.", 409); + return serials; + } + + /// + /// Starts the customer's warranty at posting (docs/ITEM_TRACKING_PLAN.md Phase 5): one + /// registration per serial, or one per line for a warranty item that is not serial-tracked. + /// Coverage is the period snapshotted on the serial at receipt, else the item's own. + /// + private async Task RegisterWarrantyAsync( + Item item, PostingLine line, IReadOnlyList serials, int customerId, + string sourceDocType, int docId, DateTime now, CancellationToken ct) + { + if (item.Warranty != Warranty.Warranty) return; + var start = DateOnly.FromDateTime(now); + + WarrantyRegistration Build(int? serialId, decimal qty, int months) => new() + { + ItemId = item.ItemId, + SerialId = serialId, + CustomerId = customerId, + SourceDocType = sourceDocType, + SourceDocId = docId, + SourceLineId = line.LineId, + Qty = qty, + StartDate = start, + PeriodMonths = months, + EndDate = start.AddMonths(months), + Status = WarrantyStatus.Active, + CreatedAt = now + }; + + if (serials.Count > 0) + { + foreach (var serial in serials) + if ((serial.WarrantyPeriodMonths ?? item.WarrantyPeriodMonths) is int months) + await _warranties.AddAsync(Build(serial.SerialId, 1m, months), ct); + return; + } + + if (item.WarrantyPeriodMonths is int itemMonths) + await _warranties.AddAsync(Build(null, line.Qty, itemMonths), ct); + } + + private sealed record PostingLine( + int LineId, int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty, + int? BatchId, IReadOnlyList SerialIds); } diff --git a/Backend/ERPCore/Services/SalesReturnService.cs b/Backend/ERPCore/Services/SalesReturnService.cs index d648a63..2aba1d0 100644 --- a/Backend/ERPCore/Services/SalesReturnService.cs +++ b/Backend/ERPCore/Services/SalesReturnService.cs @@ -17,15 +17,24 @@ namespace ERPCore.Services; /// /// Sales-return service. Auto-posts with a mandatory Return reason code and /// generates an inbound stock movement via the shared -/// (positive delta — creates an inbound FIFO layer at last cost). Single UoW +/// (positive delta — creates an inbound FIFO layer). Single UoW /// transaction, mirroring with the direction /// reversed. Also posts a real GL journal entry reversing Inventory/COGS for the /// stock movement's value (see for why revenue/tax /// aren't part of it) — reuses the Sales Day End accounts since that's the module /// whose COGS this reverses. +/// +/// Item tracking (docs/ITEM_TRACKING_PLAN.md Phase 6): a return against an invoice line restocks +/// into the batch(es)/serials that line actually issued (), at +/// the cost they were issued at; returned serials go back to InStock — or Damaged — at the return +/// warehouse, and their warranty is voided. +/// /// public sealed class SalesReturnService : ISalesReturnService { + // Return reasons whose goods are not resaleable (see DataSeeder.StandardReasonCodes). + private static readonly HashSet DamagedReasonCodes = new(StringComparer.OrdinalIgnoreCase) { "DEF", "QREJ" }; + private readonly IRepository _returns; private readonly IRepository _customers; private readonly IRepository _warehouses; @@ -34,6 +43,12 @@ public sealed class SalesReturnService : ISalesReturnService private readonly IRepository _salesInvoiceLines; private readonly IRepository _returnLines; private readonly IRepository _ledger; + private readonly IRepository _batches; + private readonly IRepository _serials; + private readonly IRepository _allocations; + private readonly IRepository _warranties; + private readonly IRepository _serialMovements; + private readonly ISerialTrackingService _serialTracking; private readonly IStockMutator _mutator; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -44,7 +59,9 @@ public sealed class SalesReturnService : ISalesReturnService public SalesReturnService( IRepository returns, IRepository customers, IRepository warehouses, IRepository items, IRepository reasonCodes, IRepository salesInvoiceLines, - IRepository returnLines, IRepository ledger, IStockMutator mutator, + IRepository returnLines, IRepository ledger, + IRepository batches, IRepository serials, IRepository allocations, + IRepository warranties, IRepository serialMovements, ISerialTrackingService serialTracking, IStockMutator mutator, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow, IGeneralLedgerService gl, IGlAccountConfigService glAccounts) { @@ -56,6 +73,12 @@ public sealed class SalesReturnService : ISalesReturnService _salesInvoiceLines = salesInvoiceLines; _returnLines = returnLines; _ledger = ledger; + _batches = batches; + _serials = serials; + _allocations = allocations; + _warranties = warranties; + _serialMovements = serialMovements; + _serialTracking = serialTracking; _mutator = mutator; _numbers = numbers; _currentUser = currentUser; @@ -91,7 +114,7 @@ public sealed class SalesReturnService : ISalesReturnService public async Task GetAsync(int returnId, CancellationToken ct = default) { var ret = await _returns.Query().AsNoTracking() - .Include(r => r.Lines) + .Include(r => r.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(r => r.ReturnId == returnId, ct); if (ret is null) return null; @@ -119,15 +142,19 @@ public sealed class SalesReturnService : ISalesReturnService ?? throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} does not exist.", 422); if (reason.Context != ReasonContext.Return) throw new DomainException(ErrorCodes.Validation, $"Reason code {request.ReasonCodeId} is not a Return reason.", 422); + var damaged = request.MarkDamaged ?? DamagedReasonCodes.Contains(reason.Code); // Original sold qty is never mutated — "remaining returnable" is computed from // return history instead, so the invoice keeps recording what was actually sold. var pendingByInvoiceLine = new Dictionary(); + var requestSerials = new HashSet(); + var resolved = new List(); foreach (var line in request.Lines) { - if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + var allocations = new List(); if (line.SalesInvoiceLineId is not null) { var invoiceLineId = line.SalesInvoiceLineId.Value; @@ -145,11 +172,20 @@ public sealed class SalesReturnService : ISalesReturnService if (line.Qty > remaining) throw new DomainException(ErrorCodes.Validation, $"Insufficient quantity — only {remaining} remain returnable on sales invoice line {invoiceLineId} (requested {line.Qty}).", 422); pendingByInvoiceLine[invoiceLineId] = pending + line.Qty; + + // Tracked: QtyReturned is updated below, inside the transaction. + allocations = await _allocations.Query() + .Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceLineId == invoiceLineId) + .OrderBy(a => a.SalesLineAllocationId) + .ToListAsync(ct); } + + resolved.Add(await ResolveTrackingAsync(item, line, allocations, requestSerials, ct)); } var now = DateTime.UtcNow; - var deltas = request.Lines.Select(l => new StockDelta(l.ItemId, null, null, l.Qty)).ToList(); + var deltas = resolved.Select(r => new StockDelta( + r.Input.ItemId, null, r.BatchId, r.Input.Qty, r.Serials.Select(s => s.SerialId).ToList(), r.UnitCost)).ToList(); var (entity, ledgerEntries) = await _uow.ExecuteInTransactionAsync(async token => { @@ -163,11 +199,13 @@ public sealed class SalesReturnService : ISalesReturnService Status = ReturnStatus.Posted, CreatedBy = _currentUser.AuditUserId, CreatedAt = now, - Lines = request.Lines.Select(l => new SalesReturnLine + Lines = resolved.Select(r => new SalesReturnLine { - SalesInvoiceLineId = l.SalesInvoiceLineId, - ItemId = l.ItemId, - Qty = l.Qty + SalesInvoiceLineId = r.Input.SalesInvoiceLineId, + ItemId = r.Input.ItemId, + Qty = r.Input.Qty, + BatchId = r.BatchId, + Serials = r.Serials.Select(s => new SalesReturnLineSerial { SerialId = s.SerialId, Serial = s }).ToList() }).ToList() }; await _returns.AddAsync(ret, token); @@ -175,6 +213,13 @@ public sealed class SalesReturnService : ISalesReturnService var refs = await _mutator.ApplyAsync(request.WarehouseId, DocumentTypes.SalesReturn, ret.ReturnId, now, deltas, token); + foreach (var r in resolved) + { + await ApplyReturnEffectsAsync(r, request.WarehouseId, damaged, now, token); + foreach (var serial in r.Serials) + await _serialMovements.AddAsync(SerialMovement.Of(serial, null, DocumentTypes.SalesReturn, ret.ReturnId, _currentUser.AuditUserId, now), token); + } + var totalValue = refs.Sum(r => r.Value); if (totalValue > 0) { @@ -218,17 +263,182 @@ public sealed class SalesReturnService : ISalesReturnService .Select(g => new { SalesInvoiceLineId = g.Key, Returned = g.Sum(x => x.Qty) }) .ToDictionaryAsync(x => x.SalesInvoiceLineId, x => x.Returned, ct); + var allocations = await _allocations.Query().AsNoTracking() + .Where(a => a.SourceDocType == DocumentTypes.SalesInvoice && a.SourceDocId == salesInvoiceId) + .Select(a => new + { + a.SourceLineId, + Dto = new SalesLineAllocationDto( + a.BatchId, a.Batch != null ? a.Batch.BatchNo : null, a.Batch != null ? a.Batch.ExpiryDate : null, + a.SerialId, a.Serial != null ? a.Serial.SerialNo : null, a.Qty, a.QtyReturned) + }) + .ToListAsync(ct); + return lines .Select(l => new SalesInvoiceLineRemainingDto( l.SalesInvoiceLineId, - l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m))) + l.Qty - (returnedByLine.TryGetValue(l.SalesInvoiceLineId, out var returned) ? returned : 0m), + allocations.Where(a => a.SourceLineId == l.SalesInvoiceLineId).Select(a => a.Dto).ToList())) .ToList(); } + /// + /// Works out which batch/serials one return line restocks, and at what cost. Against an + /// invoice line, both must come from what that line issued; free-standing, a serial item + /// must name serials that are Sold and a batch item must name its batch. Legacy invoice lines + /// posted before allocations existed (no allocation rows) return untracked, as before. + /// + private async Task ResolveTrackingAsync( + Item item, CreateSalesReturnLineInput line, List allocations, + HashSet requestSerials, CancellationToken ct) + { + var serialIds = line.SerialIds?.ToList() ?? new List(); + var batchId = line.BatchId; + var serials = new List(); + var hasAllocations = allocations.Count > 0; + + // Serial numbers switched off: a serial-mode item comes back as untracked stock. + var serialTracked = await _serialTracking.IsSerialTrackedAsync(item, ct); + if (!serialTracked) _serialTracking.RejectSerialsWhileDisabled(item, serialIds.Count); + if (!serialTracked && serialIds.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422); + + if (serialTracked) + { + var lineSoldSerials = hasAllocations && allocations.Any(a => a.SerialId != null); + if (serialIds.Count == 0) + { + if (line.SalesInvoiceLineId is null || lineSoldSerials) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; select the {line.Qty} returned serial number(s).", 422); + } + else + { + if (serialIds.Count != line.Qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku}: returning {line.Qty} unit(s) needs exactly that many serials, got {serialIds.Count}.", 422); + foreach (var id in serialIds) + if (!requestSerials.Add(id)) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is returned more than once on this document.", 422); + + serials = await _serials.Query().Where(s => serialIds.Contains(s.SerialId)).ToListAsync(ct); + foreach (var id in serialIds) + { + var serial = serials.FirstOrDefault(s => s.SerialId == id); + if (serial is null || serial.ItemId != item.ItemId) + throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422); + if (serial.Status != SerialStatus.Sold) + throw new DomainException(ErrorCodes.SerialNotAvailable, $"Serial {serial.SerialNo} is {serial.Status}, not Sold — it cannot be returned.", 422); + if (line.SalesInvoiceLineId is not null + && !allocations.Any(a => a.SerialId == id && a.QtyReturned < a.Qty)) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial {serial.SerialNo} was not sold on sales invoice line {line.SalesInvoiceLineId} (or is already returned).", 422); + } + // A serial goes back into the batch it was sold from. + batchId ??= allocations.FirstOrDefault(a => a.SerialId == serialIds[0])?.BatchId; + } + } + else if (hasAllocations) + { + var issuedBatches = allocations.Where(a => a.QtyReturned < a.Qty).Select(a => a.BatchId).Distinct().ToList(); + if (batchId is null) + { + if (issuedBatches.Count > 1) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Sales invoice line {line.SalesInvoiceLineId} issued several batches of item {item.Sku}; choose the batch being returned.", 422); + batchId = issuedBatches.FirstOrDefault(); + } + else if (!issuedBatches.Contains(batchId)) + { + throw new DomainException(ErrorCodes.Validation, + $"Batch {batchId} was not issued on sales invoice line {line.SalesInvoiceLineId}.", 422); + } + } + else if (item.TrackingMode == TrackingMode.Batch && batchId is null && line.SalesInvoiceLineId is null) + { + throw new DomainException(ErrorCodes.TrackingRequired, $"Item {item.Sku} is batch-tracked; choose the batch being returned.", 422); + } + + if (batchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == batchId && b.ItemId == item.ItemId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Batch {batchId} does not belong to item {item.Sku}.", 422); + + // Restock at what the goods were issued at, so the COGS reversal mirrors the sale. + var costed = allocations.Where(a => serials.Count > 0 + ? a.SerialId != null && serialIds.Contains(a.SerialId.Value) + : a.BatchId == batchId) + .ToList(); + decimal? unitCost = costed.Sum(a => a.Qty) > 0 + ? Math.Round(costed.Sum(a => a.Qty * a.UnitCost) / costed.Sum(a => a.Qty), 6, MidpointRounding.AwayFromZero) + : null; + + return new ResolvedReturnLine(line, item, batchId, serials, allocations, unitCost); + } + + /// + /// After the stock is back: serials return to stock (or Damaged) at the return warehouse, the + /// issuing allocations record what came back, and the matching warranty is voided — per + /// serial, or reduced by the returned quantity for a non-serial warranty line. + /// + private async Task ApplyReturnEffectsAsync(ResolvedReturnLine r, int warehouseId, bool damaged, DateTime now, CancellationToken ct) + { + foreach (var serial in r.Serials) + { + serial.Status = damaged ? SerialStatus.Damaged : SerialStatus.InStock; + serial.WarehouseId = warehouseId; + serial.BinId = null; + serial.UpdatedAt = now; + } + + if (r.Serials.Count > 0) + { + var ids = r.Serials.Select(s => s.SerialId).ToHashSet(); + foreach (var a in r.Allocations.Where(a => a.SerialId != null && ids.Contains(a.SerialId.Value))) + a.QtyReturned = a.Qty; + + var registrations = await _warranties.Query() + .Where(w => w.SerialId != null && ids.Contains(w.SerialId.Value) && w.Status == WarrantyStatus.Active) + .ToListAsync(ct); + foreach (var w in registrations) + { + w.Status = WarrantyStatus.Voided; + w.UpdatedAt = now; + } + return; + } + + var left = r.Input.Qty; + foreach (var a in r.Allocations.Where(a => a.BatchId == r.BatchId && a.QtyReturned < a.Qty)) + { + if (left <= 0) break; + var take = Math.Min(left, a.Qty - a.QtyReturned); + a.QtyReturned += take; + left -= take; + } + + if (r.Item.Warranty == Warranty.Warranty && r.Input.SalesInvoiceLineId is int invoiceLineId) + { + var registration = await _warranties.Query() + .FirstOrDefaultAsync(w => w.SourceDocType == DocumentTypes.SalesInvoice && w.SourceLineId == invoiceLineId + && w.SerialId == null && w.Status == WarrantyStatus.Active, ct); + if (registration is not null) + { + registration.Qty = Math.Max(0m, registration.Qty - r.Input.Qty); + if (registration.Qty == 0m) registration.Status = WarrantyStatus.Voided; + registration.UpdatedAt = now; + } + } + } + private static SalesReturnDto ToDto(SalesReturn r, IReadOnlyList ledgerRefs) => new( r.ReturnId, r.DocNo, r.CustomerId, r.WarehouseId, r.ReasonCodeId, r.Status, r.CreatedBy, r.CreatedAt, r.GlJournalNo, r.GlPostedAt, r.Lines.OrderBy(l => l.ReturnLineId) - .Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty)).ToList(), + .Select(l => new SalesReturnLineDto(l.ReturnLineId, l.SalesInvoiceLineId, l.ItemId, l.Qty, l.BatchId, + l.Serials.Select(s => new SalesLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty)).ToList())) + .ToList(), ledgerRefs); + + private sealed record ResolvedReturnLine( + CreateSalesReturnLineInput Input, Item Item, int? BatchId, List Serials, + List Allocations, decimal? UnitCost); } diff --git a/Backend/ERPCore/Services/SalesSlipService.cs b/Backend/ERPCore/Services/SalesSlipService.cs index bdafaad..fb8802e 100644 --- a/Backend/ERPCore/Services/SalesSlipService.cs +++ b/Backend/ERPCore/Services/SalesSlipService.cs @@ -79,7 +79,7 @@ public sealed class SalesSlipService : ISalesSlipService public async Task?> GetAsync(int salesSlipId, CancellationToken ct = default) { - var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); return slip is null ? null : new ETagged(_mapping.MapSlip(slip), slip.RowVersion); } @@ -104,7 +104,7 @@ public sealed class SalesSlipService : ISalesSlipService public async Task?> GetFreeIssueAsync(int salesSlipId, CancellationToken ct = default) { - var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct); return slip is null ? null : new ETagged(MapFreeIssue(slip), slip.RowVersion); } @@ -180,7 +180,7 @@ public sealed class SalesSlipService : ISalesSlipService public async Task PostAsync(int salesSlipId, CancellationToken ct = default) { await _posting.PostSlipAsync(salesSlipId, ct); - var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines) + var slip = await _slips.Query().AsNoTracking().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstAsync(x => x.SalesSlipId == salesSlipId, ct); await _notifications.RaiseAsync("sale.posted", "Sale Posted", $"Sales slip {slip.SlipNo} was posted.", nameof(SalesSlip), slip.SalesSlipId, ct); return _mapping.MapSlip(slip); @@ -188,7 +188,7 @@ public sealed class SalesSlipService : ISalesSlipService public async Task CancelAsync(int salesSlipId, CancellationToken ct = default) { - var slip = await _slips.Query().Include(x => x.Lines).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) + var slip = await _slips.Query().Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial).FirstOrDefaultAsync(x => x.SalesSlipId == salesSlipId, ct) ?? throw new NotFoundException($"Sales slip {salesSlipId} was not found."); if (slip.Status != SalesSlipStatus.Draft) throw new ConflictException($"Sales slip {salesSlipId} is {slip.Status} and cannot be cancelled."); @@ -201,10 +201,15 @@ public sealed class SalesSlipService : ISalesSlipService private async Task> BuildLinesAsync(int headerWarehouseId, List requests, CancellationToken ct) { var lines = new List(); + var documentSerials = new HashSet(); foreach (var r in requests) { var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct); await _sales.ValidateSalesLineAsync(headerWarehouseId, r.ItemId, r.WarehouseId, r.Qty, r.FreeQty, r.ParentLineId, ct); + // The legacy single SerialId is a one-element SerialIds. + var serialIds = r.SerialIds is { Count: > 0 } ? r.SerialIds + : r.SerialId is int single ? new List { single } : null; + var serials = await _sales.ValidateLineTrackingAsync(r.ItemId, r.WarehouseId, r.Qty + r.FreeQty, r.BatchId, serialIds, documentSerials, ct); var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, r.AllowManualPriceOverride, ct); var unitPrice = resolved.UnitPrice; var priceSource = resolved.PriceSource; @@ -212,11 +217,6 @@ public sealed class SalesSlipService : ISalesSlipService var calc = _sales.ComputeLine(r.Qty, r.FreeQty, unitPrice, r.DiscountMode, r.DiscountPct, r.DiscountValue, r.DiscountAmount, taxPct, r.IsFreeIssue); - if (r.BatchId is not null && !await _batches.Query().AnyAsync(b => b.BatchId == r.BatchId && b.ItemId == r.ItemId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Batch {r.BatchId} does not belong to item {r.ItemId}.", 422); - if (r.SerialId is not null && !await _serials.Query().AnyAsync(s => s.SerialId == r.SerialId && s.ItemId == r.ItemId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Serial {r.SerialId} does not belong to item {r.ItemId}.", 422); - lines.Add(new SalesSlipLine { ItemId = r.ItemId, @@ -225,7 +225,8 @@ public sealed class SalesSlipService : ISalesSlipService FreeQty = r.FreeQty, WarehouseId = r.WarehouseId, BatchId = r.BatchId, - SerialId = r.SerialId, + SerialId = serials.Count == 1 ? serials[0].SerialId : null, + Serials = serials.Select(s => new SalesSlipLineSerial { SerialId = s.SerialId, Serial = s }).ToList(), UnitPrice = unitPrice, BaseCost = unitPrice, PriceSource = priceSource, @@ -319,7 +320,7 @@ public sealed class SalesSlipService : ISalesSlipService x.CreatedAt, x.UpdatedAt, summary, - x.Lines.Select(l => new SalesSlipLineDto(l.SalesSlipLineId, l.ItemId, l.Description, l.Qty, l.FreeQty, l.WarehouseId, l.UnitPrice, l.BaseCost, l.PriceSource, l.DiscountPct, l.DiscountAmount, l.DiscountMode, l.NetUnitPrice, l.LineTotal, l.TaxPct, l.TaxAmount, l.IsFreeIssue, l.ParentLineId, l.BatchId, l.SerialId)).ToList()); + _mapping.MapSlip(x).Lines); } private SalesSlipDto Map(SalesSlip x) => _mapping.MapSlip(x); diff --git a/Backend/ERPCore/Services/Stock/FifoCostingService.cs b/Backend/ERPCore/Services/Stock/FifoCostingService.cs index d82f2a0..eeeb91e 100644 --- a/Backend/ERPCore/Services/Stock/FifoCostingService.cs +++ b/Backend/ERPCore/Services/Stock/FifoCostingService.cs @@ -63,11 +63,28 @@ public sealed class FifoCostingService : IFifoCostingService public async Task> ConsumeAsync( int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default, - bool allowShortfall = false) + bool allowShortfall = false, IReadOnlyCollection? serialIds = null) { var now = DateTime.UtcNow; var today = DateOnly.FromDateTime(now); + if (serialIds is not null) + { + if (serialIds.Distinct().Count() != serialIds.Count || serialIds.Count != qtyBase) + throw new DomainException(ErrorCodes.Validation, + $"Issuing {qtyBase} of item {itemId} requires exactly {qtyBase} distinct serial(s), got {serialIds.Count}.", 422); + allowShortfall = false; // a serial cannot be sold before it exists + } + + // Serial layers are held back from an issue that names no serials — but only while the + // item is serial-tracked and Product Configuration has serial numbers switched on. With + // the switch off (or the item moved off Serial), they are ordinary stock: issued + // oldest-first, and their serial records closed out below. + var holdSerialLayers = serialIds is null + && await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId && i.TrackingMode == TrackingMode.Serial, ct) + && (await _db.ProductConfig.AsNoTracking().Where(c => c.ConfigId == ProductConfig.SingletonId) + .Select(c => (bool?)c.SerialNumbersEnabled).FirstOrDefaultAsync(ct) ?? true); + if (batchId is not null) { var expiry = await _db.Batches.AsNoTracking() @@ -107,6 +124,18 @@ public sealed class FifoCostingService : IFifoCostingService """) .ToListAsync(ct); + // A serial item holds one layer per unit, so don't lock them all: keep only the named + // serials' layers (or, with none named, the untracked ones) plus shortfall layers. + if (candidateIds.Count > 0 && (serialIds is not null || holdSerialLayers)) + { + var ids = candidateIds; + candidateIds = await _db.StockLayers.AsNoTracking() + .Where(l => ids.Contains(l.LayerId) && (l.QtyRemaining < 0 || (serialIds == null + ? l.SerialId == null + : l.SerialId != null && serialIds.Contains(l.SerialId.Value)))) + .Select(l => l.LayerId).ToListAsync(ct); + } + // `IN ()` is a syntax error, so short-circuit to the normal insufficient-stock path. List locked; if (candidateIds.Count == 0) @@ -167,11 +196,13 @@ public sealed class FifoCostingService : IFifoCostingService // Stock sold before it arrived is owed first: net shortfall layers off against issuable // stock (oldest-first) before anything new is issued, so it can't be sold twice. The // sale already posted its ledger Out, so settling writes no ledger entry of its own. + // Serial layers are excluded from settlement: netting a shortfall against one would empty + // a serial's layer while the serial itself still reads InStock. var owed = locked.Where(l => l.QtyRemaining < 0).ToList(); var open = locked.Where(l => l.QtyRemaining > 0).ToList(); foreach (var shortfall in owed) { - foreach (var layer in open) + foreach (var layer in open.Where(l => l.SerialId == null)) { if (shortfall.QtyRemaining >= 0) break; var settle = Math.Min(-shortfall.QtyRemaining, layer.QtyRemaining); @@ -182,7 +213,38 @@ public sealed class FifoCostingService : IFifoCostingService // Only now apply the caller's batch filter: settlement above is deliberately // batch-agnostic (a shortfall layer has no batch), but the issue itself is not. - var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId)).ToList(); + // Serial filter: named serials only, or — with none named — no serial layer while they + // are held back (see holdSerialLayers). + var layers = open.Where(l => l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId) + && (serialIds == null + ? !holdSerialLayers || l.SerialId == null + : l.SerialId != null && serialIds.Contains(l.SerialId.Value))) + .ToList(); + + if (serialIds is not null) + { + var found = layers.Select(l => l.SerialId!.Value).ToHashSet(); + var missing = serialIds.Where(id => !found.Contains(id)).ToList(); + if (missing.Count > 0) + { + var numbers = await _db.Serials.AsNoTracking().Where(s => missing.Contains(s.SerialId)) + .Select(s => s.SerialNo).ToListAsync(ct); + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial(s) {string.Join(", ", numbers.Count > 0 ? numbers : missing.Select(m => m.ToString()))} are not available to issue from warehouse {warehouseId} (not in stock here, on hold, or expired).", 409); + } + } + + // FEFO then FIFO: soonest-expiring batch first; unbatched/no-expiry stock after. + var layerBatchIds = layers.Where(l => l.BatchId != null).Select(l => l.BatchId!.Value).Distinct().ToList(); + if (layerBatchIds.Count > 0) + { + var expiries = await _db.Batches.AsNoTracking().Where(b => layerBatchIds.Contains(b.BatchId)) + .ToDictionaryAsync(b => b.BatchId, b => b.ExpiryDate, ct); + layers = layers + .OrderBy(l => l.BatchId is int b && expiries.TryGetValue(b, out var e) && e is not null ? e.Value : DateOnly.MaxValue) + .ThenBy(l => l.ReceiptDate).ThenBy(l => l.LayerId) + .ToList(); + } var available = layers.Sum(l => l.QtyRemaining); if (available < qtyBase && !allowShortfall) @@ -190,6 +252,7 @@ public sealed class FifoCostingService : IFifoCostingService var heldExists = await _db.StockLayers.AsNoTracking().AnyAsync(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && (batchId == null || l.BatchId == batchId) + && (serialIds == null ? l.SerialId == null : serialIds.Contains(l.SerialId ?? 0)) && l.GrnLine != null && (l.GrnLine.HoldStatus == HoldStatus.OnHold || l.GrnLine.HoldStatus == HoldStatus.Rejected), ct); if (heldExists) throw new DomainException(ErrorCodes.OnHoldNotIssuable, @@ -207,7 +270,27 @@ public sealed class FifoCostingService : IFifoCostingService var take = Math.Min(remaining, layer.QtyRemaining); layer.QtyRemaining -= take; remaining -= take; - segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost)); + segments.Add(new ConsumedSegment(layer.LayerId, take, layer.UnitCost, layer.BatchId, layer.SerialId)); + } + + // Serial units issued without being named (serial numbers switched off): close their + // records so the register doesn't keep showing them in stock. + if (serialIds is null) + { + var issuedSerialIds = layers.Where(l => l.SerialId != null && l.QtyRemaining == 0 + && segments.Any(s => s.LayerId == l.LayerId)) + .Select(l => l.SerialId!.Value).ToList(); + if (issuedSerialIds.Count > 0) + { + var issued = await _db.Serials.Where(s => issuedSerialIds.Contains(s.SerialId)).ToListAsync(ct); + foreach (var serial in issued) + { + serial.Status = SerialStatus.IssuedUntracked; + serial.WarehouseId = null; + serial.BinId = null; + serial.UpdatedAt = now; + } + } } // Over-stock sale: whatever stock couldn't cover becomes a negative layer (on-hand goes @@ -232,6 +315,32 @@ public sealed class FifoCostingService : IFifoCostingService return segments; } + public async Task> SuggestBatchesAsync( + int itemId, int warehouseId, decimal qtyBase, CancellationToken ct = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var open = await _db.StockLayers.AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && l.SerialId == null + && (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available) + && (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today)) + .Select(l => new { l.BatchId, BatchNo = l.Batch != null ? l.Batch.BatchNo : null, Expiry = l.Batch != null ? l.Batch.ExpiryDate : null, l.ReceiptDate, l.LayerId, l.QtyRemaining }) + .ToListAsync(ct); + + var result = new List(); + var remaining = qtyBase; + foreach (var group in open + .GroupBy(l => new { l.BatchId, l.BatchNo, l.Expiry }) + .OrderBy(g => g.Key.Expiry ?? DateOnly.MaxValue) + .ThenBy(g => g.Min(l => l.ReceiptDate))) + { + if (remaining <= 0) break; + var take = Math.Min(remaining, group.Sum(l => l.QtyRemaining)); + result.Add(new BatchAllocation(group.Key.BatchId, group.Key.BatchNo, group.Key.Expiry, take)); + remaining -= take; + } + return result; + } + // Most recent layer's cost at this warehouse, else anywhere, else 0 (never-stocked item). private async Task LastKnownUnitCostAsync(int itemId, int warehouseId, CancellationToken ct) { diff --git a/Backend/ERPCore/Services/Stock/ItemTrackingService.cs b/Backend/ERPCore/Services/Stock/ItemTrackingService.cs new file mode 100644 index 0000000..53f7373 --- /dev/null +++ b/Backend/ERPCore/Services/Stock/ItemTrackingService.cs @@ -0,0 +1,263 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Stock; +using ERPCore.Infra.Persistence; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Stock; + +/// +public sealed class ItemTrackingService : IItemTrackingService +{ + private readonly ErpDbContext _db; + private readonly IFifoCostingService _fifo; + + public ItemTrackingService(ErpDbContext db, IFifoCostingService fifo) + { + _db = db; + _fifo = fifo; + } + + private static DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow); + + public const int MaxGeneratedSerials = 1000; + + public async Task> NextSerialNumbersAsync(int itemId, string prefix, int count, CancellationToken ct = default) + { + prefix = prefix.Trim(); + if (count < 1 || count > MaxGeneratedSerials) + throw new DomainException(ErrorCodes.Validation, $"Generate between 1 and {MaxGeneratedSerials} serial numbers at a time.", 422); + if (prefix.Length > 80) + throw new DomainException(ErrorCodes.Validation, "The serial prefix cannot be longer than 80 characters.", 422); + if (!await _db.Items.AsNoTracking().AnyAsync(i => i.ItemId == itemId, ct)) + throw new NotFoundException($"Item {itemId} was not found."); + + // Everything already taken under this prefix: received serials of the item, plus numbers + // sitting on draft GRN lines (they become serials when the GRN is confirmed). + var used = await _db.Serials.AsNoTracking() + .Where(s => s.ItemId == itemId && s.SerialNo.StartsWith(prefix)) + .Select(s => s.SerialNo) + .Concat(_db.GrnLineSerials.AsNoTracking() + .Where(s => s.GrnLine!.ItemId == itemId && s.SerialId == null && s.SerialNo.StartsWith(prefix)) + .Select(s => s.SerialNo)) + .ToListAsync(ct); + + // Highest numeric tail after the prefix ("PH-0041" → 41); width follows the widest seen. + long max = 0; + var width = 4; + foreach (var number in used) + { + var tail = number[prefix.Length..]; + if (tail.Length == 0 || tail.Length > 18 || !tail.All(char.IsAsciiDigit)) continue; + max = Math.Max(max, long.Parse(tail)); + width = Math.Max(width, tail.Length); + } + + var taken = used.ToHashSet(StringComparer.OrdinalIgnoreCase); + var result = new List(count); + for (var n = max + 1; result.Count < count; n++) + { + var candidate = prefix + n.ToString().PadLeft(width, '0'); + if (candidate.Length > 100) + throw new DomainException(ErrorCodes.Validation, "Generated serial numbers would exceed 100 characters — use a shorter prefix.", 422); + if (taken.Add(candidate)) result.Add(candidate); + } + return result; + } + + public async Task> GetAvailableSerialsAsync( + int itemId, int warehouseId, string? q, int take, CancellationToken ct = default) + { + var today = Today; + // Issuable = InStock here AND its own layer is open, not on hold, not in an expired batch — + // the same test posting applies, so the picker never offers a serial posting would refuse. + var query = _db.StockLayers.AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.SerialId != null && l.QtyRemaining > 0 + && l.Serial!.Status == SerialStatus.InStock && l.Serial.WarehouseId == warehouseId + && (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available) + && (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today)); + if (!string.IsNullOrWhiteSpace(q)) + { + var term = q.Trim(); + query = query.Where(l => EF.Functions.Like(l.Serial!.SerialNo, $"%{term}%") || l.Serial.Barcode == term); + } + + return await query + .OrderBy(l => l.Serial!.SerialNo) + .Take(Math.Clamp(take, 1, 500)) + .Select(l => new AvailableSerialDto(l.SerialId!.Value, l.Serial!.SerialNo, l.BatchId, + l.Batch != null ? l.Batch.BatchNo : null, l.Serial.Status)) + .ToListAsync(ct); + } + + public async Task> GetAvailableBatchesAsync(int itemId, int warehouseId, CancellationToken ct = default) + { + var today = Today; + var rows = await _db.StockLayers.AsNoTracking() + .Where(l => l.ItemId == itemId && l.WarehouseId == warehouseId && l.QtyRemaining > 0 && l.SerialId == null + && (l.GrnLine == null || l.GrnLine.HoldStatus == HoldStatus.Available) + && (l.Batch == null || l.Batch.ExpiryDate == null || l.Batch.ExpiryDate >= today)) + .GroupBy(l => new { l.BatchId, BatchNo = l.Batch != null ? l.Batch.BatchNo : null, Expiry = l.Batch != null ? l.Batch.ExpiryDate : null }) + .Select(g => new { g.Key.BatchId, g.Key.BatchNo, g.Key.Expiry, Qty = g.Sum(l => l.QtyRemaining) }) + .ToListAsync(ct); + + return rows + .OrderBy(r => r.Expiry ?? DateOnly.MaxValue).ThenBy(r => r.BatchNo) + .Select(r => new AvailableBatchDto(r.BatchId, r.BatchNo, r.Expiry, r.Qty, + r.Expiry is DateOnly e ? e.DayNumber - today.DayNumber : null)) + .ToList(); + } + + public async Task> SuggestBatchesAsync(int itemId, int warehouseId, decimal qty, CancellationToken ct = default) + => (await _fifo.SuggestBatchesAsync(itemId, warehouseId, qty, ct)) + .Select(a => new BatchSuggestionDto(a.BatchId, a.BatchNo, a.ExpiryDate, a.Qty)).ToList(); + + public async Task> ListSerialsAsync( + PageQuery query, int? itemId, int? warehouseId, SerialStatus? status, CancellationToken ct = default) + { + var q = _db.Serials.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(s => EF.Functions.Like(s.SerialNo, $"%{term}%") || s.Barcode == term + || EF.Functions.Like(s.Item!.Sku, $"%{term}%") || EF.Functions.Like(s.Item.Name, $"%{term}%")); + } + if (itemId is not null) q = q.Where(s => s.ItemId == itemId); + if (warehouseId is not null) q = q.Where(s => s.WarehouseId == warehouseId); + if (status is not null) q = q.Where(s => s.Status == status); + + var total = await q.CountAsync(ct); + var rows = await ProjectSerials(q.OrderByDescending(s => s.SerialId).Skip(query.Skip).Take(query.PageSize)).ToListAsync(ct); + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetSerialAsync(int serialId, CancellationToken ct = default) + { + var serial = await ProjectSerials(_db.Serials.AsNoTracking().Where(s => s.SerialId == serialId)).FirstOrDefaultAsync(ct); + return serial is null ? null : await BuildDetailAsync(serial, ct); + } + + public async Task> FindSerialAsync(string serialNo, CancellationToken ct = default) + { + var term = serialNo.Trim(); + var serials = await ProjectSerials(_db.Serials.AsNoTracking().Where(s => s.SerialNo == term || s.Barcode == term)) + .Take(20).ToListAsync(ct); + var result = new List(); + foreach (var s in serials) result.Add(await BuildDetailAsync(s, ct)); + return result; + } + + public async Task> ListWarrantiesAsync( + PageQuery query, int? customerId, int? itemId, string? serialNo, WarrantyStatus? status, + int? expiringWithinDays, bool? expired, CancellationToken ct = default) + { + var today = Today; + var q = _db.WarrantyRegistrations.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(w => (w.Serial != null && EF.Functions.Like(w.Serial.SerialNo, $"%{term}%")) + || EF.Functions.Like(w.Item!.Sku, $"%{term}%") || EF.Functions.Like(w.Item.Name, $"%{term}%")); + } + if (customerId is not null) q = q.Where(w => w.CustomerId == customerId); + if (itemId is not null) q = q.Where(w => w.ItemId == itemId); + if (!string.IsNullOrWhiteSpace(serialNo)) + { + var term = serialNo.Trim(); + q = q.Where(w => w.Serial != null && (w.Serial.SerialNo == term || w.Serial.Barcode == term)); + } + if (status is not null) q = q.Where(w => w.Status == status); + if (expiringWithinDays is int days) + { + var until = today.AddDays(days); + q = q.Where(w => w.Status == WarrantyStatus.Active && w.EndDate >= today && w.EndDate <= until); + } + if (expired == true) q = q.Where(w => w.EndDate < today); + if (expired == false) q = q.Where(w => w.EndDate >= today); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(w => w.EndDate).ThenBy(w => w.WarrantyRegistrationId) + .Skip(query.Skip).Take(query.PageSize) + .Select(WarrantyProjection()) + .ToListAsync(ct); + return PagedResponse.Create(rows.Select(r => WithDays(r, today)).ToList(), query.Page, query.PageSize, total); + } + + public async Task> ListBatchStockAsync( + PageQuery query, int? itemId, int? warehouseId, int? expiringWithinDays, CancellationToken ct = default) + { + var today = Today; + var layers = _db.StockLayers.AsNoTracking().Where(l => l.QtyRemaining > 0 && l.BatchId != null); + if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId); + if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId); + if (expiringWithinDays is int days) + { + var until = today.AddDays(days); + layers = layers.Where(l => l.Batch!.ExpiryDate != null && l.Batch.ExpiryDate <= until); + } + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + layers = layers.Where(l => EF.Functions.Like(l.Batch!.BatchNo, $"%{term}%") + || EF.Functions.Like(l.Item!.Sku, $"%{term}%") || EF.Functions.Like(l.Item.Name, $"%{term}%")); + } + + var grouped = layers.GroupBy(l => new + { + l.ItemId, Sku = l.Item!.Sku, ItemName = l.Item.Name, + l.WarehouseId, WarehouseName = l.Warehouse != null ? l.Warehouse.Name : null, + l.BatchId, BatchNo = l.Batch!.BatchNo, Expiry = l.Batch.ExpiryDate + }).Select(g => new { g.Key, Qty = g.Sum(l => l.QtyRemaining) }); + + var total = await grouped.CountAsync(ct); + var rows = await grouped + .OrderBy(g => g.Key.Expiry == null).ThenBy(g => g.Key.Expiry).ThenBy(g => g.Key.Sku) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + return PagedResponse.Create(rows.Select(r => new BatchStockDto( + r.Key.ItemId, r.Key.Sku, r.Key.ItemName, r.Key.WarehouseId, r.Key.WarehouseName ?? $"Warehouse {r.Key.WarehouseId}", + r.Key.BatchId, r.Key.BatchNo, r.Key.Expiry, r.Qty, + r.Key.Expiry is DateOnly e ? e.DayNumber - today.DayNumber : null, + r.Key.Expiry is DateOnly x && x < today)).ToList(), query.Page, query.PageSize, total); + } + + private static IQueryable ProjectSerials(IQueryable q) => q.Select(s => new SerialSummaryDto( + s.SerialId, s.ItemId, s.Item!.Sku, s.Item.Name, s.SerialNo, s.Status, + s.WarehouseId, s.Warehouse != null ? s.Warehouse.Name : null, s.BinId, s.ReceivedAt, + s.GrnLine != null ? s.GrnLine.GrnId : null, s.GrnLine != null && s.GrnLine.Grn != null ? s.GrnLine.Grn.DocNo : null, + s.WarrantyPeriodMonths)); + + private async Task BuildDetailAsync(SerialSummaryDto serial, CancellationToken ct) + { + var today = Today; + var history = await _db.SerialMovements.AsNoTracking() + .Where(m => m.SerialId == serial.SerialId) + .OrderBy(m => m.SerialMovementId) + .Select(m => new SerialMovementDto(m.SerialMovementId, m.SourceDocType, m.SourceDocId, m.Status, + m.FromWarehouseId, m.WarehouseId, m.UserId, m.CreatedAt)) + .ToListAsync(ct); + var warranties = await _db.WarrantyRegistrations.AsNoTracking() + .Where(w => w.SerialId == serial.SerialId) + .OrderByDescending(w => w.WarrantyRegistrationId) + .Select(WarrantyProjection()) + .ToListAsync(ct); + return new SerialDetailDto(serial, history, warranties.Select(w => WithDays(w, today)).ToList()); + } + + private static global::System.Linq.Expressions.Expression> WarrantyProjection() + => w => new WarrantyRegistrationDto( + w.WarrantyRegistrationId, w.ItemId, w.Item!.Sku, w.Item.Name, w.SerialId, w.Serial != null ? w.Serial.SerialNo : null, + w.CustomerId, w.Customer != null ? (w.Customer.DisplayName ?? w.Customer.Name) : string.Empty, + w.SourceDocType, w.SourceDocId, w.SourceLineId, w.Qty, w.StartDate, w.PeriodMonths, w.EndDate, w.Status, + 0, false); + + private static WarrantyRegistrationDto WithDays(WarrantyRegistrationDto w, DateOnly today) => w with + { + DaysRemaining = Math.Max(0, w.EndDate.DayNumber - today.DayNumber), + IsExpired = w.EndDate < today + }; +} diff --git a/Backend/ERPCore/Services/Stock/SerialTrackingService.cs b/Backend/ERPCore/Services/Stock/SerialTrackingService.cs new file mode 100644 index 0000000..aad87d9 --- /dev/null +++ b/Backend/ERPCore/Services/Stock/SerialTrackingService.cs @@ -0,0 +1,156 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Infra.Auth; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Stock; + +/// +public sealed class SerialTrackingService : ISerialTrackingService +{ + private readonly IRepository _serials; + private readonly IRepository _movements; + private readonly IRepository _layers; + private readonly IRepository _config; + private readonly ICurrentUser _currentUser; + private bool? _enabled; // per request (scoped service) — one config read + + public SerialTrackingService( + IRepository serials, IRepository movements, + IRepository layers, IRepository config, ICurrentUser currentUser) + { + _serials = serials; + _movements = movements; + _layers = layers; + _config = config; + _currentUser = currentUser; + } + + public async Task IsEnabledAsync(CancellationToken ct = default) + => _enabled ??= await _config.Query().AsNoTracking() + .Where(c => c.ConfigId == ProductConfig.SingletonId) + .Select(c => (bool?)c.SerialNumbersEnabled) + .FirstOrDefaultAsync(ct) ?? true; + + public async Task IsSerialTrackedAsync(Item item, CancellationToken ct = default) + => item.TrackingMode == TrackingMode.Serial && await IsEnabledAsync(ct); + + public void RejectSerialsWhileDisabled(Item item, int serialCount) + { + if (item.TrackingMode == TrackingMode.Serial && serialCount > 0) + throw new DomainException(ErrorCodes.ConfigDisabled, + $"Serial numbers are switched off in Product Configuration; item {item.Sku} is handled without serials.", 422); + } + + public async Task> ResolveOutboundAsync( + Item item, int warehouseId, decimal qty, IReadOnlyCollection? serialIds, + ISet documentSerials, IReadOnlyCollection allowedStatuses, CancellationToken ct = default) + { + var ids = serialIds?.ToList() ?? new List(); + if (!await IsSerialTrackedAsync(item, ct)) + { + RejectSerialsWhileDisabled(item, ids.Count); + if (ids.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serials are not accepted.", 422); + return Array.Empty(); + } + + if (ids.Count == 0) + { + var untracked = await _layers.Query().AsNoTracking() + .Where(l => l.ItemId == item.ItemId && l.WarehouseId == warehouseId && l.SerialId == null && l.QtyRemaining > 0) + .SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + if (untracked < qty) + throw new DomainException(ErrorCodes.TrackingRequired, $"Item {item.Sku} is serial-tracked; select {qty} serial number(s).", 422); + return Array.Empty(); + } + + if (ids.Count != qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku}: {qty} unit(s) need exactly that many serials, got {ids.Count}.", 422); + foreach (var id in ids) + if (!documentSerials.Add(id)) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial {id} is used more than once on this document.", 422); + + var serials = await _serials.Query().Where(s => ids.Contains(s.SerialId)).ToListAsync(ct); + foreach (var id in ids) + { + var serial = serials.FirstOrDefault(s => s.SerialId == id); + if (serial is null || serial.ItemId != item.ItemId) + throw new DomainException(ErrorCodes.Validation, $"Serial {id} does not belong to item {item.Sku}.", 422); + if (serial.WarehouseId != warehouseId || !allowedStatuses.Contains(serial.Status)) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial {serial.SerialNo} is {serial.Status}{(serial.WarehouseId is int w && w != warehouseId ? $" at warehouse {w}" : string.Empty)} and cannot leave warehouse {warehouseId}.", 422); + } + return serials; + } + + public async Task> ResolveInboundAsync( + Item item, int warehouseId, decimal qty, IReadOnlyCollection? serialNos, + ISet documentSerialNos, CancellationToken ct = default) + { + var numbers = (serialNos ?? Array.Empty()).Select(n => n.Trim()).Where(n => n.Length > 0).ToList(); + if (!await IsSerialTrackedAsync(item, ct)) + { + RejectSerialsWhileDisabled(item, numbers.Count); + if (numbers.Count > 0) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is not serial-tracked; serial numbers are not accepted.", 422); + return Array.Empty(); + } + + if (qty != decimal.Truncate(qty)) + throw new DomainException(ErrorCodes.Validation, $"Item {item.Sku} is serial-tracked; the quantity must be a whole number.", 422); + if (numbers.Count != qty) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {item.Sku} is serial-tracked; enter exactly {qty} serial number(s), got {numbers.Count}.", 422); + if (numbers.Any(n => n.Length > 100)) + throw new DomainException(ErrorCodes.Validation, "A serial number cannot be longer than 100 characters.", 422); + foreach (var n in numbers) + if (!documentSerialNos.Add($"{item.ItemId}:{n.ToUpperInvariant()}")) + throw new DomainException(ErrorCodes.SerialDuplicate, $"Serial number {n} is entered more than once.", 422); + + var existing = await _serials.Query().Where(s => s.ItemId == item.ItemId && numbers.Contains(s.SerialNo)).ToListAsync(ct); + var inStock = existing.Where(s => s.WarehouseId != null).Select(s => s.SerialNo).ToList(); + if (inStock.Count > 0) + throw new DomainException(ErrorCodes.SerialDuplicate, + $"Serial number(s) {string.Join(", ", inStock)} of item {item.Sku} are already in stock.", 409); + + var now = DateTime.UtcNow; + var result = new List(existing); + foreach (var n in numbers.Where(n => !existing.Any(e => string.Equals(e.SerialNo, n, StringComparison.OrdinalIgnoreCase)))) + { + var serial = new Serial + { + ItemId = item.ItemId, + SerialNo = n, + // Location is set by MoveAsync once the stock actually lands. Callers must save + // (flush) before building StockDeltas, which need the generated SerialId. + Status = SerialStatus.InStock, + ReceivedAt = now, + CreatedAt = now, + WarrantyPeriodMonths = item.Warranty == Warranty.Warranty ? item.WarrantyPeriodMonths : null + }; + await _serials.AddAsync(serial, ct); + result.Add(serial); + } + return result; + } + + public async Task MoveAsync( + IEnumerable serials, SerialStatus status, int? warehouseId, int? binId, + string sourceDocType, int sourceDocId, DateTime now, CancellationToken ct = default) + { + foreach (var serial in serials) + { + var from = serial.WarehouseId; + serial.Status = status; + serial.WarehouseId = warehouseId; + serial.BinId = binId; + serial.UpdatedAt = now; + await _movements.AddAsync(SerialMovement.Of(serial, from, sourceDocType, sourceDocId, _currentUser.AuditUserId, now), ct); + } + } +} diff --git a/Backend/ERPCore/Services/Stock/StockMutator.cs b/Backend/ERPCore/Services/Stock/StockMutator.cs index 7a1b9ae..4f4fae5 100644 --- a/Backend/ERPCore/Services/Stock/StockMutator.cs +++ b/Backend/ERPCore/Services/Stock/StockMutator.cs @@ -3,6 +3,7 @@ using ERPCore.Domain.Enums; using ERPCore.Infra.Auth; using ERPCore.Repositories.Interfaces; using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; using Microsoft.EntityFrameworkCore; namespace ERPCore.Services.Stock; @@ -41,24 +42,39 @@ public sealed class StockMutator : IStockMutator if (!balances.TryGetValue(d.ItemId, out var bal)) bal = await _fifo.GetOnHandAsync(d.ItemId, warehouseId, ct); + var serialIds = d.SerialIds is { Count: > 0 } ? d.SerialIds : null; + if (serialIds is not null && serialIds.Count != Math.Abs(d.QtyDelta)) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Item {d.ItemId}: {Math.Abs(d.QtyDelta)} unit(s) need exactly that many serials, got {serialIds.Count}.", 422); + // The ledger carries a serial only when the movement is exactly one unit. + int? ledgerSerialId = serialIds is { Count: 1 } ? serialIds[0] : null; + StockLedger entry; if (d.QtyDelta < 0) { var qty = -d.QtyDelta; - var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct); + var segments = await _fifo.ConsumeAsync(d.ItemId, warehouseId, d.BatchId, qty, ct, serialIds: serialIds); var unitCost = segments.Sum(s => s.Qty * s.UnitCost) / qty; bal -= qty; entry = await _fifo.PostLedgerAsync( - d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor, + d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor, Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct); } else { - var unitCost = await LastCostAsync(d.ItemId, warehouseId, ct); - await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct); + var unitCost = d.UnitCost ?? await LastCostAsync(d.ItemId, warehouseId, ct); + if (serialIds is not null) + { + foreach (var serialId in serialIds) + await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, serialId, null, 1m, unitCost, now, ct); + } + else + { + await _fifo.CreateInboundLayerAsync(d.ItemId, warehouseId, d.BatchId, null, null, d.QtyDelta, unitCost, now, ct); + } bal += d.QtyDelta; entry = await _fifo.PostLedgerAsync( - d.ItemId, warehouseId, d.BinId, d.BatchId, null, actor, + d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor, Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct); } diff --git a/Backend/ERPCore/Services/TransferService.cs b/Backend/ERPCore/Services/TransferService.cs index a4036c8..b3424d9 100644 --- a/Backend/ERPCore/Services/TransferService.cs +++ b/Backend/ERPCore/Services/TransferService.cs @@ -37,12 +37,15 @@ public sealed class TransferService : ITransferService private readonly IBranchConfigService _branchConfig; private readonly INotificationService _notifications; private readonly IUnitOfWork _uow; + private readonly ISerialTrackingService _serialTracking; public TransferService( IRepository transfers, IRepository warehouses, IRepository items, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, - IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow) + IBranchConfigService branchConfig, INotificationService notifications, IUnitOfWork uow, + ISerialTrackingService serialTracking) { + _serialTracking = serialTracking; _transfers = transfers; _warehouses = warehouses; _items = items; @@ -83,7 +86,8 @@ public sealed class TransferService : ITransferService public async Task GetAsync(int transferId, CancellationToken ct = default) { - var t = await _transfers.Query().AsNoTracking().Include(x => x.Lines) + var t = await _transfers.Query().AsNoTracking() + .Include(x => x.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(x => x.TransferId == transferId, ct); return t is null ? null : Map(t); } @@ -95,9 +99,42 @@ public sealed class TransferService : ITransferService await EnsureWarehouseAsync(request.SrcWarehouseId, ct); await EnsureWarehouseAsync(request.DestWarehouseId, ct); var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); + + // Resolve tracking up front: serial lines carry their serials; a batch-tracked line with + // no batch is split FEFO into one line per batch, so batch identity survives the move. + var lines = new List(); + var documentSerials = new HashSet(); foreach (var line in request.Lines) - if (!await _items.Query().AnyAsync(i => i.ItemId == line.ItemId, ct)) - throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + { + var item = await _items.Query().AsNoTracking().FirstOrDefaultAsync(i => i.ItemId == line.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422); + var serials = await _serialTracking.ResolveOutboundAsync( + item, request.SrcWarehouseId, line.Qty, line.SerialIds, documentSerials, new[] { SerialStatus.InStock }, ct); + + StockTransferLine NewLine(int? batchId, decimal qty) => new() + { + ItemId = line.ItemId, + SrcBinId = line.SrcBinId, + DestBinId = line.DestBinId, + BatchId = batchId, + Qty = qty, + SerialId = serials.Count == 1 ? serials[0].SerialId : null, + Serials = serials.Select(s => new StockTransferLineSerial { SerialId = s.SerialId }).ToList() + }; + + if (item.TrackingMode == TrackingMode.Batch && line.BatchId is null) + { + var picks = await _fifo.SuggestBatchesAsync(line.ItemId, request.SrcWarehouseId, line.Qty, ct); + var picked = picks.Sum(p => p.Qty); + foreach (var pick in picks) lines.Add(NewLine(pick.BatchId, pick.Qty)); + // Whatever FEFO could not place stays unbatched — dispatch then reports the shortfall. + if (picked < line.Qty) lines.Add(NewLine(null, line.Qty - picked)); + } + else + { + lines.Add(NewLine(line.BatchId, line.Qty)); + } + } var actor = _currentUser.AuditUserId; var transfer = await _uow.ExecuteInTransactionAsync(async token => @@ -112,14 +149,7 @@ public sealed class TransferService : ITransferService Status = TransferStatus.Draft, CreatedBy = actor, CreatedAt = DateTime.UtcNow, - Lines = request.Lines.Select(l => new StockTransferLine - { - ItemId = l.ItemId, - SrcBinId = l.SrcBinId, - DestBinId = l.DestBinId, - BatchId = l.BatchId, - Qty = l.Qty - }).ToList() + Lines = lines }; await _transfers.AddAsync(entity, token); return entity; @@ -132,7 +162,8 @@ public sealed class TransferService : ITransferService public async Task DispatchAsync(int transferId, CancellationToken ct = default) { - var transfer = await _transfers.Query().Include(t => t.Lines) + var transfer = await _transfers.Query() + .Include(t => t.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(t => t.TransferId == transferId, ct) ?? throw new NotFoundException($"Transfer {transferId} was not found."); if (transfer.Status != TransferStatus.Draft) @@ -148,10 +179,28 @@ public sealed class TransferService : ITransferService { foreach (var line in transfer.Lines.OrderBy(l => l.TransferLineId)) { - var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token); + // The draft didn't reserve its serials — re-check they are still here and in stock. + var serials = line.Serials.Select(s => s.Serial!).ToList(); + var gone = serials.Where(s => s.Status != SerialStatus.InStock || s.WarehouseId != transfer.SrcWarehouseId).Select(s => s.SerialNo).ToList(); + if (gone.Count > 0) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Serial(s) {string.Join(", ", gone)} are no longer in stock at the source warehouse.", 409); + + var segments = await _fifo.ConsumeAsync(line.ItemId, transfer.SrcWarehouseId, line.BatchId, line.Qty, token, + serialIds: serials.Count > 0 ? serials.Select(s => s.SerialId).ToList() : null); var value = segments.Sum(s => s.Qty * s.UnitCost); line.UnitCost = value / line.Qty; // value-weighted cost, preserved to receive + // Each serial keeps its own cost and batch for its destination layer. + foreach (var lineSerial in line.Serials) + { + var segment = segments.First(s => s.SerialId == lineSerial.SerialId); + lineSerial.UnitCost = segment.UnitCost; + lineSerial.BatchId = segment.BatchId; + } + await _serialTracking.MoveAsync(serials, SerialStatus.InTransit, null, null, + DocumentTypes.Transfer, transfer.TransferId, now, token); + if (!balances.TryGetValue(line.ItemId, out var bal)) bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.SrcWarehouseId, token); bal -= line.Qty; @@ -174,7 +223,8 @@ public sealed class TransferService : ITransferService public async Task ReceiveAsync(int transferId, ReceiveTransferRequest request, CancellationToken ct = default) { - var transfer = await _transfers.Query().Include(t => t.Lines) + var transfer = await _transfers.Query() + .Include(t => t.Lines).ThenInclude(l => l.Serials).ThenInclude(s => s.Serial) .FirstOrDefaultAsync(t => t.TransferId == transferId, ct) ?? throw new NotFoundException($"Transfer {transferId} was not found."); if (transfer.Status != TransferStatus.InTransit) @@ -199,9 +249,26 @@ public sealed class TransferService : ITransferService $"Receiving {input.Qty} exceeds the outstanding in-transit {outstanding} on line {input.TransferLineId}.", 422); var unitCost = line.UnitCost ?? 0m; - var layer = await _fifo.CreateInboundLayerAsync( - line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token); - createdLayers.Add(layer); + if (line.Serials.Count > 0) + { + var arriving = ResolveArrivingSerials(line, input); + foreach (var lineSerial in arriving) + { + var layer = await _fifo.CreateInboundLayerAsync( + line.ItemId, transfer.DestWarehouseId, lineSerial.BatchId ?? line.BatchId, lineSerial.SerialId, null, + 1m, lineSerial.UnitCost ?? unitCost, now, token); + createdLayers.Add(layer); + lineSerial.ReceivedAt = now; + } + await _serialTracking.MoveAsync(arriving.Select(s => s.Serial!), SerialStatus.InStock, + transfer.DestWarehouseId, line.DestBinId, DocumentTypes.Transfer, transfer.TransferId, now, token); + } + else + { + var layer = await _fifo.CreateInboundLayerAsync( + line.ItemId, transfer.DestWarehouseId, line.BatchId, null, null, input.Qty, unitCost, now, token); + createdLayers.Add(layer); + } if (!balances.TryGetValue(line.ItemId, out var bal)) bal = await _fifo.GetOnHandAsync(line.ItemId, transfer.DestWarehouseId, token); @@ -234,8 +301,34 @@ public sealed class TransferService : ITransferService throw new DomainException(ErrorCodes.Validation, $"Warehouse {warehouseId} does not exist.", 422); } + /// + /// Which in-transit serials a receive line covers: the ones it names (each still outstanding + /// on the line), or — with none named — every outstanding one, which must then be the full qty. + /// + private static List ResolveArrivingSerials(StockTransferLine line, ReceiveTransferLineInput input) + { + var outstanding = line.Serials.Where(s => s.ReceivedAt == null).ToList(); + if (input.SerialIds is not { Count: > 0 }) + { + if (input.Qty != outstanding.Count) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Transfer line {line.TransferLineId} is serial-tracked; name which {input.Qty} of its {outstanding.Count} in-transit serials arrived.", 422); + return outstanding; + } + + if (input.SerialIds.Count != input.Qty || input.SerialIds.Distinct().Count() != input.SerialIds.Count) + throw new DomainException(ErrorCodes.TrackingRequired, + $"Receiving {input.Qty} on transfer line {line.TransferLineId} needs exactly that many distinct serials.", 422); + var arriving = outstanding.Where(s => input.SerialIds.Contains(s.SerialId)).ToList(); + if (arriving.Count != input.SerialIds.Count) + throw new DomainException(ErrorCodes.SerialNotAvailable, + $"Some serials are not in transit on transfer line {line.TransferLineId}.", 422); + return arriving; + } + private static TransferDto Map(StockTransfer t) => new( t.TransferId, t.DocNo, t.SrcWarehouseId, t.DestWarehouseId, t.BranchId, t.Status, t.CreatedBy, t.CreatedAt, t.Lines.OrderBy(l => l.TransferLineId).Select(l => new TransferLineDto( - l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived)).ToList()); + l.TransferLineId, l.ItemId, l.SrcBinId, l.DestBinId, l.BatchId, l.Qty, l.QtyReceived, + l.Serials.Select(s => new TransferLineSerialDto(s.SerialId, s.Serial?.SerialNo ?? string.Empty, s.ReceivedAt != null)).ToList())).ToList()); } diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 2557cec..10b002f 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -30,6 +30,11 @@ public static class ErrorCodes public const string ConfigDisabled = "CONFIG_DISABLED"; public const string RoleInUse = "ROLE_IN_USE"; + // Item tracking (docs/ITEM_TRACKING_PLAN.md). + public const string SerialDuplicate = "SERIAL_DUPLICATE"; + public const string SerialNotAvailable = "SERIAL_NOT_AVAILABLE"; + public const string TrackingRequired = "TRACKING_REQUIRED"; + // Auth proxy (AuthController → AuthHex, docs/11 §2.0) public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR"; public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE"; diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx index 896c151..8de035d 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -18,6 +18,7 @@ import { CreatePurchaseReturnLineInput } from "@/types/procurement" import { Grn, GrnLine } from "@/types/grn" import { ItemListItem } from "@/types/master-data" import { ReasonCode } from "@/types/stock" +import { useTrackingSettings } from "@/hooks/use-tracking-settings" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -39,6 +40,8 @@ const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background interface LineState { selected: boolean qty: string + /** Serial line: the received serials going back to the vendor (qty follows the count). */ + serialIds?: number[] } function NewPurchaseReturnContent() { @@ -62,6 +65,7 @@ function NewPurchaseReturnContent() { const [headerError, setHeaderError] = useState(null) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) + const { serialNumbersEnabled } = useTrackingSettings() useEffect(() => { Promise.all([grnsApi.list({ pageSize: 200 }), itemsApi.list({ pageSize: 200 }), reasonCodesApi.list("Return")]) @@ -121,6 +125,33 @@ function NewPurchaseReturnContent() { setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } })) } + function toggleSerial(grnLineId: number, serialId: number) { + setLineState((prev) => { + const current = prev[grnLineId]?.serialIds ?? [] + const next = current.includes(serialId) ? current.filter((id) => id !== serialId) : [...current, serialId] + return { ...prev, [grnLineId]: { selected: next.length > 0, qty: next.length > 0 ? String(next.length) : "", serialIds: next } } + }) + } + + /** A serial line lists its received serials to tick — the server refuses any no longer in stock. */ + function renderSerials(line: GrnLine) { + // Serial numbers switched off in Product Configuration: the line goes back as plain stock. + if (serialNumbersEnabled === false) return null + const serials = (line.serials ?? []).filter((s) => s.serialId !== null) + if (serials.length === 0) return null + const picked = lineState[line.grnLineId]?.serialIds ?? [] + return ( +
+ {serials.map((s) => ( + + ))} +
+ ) + } + async function handleSubmit() { setHeaderError(null) setSubmitError(null) @@ -143,6 +174,8 @@ function NewPurchaseReturnContent() { const nextErrors: Record> = {} for (const line of selectedLines) { const errors = validateReturnLine({ grnLineId: line.grnLineId, qty: lineState[line.grnLineId].qty, maxQty: line.qty }) + if (serialNumbersEnabled !== false && (line.serials ?? []).some((s) => s.serialId !== null) && (lineState[line.grnLineId].serialIds?.length ?? 0) !== Number(lineState[line.grnLineId].qty)) + errors.qty = "Tick one serial per unit going back" if (Object.keys(errors).length > 0) nextErrors[line.grnLineId] = errors } setLineErrors(nextErrors) @@ -155,6 +188,7 @@ function NewPurchaseReturnContent() { grnLineId: l.grnLineId, itemId: l.itemId, qty: Number(lineState[l.grnLineId].qty), + serialIds: lineState[l.grnLineId].serialIds?.length ? lineState[l.grnLineId].serialIds : null, })) setSubmitting(true) @@ -249,6 +283,7 @@ function NewPurchaseReturnContent() { {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {renderSerials(line)}
@@ -309,7 +344,10 @@ function NewPurchaseReturnContent() { aria-label={`Return ${item?.sku ?? `item #${line.itemId}`}`} /> - {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + + {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {renderSerials(line)} + {line.qty} diff --git a/Frontend/erp-system/app/dashboard/products/settings/page.tsx b/Frontend/erp-system/app/dashboard/products/settings/page.tsx index f1323fc..47290ae 100644 --- a/Frontend/erp-system/app/dashboard/products/settings/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/settings/page.tsx @@ -11,6 +11,7 @@ import { ProductConfig, UpdateProductConfigRequest } from "@/types/master-data" import { Skeleton } from "@/components/ui/skeleton" import { Switch } from "@/components/ui/switch" import { toast } from "@/components/ui/toast" +import { invalidateTrackingSettings } from "@/hooks/use-tracking-settings" /** * Product Configuration (docs/11 §2.8; FR-MD-11) - the singleton feature gate. @@ -61,6 +62,8 @@ export default function ProductSettingsPage() { ) setConfig(res.data) setEtag(res.etag) + // Screens read the serial switch through a cached hook — make the next read fresh. + invalidateTrackingSettings() toast.success("Configuration saved", message) return true } catch (err) { @@ -152,7 +155,7 @@ export default function ProductSettingsPage() { toggle("serialNumbersEnabled", v)} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 3ca58b1..6fc438a 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -13,7 +13,7 @@ import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { ConfirmGrnResponse, Grn } from "@/types/grn" +import { ConfirmGrnResponse, Grn, GrnLine } from "@/types/grn" import { Bin, ItemListItem, Uom } from "@/types/master-data" import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices" @@ -27,6 +27,34 @@ import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badg import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" +/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */ +function LineTrackingInfo({ line }: { line: GrnLine }) { + const serials = line.serials ?? [] + if (!line.batchNo && serials.length === 0) return null + return ( +
+ {line.batchNo && ( +

+ Batch {line.batchNo} + {line.batchExpiryDate && <> · expires {line.batchExpiryDate}} +

+ )} + {serials.length > 0 && ( +
+ + {serials.length} serial number{serials.length === 1 ? "" : "s"} + +
    + {serials.map((s) => ( +
  • {s.serialNo}
  • + ))} +
+
+ )} +
+ ) +} + export default function GrnDetailPage() { const params = useParams<{ id: string }>() const router = useRouter() @@ -235,6 +263,7 @@ export default function GrnDetailPage() { {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured

)} +
@@ -357,6 +386,7 @@ export default function GrnDetailPage() { {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured

)} + {baseUomLabel(items, uoms, line.itemId)} {binFor(line.binId)} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index 21dcb10..724e46c 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2 } from "lucide-react" +import { ArrowLeft, ExternalLink, RefreshCw, ShieldCheck, Trash2, Wand2 } from "lucide-react" import { ScanInput } from "@/components/scanning/ScanInput" import { ApiError } from "@/lib/api-client" @@ -15,6 +15,8 @@ import { branchesHrmApi } from "@/lib/api/hrm-masters" import { itemsApi } from "@/lib/api/items" import { uomsApi } from "@/lib/api/uoms" import { grnConfigApi } from "@/lib/api/grn-config" +import { useTrackingSettings } from "@/hooks/use-tracking-settings" +import { trackingApi } from "@/lib/api/tracking" import { baseUomLabel } from "@/lib/uom-label" import { errorMessage } from "@/lib/error-map" import { validateLine, grnHeaderSchema } from "@/lib/validations/grn" @@ -40,12 +42,25 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip type Mode = "po" | "direct" type LineTab = "lines" | "warranty" -/** Whole units a line's qty represents — one warranty number is captured per unit. */ +/** Whole units a line's qty represents — one warranty/serial number is captured per unit. */ function unitCount(qty: string): number { const n = Math.floor(Number(qty)) return Number.isFinite(n) && n > 0 ? n : 0 } +/** + * Items that need one number per received unit: serial-tracked items (their serial numbers — + * which double as the warranty identity, docs/ITEM_TRACKING_PLAN.md D1) and other warranty items. + */ +function capturesUnitNumbers(item: ItemListItem | null | undefined): boolean { + return item?.trackingMode === "Serial" || item?.warranty === "Warranty" +} + +/** "serial number" for a serial-tracked item, else "warranty number". */ +function unitNumberLabel(item: ItemListItem | null | undefined): string { + return item?.trackingMode === "Serial" ? "serial number" : "warranty number" +} + /** One-shot hint next to the warranty button — shows on mount, then fades out on its own. */ function WarrantyHintBubble() { const [visible, setVisible] = useState(true) @@ -83,10 +98,15 @@ interface DraftLine { discountPct: string vatPct: string holdStatus: HoldStatus - /** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */ + /** + * One entry per received unit, index-aligned; only meaningful when the item captures unit + * numbers — sent as `serialNumbers` for a serial-tracked item, else as `warrantyNumbers`. + */ warrantyNumbers: string[] - /** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo }` on submit. */ + /** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo, expiryDate }` on submit. */ batchNo: string + /** yyyy-mm-dd, optional — drives FEFO picking and expiry blocking. */ + batchExpiry: string } /** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */ @@ -155,10 +175,12 @@ function buildWarrantyRange(from: string, to: string): string[] | null { * with a generated number — existing (manually edited) values are left untouched. */ function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] { - if (item?.warranty !== "Warranty") return l.warrantyNumbers + if (!capturesUnitNumbers(item)) return l.warrantyNumbers const units = unitCount(l.qty) const next = l.warrantyNumbers.slice(0, units) while (next.length < units) next.push("") + // Serial numbers are the manufacturer's — never invent them; leave the slots for scanning. + if (item?.trackingMode === "Serial") return next for (let i = 0; i < units; i++) { if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber() } @@ -179,12 +201,14 @@ function emptyLine(): DraftLine { holdStatus: "Available", warrantyNumbers: [], batchNo: "", + batchExpiry: "", } } export default function NewGrnPage() { const router = useRouter() const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth() + const { serialNumbersEnabled } = useTrackingSettings() const [mode, setMode] = useState("po") @@ -339,6 +363,7 @@ export default function NewGrnPage() { holdStatus: "Available", warrantyNumbers: [], batchNo: "", + batchExpiry: "", } return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) } }) @@ -440,8 +465,16 @@ export default function NewGrnPage() { setLines((prev) => prev.filter((l) => l.key !== key)) } + /** + * The item as this GRN must treat it: with serial numbers switched off in Product + * Configuration a Serial item is received as untracked stock (a warranty item then takes + * warranty numbers again), so every serial rule below follows from this one place. + */ function itemFor(itemId: number | null) { - return items?.find((i) => i.itemId === itemId) ?? null + const item = items?.find((i) => i.itemId === itemId) ?? null + return item && item.trackingMode === "Serial" && serialNumbersEnabled === false + ? { ...item, trackingMode: "None" as const } + : item } const itemOptions = useMemo( @@ -530,11 +563,68 @@ export default function NewGrnPage() { // From/To are left exactly as typed — not cleared — so a typo is easy to fix in place. } - // Lines whose item is warranty-tracked and carry at least one unit — these are the rows - // the "Warranty numbers" tab needs to capture, one input per unit. + // Lines whose item is serial- or warranty-tracked and carry at least one unit — these are + // the rows the "Serial / warranty numbers" tab needs to capture, one input per unit. const warrantyLines = lines .map((l) => ({ line: l, item: itemFor(l.itemId), units: unitCount(l.qty) })) - .filter((w) => w.item?.warranty === "Warranty" && w.units > 0) + .filter((w) => capturesUnitNumbers(w.item) && w.units > 0) + + // Bulk paste for a line's serial/warranty numbers — one per line (or comma-separated), filling + // units from the first; extra pasted values beyond the unit count are reported, not dropped silently. + const [pasteInputs, setPasteInputs] = useState>({}) + function applyPastedNumbers(lineKey: string, units: number) { + const values = (pasteInputs[lineKey] ?? "").split(/[\r\n,;\t]+/).map((s) => s.trim()).filter((s) => s.length > 0) + if (values.length === 0) return + setLines((prev) => + prev.map((l) => { + if (l.key !== lineKey) return l + const next = l.warrantyNumbers.slice(0, units) + while (next.length < units) next.push("") + values.slice(0, units).forEach((v, i) => (next[i] = v)) + return { ...l, warrantyNumbers: next } + }) + ) + setPasteInputs((prev) => ({ ...prev, [lineKey]: "" })) + if (values.length > units) + toast.error("Too many numbers", `${values.length} pasted for ${units} unit(s) — the extra ${values.length - units} were not used.`) + } + + // Auto-generate serial numbers: the server continues the highest number already used under the + // prefix (received + draft GRNs); numbers already typed on this form are skipped too. Only empty + // unit slots are filled — scanned/typed serials are never overwritten. + const [genPrefixes, setGenPrefixes] = useState>({}) + const [generating, setGenerating] = useState(null) + async function autoGenerateSerials(lineKey: string, itemId: number, units: number, prefix: string) { + const line = lines.find((l) => l.key === lineKey) + if (!line) return + const blanks = Array.from({ length: units }, (_, i) => i).filter((i) => !(line.warrantyNumbers[i] ?? "").trim()) + if (blanks.length === 0) { + toast.error("Nothing to generate", "Every unit already has a serial number — clear some first.") + return + } + const inForm = new Set( + lines.filter((l) => l.itemId === itemId).flatMap((l) => l.warrantyNumbers.map((s) => s.trim().toLowerCase())).filter((s) => s) + ) + setGenerating(lineKey) + try { + const fetched = await trackingApi.nextSerialNumbers(itemId, prefix, Math.min(1000, blanks.length + inForm.size)) + const fresh = fetched.filter((n) => !inForm.has(n.toLowerCase())).slice(0, blanks.length) + setLines((prev) => + prev.map((l) => { + if (l.key !== lineKey) return l + const next = l.warrantyNumbers.slice(0, units) + while (next.length < units) next.push("") + blanks.forEach((slot, i) => { if (fresh[i]) next[slot] = fresh[i] }) + return { ...l, warrantyNumbers: next } + }) + ) + toast.success("Serial numbers generated", `${fresh.length} filled, starting ${fresh[0] ?? "—"}.`) + } catch (err) { + toast.error("Could not generate serial numbers", errorMessage(err)) + } finally { + setGenerating(null) + } + } async function handleSubmit() { setSubmitError(null) @@ -576,6 +666,8 @@ export default function NewGrnPage() { discountPct: allowDiscounts ? line.discountPct : "0", vatPct: allowVat ? line.vatPct : "0", warranty: item?.warranty ?? null, + trackingMode: item?.trackingMode ?? null, + batchNo: line.batchNo, warrantyNumbers: line.warrantyNumbers, }) if (Object.keys(errors).length > 0) nextLineErrors[line.key] = errors @@ -593,6 +685,8 @@ export default function NewGrnPage() { const payloadLines: CreateGrnLineInput[] = lines.map((l) => { const item = itemFor(l.itemId) + const unitNumbers = l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0) + const serial = item?.trackingMode === "Serial" return { poLineId: l.poLineId, itemId: l.itemId as number, @@ -602,11 +696,13 @@ export default function NewGrnPage() { discountPct: allowDiscounts ? Number(l.discountPct) || 0 : 0, vatPct: allowVat ? Number(l.vatPct) || 0 : 0, holdStatus: l.holdStatus, - warrantyNumbers: - item?.warranty === "Warranty" - ? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0) + // A serial item's serials are its warranty identity — it sends no warranty numbers. + serialNumbers: serial ? unitNumbers : null, + warrantyNumbers: !serial && item?.warranty === "Warranty" ? unitNumbers : null, + batch: + item?.trackingMode === "Batch" && l.batchNo.trim() + ? { batchNo: l.batchNo.trim(), expiryDate: l.batchExpiry || null } : null, - batch: item?.trackingMode === "Batch" && l.batchNo.trim() ? { batchNo: l.batchNo.trim() } : null, } }) @@ -861,7 +957,7 @@ export default function NewGrnPage() { className="rounded-full" onClick={() => setLineTab("warranty")} > - Warranty numbers + Serial / warranty numbers {warrantyLines.length > 0 && ( {warrantyLines.reduce((sum, w) => sum + w.units, 0)} @@ -951,14 +1047,27 @@ export default function NewGrnPage() { {item?.trackingMode === "Batch" && ( -
- - updateLine(line.key, { batchNo: e.target.value })} - placeholder="Batch no" - className="h-11 text-sm" - /> +
+
+ + updateLine(line.key, { batchNo: e.target.value })} + placeholder="Batch no" + className="h-11 text-sm" + /> + +
+
+ + updateLine(line.key, { batchExpiry: e.target.value })} + className="h-11 text-sm" + /> +
)} @@ -1050,10 +1159,10 @@ export default function NewGrnPage() {
- {item?.warranty === "Warranty" && ( + {capturesUnitNumbers(item) && ( )} {errors.warrantyNumbers && } @@ -1134,12 +1243,24 @@ export default function NewGrnPage() { {item?.trackingMode === "Batch" ? ( - updateLine(line.key, { batchNo: e.target.value })} - placeholder="Batch no" - className="h-11 text-sm" - /> +
+ updateLine(line.key, { batchNo: e.target.value })} + placeholder="Batch no" + className="h-11 text-sm" + /> + updateLine(line.key, { batchExpiry: e.target.value })} + aria-label="Batch expiry date" + title="Expiry date (optional)" + className="h-9 text-xs" + /> + +
) : ( — )} @@ -1237,7 +1358,7 @@ export default function NewGrnPage() {
- {item?.warranty === "Warranty" ? ( + {capturesUnitNumbers(item) ? (
setLineTab("warranty")} - aria-label="Add warranty numbers" + aria-label={`Add ${unitNumberLabel(item)}s`} /> } > - Add warranty numbers + Add {unitNumberLabel(item)}s {errors.warrantyNumbers && ( @@ -1311,8 +1432,8 @@ export default function NewGrnPage() { {warrantyLines.length === 0 ? (

- No warranty-tracked items on this GRN yet — add a line for an item marked - “Warranty” to capture its numbers here. + No serial- or warranty-tracked items on this GRN yet — add a line for such an + item to capture one number per unit here.

) : (
@@ -1320,11 +1441,65 @@ export default function NewGrnPage() { const errors = lineErrors[line.key] ?? {} const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" } const addError = warrantyAddErrors[line.key] + const serial = item?.trackingMode === "Serial" + const label = unitNumberLabel(item) + const entered = line.warrantyNumbers.filter((s) => s.trim().length > 0).length return (
-
+
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} + {serial ? "Serial" : "Warranty"} + {serial && item?.warranty === "Warranty" && ( + Warranty starts at sale + )} + + {entered} of {units} entered +
+ {serial && ( +
+
+ +