diff --git a/Backend/ERPCore.Tests/DualApprovalTests.cs b/Backend/ERPCore.Tests/DualApprovalTests.cs index 222b69b..638f4f0 100644 --- a/Backend/ERPCore.Tests/DualApprovalTests.cs +++ b/Backend/ERPCore.Tests/DualApprovalTests.cs @@ -9,6 +9,7 @@ using ERPCore.Infra.Auth; using ERPCore.Infra.Persistence; using ERPCore.Repositories; using ERPCore.Services; +using ERPCore.Services.Gl; using ERPCore.Services.Interfaces; using ERPCore.Services.Stock; using ERPCore.System.Errors; @@ -300,7 +301,10 @@ public class DualApprovalTests new Repository(db), new Repository(db), new Repository(db), new Repository(db), new Repository(db), mutator, new FixedValuation(), numbers, user, new FakeBranchConfig(), notifications, approvals, uow, db, gl, - new ConfigurationBuilder().Build(), + new StubGlAccountConfigService( + (GlAccountRole.Inventory, "1200"), + (GlAccountRole.InventoryGain, "4900"), + (GlAccountRole.InventoryLoss, "5900")), new SerialTrackingService(new Repository(db), new Repository(db), new Repository(db), new Repository(db), user)); var prices = new PriceAdjustmentService( diff --git a/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs b/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs index 6cc9501..e56526c 100644 --- a/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs +++ b/Backend/ERPCore.Tests/TestSupport/BankDepositTestSupport.cs @@ -45,6 +45,10 @@ internal sealed class FakeGeneralLedger : IGeneralLedgerService public Task GetPeriodByDateAsync(DateOnly date, CancellationToken ct) => Task.FromResult(new GlPeriod(1, 1)); public Task> ListBankAccountsAsync(CancellationToken ct) => Task.FromResult(Accounts); + + /// Not implemented on purpose — nothing here picks a GL account by code; see + /// for the fake that does. + public Task> ListAccountsAsync(CancellationToken ct) => throw new NotSupportedException(); } /// The in-memory provider has no transactions: run the work, then save — what the real one commits. diff --git a/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs index e09569e..5e03eaf 100644 --- a/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Migrations/ErpDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using ERPCore.Infra.Persistence; using Microsoft.EntityFrameworkCore; @@ -1651,6 +1651,564 @@ namespace ERPCore.Migrations b.ToTable("hr_employment_types", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Expense", b => + { + b.Property("ExpenseId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ExpenseId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BalanceAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BillReference") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("BranchId") + .HasColumnType("int"); + + b.Property("CancelReason") + .HasMaxLength(250) + .HasColumnType("varchar(250)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("ExpenseCategoryId") + .HasColumnType("int"); + + b.Property("ExpenseDate") + .HasColumnType("date"); + + b.Property("ExpenseTitleId") + .HasColumnType("int"); + + b.Property("GlExpenseAccountCode") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("GlPostedAt") + .HasColumnType("datetime(6)"); + + b.Property("GlReversalJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PaidAmount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("Payee") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("PaymentStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Unpaid"); + + b.Property("PeriodMonth") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Draft"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.HasKey("ExpenseId"); + + b.HasIndex("BranchId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ExpenseCategoryId"); + + b.HasIndex("ExpenseDate"); + + b.HasIndex("ExpenseTitleId"); + + b.HasIndex("PaymentStatus"); + + b.HasIndex("Status"); + + b.HasIndex("VendorId"); + + b.ToTable("expenses", null, t => + { + t.HasCheckConstraint("ck_expenses_amount", "`Amount` > 0"); + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ExpenseCategory", b => + { + b.Property("ExpenseCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ExpenseCategoryId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("ExpenseCategoryId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("expense_categories", (string)null); + + b.HasData( + new + { + ExpenseCategoryId = 1, + Code = "UTILITY", + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + IsSystem = true, + Name = "Utility", + RowVersion = 1u, + SortOrder = 1, + Status = "Active" + }, + new + { + ExpenseCategoryId = 2, + Code = "RENTAL", + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + IsSystem = true, + Name = "Rental", + RowVersion = 1u, + SortOrder = 2, + Status = "Active" + }, + new + { + ExpenseCategoryId = 3, + Code = "SALARIES", + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + IsSystem = true, + Name = "Salaries", + RowVersion = 1u, + SortOrder = 3, + Status = "Active" + }, + new + { + ExpenseCategoryId = 4, + Code = "OTHER", + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + IsSystem = true, + Name = "Other", + RowVersion = 1u, + SortOrder = 4, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ExpensePayment", b => + { + b.Property("ExpensePaymentId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ExpensePaymentId")); + + b.Property("Amount") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BankAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ChequeNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedBy") + .HasColumnType("int"); + + b.Property("ExpenseId") + .HasColumnType("int"); + + b.Property("GlBankAccountId") + .HasColumnType("bigint"); + + b.Property("GlJournalNo") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("PaymentDate") + .HasColumnType("datetime(6)"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("Reference") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("ExpensePaymentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("ExpenseId"); + + b.ToTable("expense_payments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ExpenseTitle", b => + { + b.Property("ExpenseTitleId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ExpenseTitleId")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExpenseCategoryId") + .HasColumnType("int"); + + b.Property("GlExpenseAccountCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("IsSystem") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("int unsigned"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("ExpenseTitleId"); + + b.HasIndex("ExpenseCategoryId", "Name") + .IsUnique(); + + b.ToTable("expense_titles", (string)null); + + b.HasData( + new + { + ExpenseTitleId = 1, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 1, + GlExpenseAccountCode = "5200-01", + IsSystem = true, + Name = "Electricity", + RowVersion = 1u, + SortOrder = 1, + Status = "Active" + }, + new + { + ExpenseTitleId = 2, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 1, + GlExpenseAccountCode = "5200-01", + IsSystem = true, + Name = "Water", + RowVersion = 1u, + SortOrder = 2, + Status = "Active" + }, + new + { + ExpenseTitleId = 3, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 1, + GlExpenseAccountCode = "5200-01", + IsSystem = true, + Name = "Telephone", + RowVersion = 1u, + SortOrder = 3, + Status = "Active" + }, + new + { + ExpenseTitleId = 4, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 1, + GlExpenseAccountCode = "5200-01", + IsSystem = true, + Name = "Internet", + RowVersion = 1u, + SortOrder = 4, + Status = "Active" + }, + new + { + ExpenseTitleId = 5, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 1, + GlExpenseAccountCode = "5200-01", + IsSystem = true, + Name = "Gas", + RowVersion = 1u, + SortOrder = 5, + Status = "Active" + }, + new + { + ExpenseTitleId = 6, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 2, + GlExpenseAccountCode = "5200-02", + IsSystem = true, + Name = "Building Rent", + RowVersion = 1u, + SortOrder = 1, + Status = "Active" + }, + new + { + ExpenseTitleId = 7, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 2, + GlExpenseAccountCode = "5200-02", + IsSystem = true, + Name = "Vehicle Rent", + RowVersion = 1u, + SortOrder = 2, + Status = "Active" + }, + new + { + ExpenseTitleId = 8, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 2, + GlExpenseAccountCode = "5200-02", + IsSystem = true, + Name = "Equipment Rent", + RowVersion = 1u, + SortOrder = 3, + Status = "Active" + }, + new + { + ExpenseTitleId = 9, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 3, + GlExpenseAccountCode = "5200-03", + IsSystem = true, + Name = "Staff Salaries", + RowVersion = 1u, + SortOrder = 1, + Status = "Active" + }, + new + { + ExpenseTitleId = 10, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 3, + GlExpenseAccountCode = "5200-03", + IsSystem = true, + Name = "Wages", + RowVersion = 1u, + SortOrder = 2, + Status = "Active" + }, + new + { + ExpenseTitleId = 11, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 3, + GlExpenseAccountCode = "5200-03", + IsSystem = true, + Name = "Overtime", + RowVersion = 1u, + SortOrder = 3, + Status = "Active" + }, + new + { + ExpenseTitleId = 12, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 3, + GlExpenseAccountCode = "5200-03", + IsSystem = true, + Name = "Bonus", + RowVersion = 1u, + SortOrder = 4, + Status = "Active" + }, + new + { + ExpenseTitleId = 13, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 3, + GlExpenseAccountCode = "5200-03", + IsSystem = true, + Name = "EPF / ETF Contribution", + RowVersion = 1u, + SortOrder = 5, + Status = "Active" + }, + new + { + ExpenseTitleId = 14, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 4, + GlExpenseAccountCode = "5200-04", + IsSystem = true, + Name = "Stationery", + RowVersion = 1u, + SortOrder = 1, + Status = "Active" + }, + new + { + ExpenseTitleId = 15, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 4, + GlExpenseAccountCode = "5200-04", + IsSystem = true, + Name = "Transport", + RowVersion = 1u, + SortOrder = 2, + Status = "Active" + }, + new + { + ExpenseTitleId = 16, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 4, + GlExpenseAccountCode = "5200-04", + IsSystem = true, + Name = "Repairs & Maintenance", + RowVersion = 1u, + SortOrder = 3, + Status = "Active" + }, + new + { + ExpenseTitleId = 17, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 4, + GlExpenseAccountCode = "5200-04", + IsSystem = true, + Name = "Cleaning", + RowVersion = 1u, + SortOrder = 4, + Status = "Active" + }, + new + { + ExpenseTitleId = 18, + CreatedAt = new DateTime(2026, 10, 7, 0, 0, 0, 0, DateTimeKind.Utc), + ExpenseCategoryId = 4, + GlExpenseAccountCode = "5200-04", + IsSystem = true, + Name = "Miscellaneous", + RowVersion = 1u, + SortOrder = 5, + Status = "Active" + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b => { b.Property("ConfigId") @@ -8139,6 +8697,78 @@ namespace ERPCore.Migrations b.Navigation("SalaryComponent"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Expense", 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.ExpenseCategory", "Category") + .WithMany() + .HasForeignKey("ExpenseCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ExpenseTitle", "Title") + .WithMany() + .HasForeignKey("ExpenseTitleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + + b.Navigation("Category"); + + b.Navigation("Creator"); + + b.Navigation("Title"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ExpensePayment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Expense", "Expense") + .WithMany("Payments") + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Expense"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ExpenseTitle", b => + { + b.HasOne("ERPCore.Domain.Entities.ExpenseCategory", "Category") + .WithMany("Titles") + .HasForeignKey("ExpenseCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.GlAccountConfig", b => { b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") diff --git a/Backend/ERPCore/Services/AdjustmentService.cs b/Backend/ERPCore/Services/AdjustmentService.cs index 3642694..a7d5cd7 100644 --- a/Backend/ERPCore/Services/AdjustmentService.cs +++ b/Backend/ERPCore/Services/AdjustmentService.cs @@ -53,9 +53,10 @@ public sealed class AdjustmentService : IAdjustmentService 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, IGlAccountConfigService glAccounts) + IRepository reasonCodes, IRepository ledger, IStockMutator mutator, IFifoCostingService fifo, + INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, INotificationService notifications, + IApprovalService approvals, IUnitOfWork uow, ErpDbContext db, + IGeneralLedgerService gl, IGlAccountConfigService glAccounts, ISerialTrackingService serialTracking) { _serialTracking = serialTracking; _adjustments = adjustments; diff --git a/Backend/ERPCore/Services/CountService.cs b/Backend/ERPCore/Services/CountService.cs index cd02c5b..7ff851d 100644 --- a/Backend/ERPCore/Services/CountService.cs +++ b/Backend/ERPCore/Services/CountService.cs @@ -38,14 +38,15 @@ public sealed class CountService : ICountService private readonly IRepository _stockLayers; private readonly IRepository _batches; private readonly IRepository _serials; - private readonly IGlAccountConfigService _glAccounts; + private readonly ISerialTrackingService _serialTracking; + private readonly AdjustmentService _adjustments; public CountService( IRepository counts, IRepository warehouses, IRepository items, IRepository reasonCodes, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IUnitOfWork uow, - IGeneralLedgerService gl, IRepository stockLayers, IRepository batches, - IRepository serials, IGlAccountConfigService glAccounts) + IRepository stockLayers, IRepository batches, + IRepository serials, ISerialTrackingService serialTracking, AdjustmentService adjustments) { _serialTracking = serialTracking; _counts = counts; @@ -60,42 +61,7 @@ public sealed class CountService : ICountService _stockLayers = stockLayers; _batches = batches; _serials = serials; - _glAccounts = glAccounts; - } - - /// Same logic as AdjustmentService's private method of the same name — see there for why - /// gains/losses post as separate lines instead of a net. - private async Task PostAdjustmentJournalAsync( - string docNo, DateTime now, IReadOnlyList refs, CancellationToken ct) - { - var gain = refs.Where(r => r.Direction == Direction.In).Sum(r => r.Value); - 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(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(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 - { - PeriodId = period.PeriodId, - EntryDate = BusinessClock.ToBusinessDate(now), - SourceModule = "ADJUSTMENT", - Reference = docNo, - Narration = $"Stock adjustment {docNo}", - Lines = lines - }, ct); - return result.JournalNo; + _adjustments = adjustments; } public async Task> ListAsync( diff --git a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs index 52ff97f..b104526 100644 --- a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs @@ -64,11 +64,11 @@ 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 period = await _gl.GetPeriodByDateAsync(paymentDay, token); var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest { PeriodId = period.PeriodId, - EntryDate = BusinessClock.ToBusinessDate(now), + EntryDate = paymentDay, SourceModule = "PURCHASE_INVOICE_PAYMENT", Reference = invoice.DocNo, Narration = $"Payment against invoice {invoice.DocNo}", @@ -104,13 +104,14 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi /// /// 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 + /// rule as . Omitted ⇒ today — the business day + /// (), not the UTC one. 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 today = BusinessClock.ToBusinessDate(now); var day = requested ?? today; if (day > today.AddDays(1)) throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400); diff --git a/Backend/ERPCore/Services/Stock/FifoCostingService.cs b/Backend/ERPCore/Services/Stock/FifoCostingService.cs index 18205e9..418a2e4 100644 --- a/Backend/ERPCore/Services/Stock/FifoCostingService.cs +++ b/Backend/ERPCore/Services/Stock/FifoCostingService.cs @@ -27,12 +27,13 @@ public sealed class FifoCostingService : IFifoCostingService private readonly ErpDbContext _db; private readonly IGeneralLedgerService _gl; private readonly IGlAccountConfigService _glAccounts; + private readonly LowStockTracker? _lowStock; /// Records each issue for the after-request low-stock check; optional so callers outside a request (tests) can omit it. public FifoCostingService( IRepository layers, IRepository ledger, IRepository journal, ErpDbContext db, - IGeneralLedgerService gl, IGlAccountConfigService glAccounts) + IGeneralLedgerService gl, IGlAccountConfigService glAccounts, LowStockTracker? lowStock = null) { _layers = layers; _ledger = ledger; @@ -40,6 +41,7 @@ public sealed class FifoCostingService : IFifoCostingService _db = db; _gl = gl; _glAccounts = glAccounts; + _lowStock = lowStock; } public async Task CreateInboundLayerAsync(