price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
@@ -0,0 +1,34 @@
using ERPCore.Services.Stock;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ERPCore.Infra.Filters;
/// <summary>
/// After every API action that succeeded, checks whether its stock issues pushed anything to or
/// below its reorder point (<see cref="LowStockAlertService"/>). By then the action's UoW
/// transaction has committed, so only stock that really left is alerted on. The check is
/// best-effort: a failure is logged and never fails the request that moved the stock.
/// </summary>
public sealed class LowStockAlertFilter(LowStockAlertService alerts, LowStockTracker tracker, ILogger<LowStockAlertFilter> logger) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var executed = await next();
// A failed action rolled its transaction back: nothing it "issued" actually left stock.
if (executed.Exception is not null && !executed.ExceptionHandled)
{
tracker.Drain();
return;
}
try
{
await alerts.CheckAsync(context.HttpContext.RequestAborted);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Low-stock check failed after {Path}", context.HttpContext.Request.Path);
}
}
}
@@ -0,0 +1,99 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Dual approval tables (docs/DUAL_APPROVAL_PLAN.md §4, §6, §7).</summary>
public sealed class DocumentApprovalConfiguration : IEntityTypeConfiguration<DocumentApproval>
{
public void Configure(EntityTypeBuilder<DocumentApproval> builder)
{
builder.ToTable("document_approvals");
builder.HasKey(a => a.DocumentApprovalId);
builder.Property(a => a.DocType).IsRequired().HasMaxLength(10);
builder.Property(a => a.Decision).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(a => a.Comment).HasMaxLength(250);
builder.Property(a => a.DecidedAt).IsRequired();
builder.HasOne(a => a.Decider).WithMany().HasForeignKey(a => a.DecidedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(a => new { a.DocType, a.DocId });
}
}
public sealed class ApprovalConfigConfiguration : IEntityTypeConfiguration<ApprovalConfig>
{
public void Configure(EntityTypeBuilder<ApprovalConfig> builder)
{
builder.ToTable("approval_config", t =>
t.HasCheckConstraint("ck_approval_config_singleton", $"`ApprovalConfigId` = {ApprovalConfig.SingletonId}"));
builder.HasKey(c => c.ApprovalConfigId);
builder.Property(c => c.ApprovalConfigId).ValueGeneratedNever();
builder.Property(c => c.StockAdjustmentLevel2ThresholdValue).HasPrecision(18, 4);
builder.Property(c => c.PriceAdjustmentLevel2ThresholdPercent).HasPrecision(9, 4);
builder.Property(c => c.Level2RoleCodes).HasMaxLength(500);
// Dual approval on by default for both documents (plan §8 recommended defaults).
builder.HasData(new ApprovalConfig
{
ApprovalConfigId = ApprovalConfig.SingletonId,
StockAdjustmentLevels = 2,
PriceAdjustmentLevels = 2,
RowVersion = 1
});
}
}
public sealed class PriceAdjustmentConfiguration : IEntityTypeConfiguration<PriceAdjustment>
{
public void Configure(EntityTypeBuilder<PriceAdjustment> builder)
{
builder.ToTable("price_adjustments");
builder.HasKey(p => p.PriceAdjustmentId);
builder.Property(p => p.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(p => p.DocNo).IsUnique();
builder.Property(p => p.Reason).IsRequired().HasMaxLength(250);
builder.Property(p => p.Status).HasConversion<string>().HasMaxLength(30).IsRequired()
.HasDefaultValue(PriceAdjustmentStatus.PendingApproval);
builder.Property(p => p.RejectReason).HasMaxLength(250);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(p => p.Lines).WithOne(l => l.PriceAdjustment).HasForeignKey(l => l.PriceAdjustmentId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(p => p.Status);
}
}
public sealed class PriceAdjustmentLineConfiguration : IEntityTypeConfiguration<PriceAdjustmentLine>
{
public void Configure(EntityTypeBuilder<PriceAdjustmentLine> builder)
{
builder.ToTable("price_adjustment_lines");
builder.HasKey(l => l.PriceAdjustmentLineId);
foreach (var p in new[] { nameof(PriceAdjustmentLine.OldMinPrice), nameof(PriceAdjustmentLine.OldMaxPrice), nameof(PriceAdjustmentLine.OldFlatPrice),
nameof(PriceAdjustmentLine.NewMinPrice), nameof(PriceAdjustmentLine.NewMaxPrice), nameof(PriceAdjustmentLine.NewFlatPrice) })
builder.Property<decimal?>(p).HasPrecision(18, 4);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(l => l.ItemId);
}
}
public sealed class ItemPriceHistoryConfiguration : IEntityTypeConfiguration<ItemPriceHistory>
{
public void Configure(EntityTypeBuilder<ItemPriceHistory> builder)
{
builder.ToTable("item_price_history");
builder.HasKey(h => h.ItemPriceHistoryId);
foreach (var p in new[] { nameof(ItemPriceHistory.OldMinPrice), nameof(ItemPriceHistory.OldMaxPrice), nameof(ItemPriceHistory.OldFlatPrice),
nameof(ItemPriceHistory.NewMinPrice), nameof(ItemPriceHistory.NewMaxPrice), nameof(ItemPriceHistory.NewFlatPrice) })
builder.Property<decimal?>(p).HasPrecision(18, 4);
builder.Property(h => h.AppliedAt).IsRequired();
builder.HasOne(h => h.Item).WithMany().HasForeignKey(h => h.ItemId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(h => h.PriceAdjustment).WithMany().HasForeignKey(h => h.PriceAdjustmentId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(h => new { h.ItemId, h.AppliedAt });
}
}
@@ -0,0 +1,50 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md §4).</summary>
public sealed class BankDepositConfiguration : IEntityTypeConfiguration<BankDeposit>
{
public void Configure(EntityTypeBuilder<BankDeposit> builder)
{
builder.ToTable("bank_deposits", t =>
t.HasCheckConstraint("ck_bank_deposits_amount", "`Amount` > 0"));
builder.HasKey(x => x.BankDepositId);
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(x => x.DocNo).IsUnique();
builder.Property(x => x.BusinessDate).IsRequired();
builder.Property(x => x.DepositDate).IsRequired();
builder.Property(x => x.Amount).HasPrecision(18, 4);
builder.Property(x => x.FromCashAccountCode).IsRequired().HasMaxLength(20);
builder.Property(x => x.FromCashAccountName).IsRequired().HasMaxLength(200);
builder.Property(x => x.ToBankAccountCode).IsRequired().HasMaxLength(20);
builder.Property(x => x.ToBankAccountName).IsRequired().HasMaxLength(200);
builder.Property(x => x.ToBankName).HasMaxLength(150);
builder.Property(x => x.ToAccountNumber).HasMaxLength(50);
builder.Property(x => x.DepositSlipNo).HasMaxLength(50);
builder.Property(x => x.DepositedBy).HasMaxLength(150);
builder.Property(x => x.Remarks).HasMaxLength(500);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(BankDepositStatus.Posted);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.GlReversalJournalNo).HasMaxLength(30);
builder.Property(x => x.CancelReason).HasMaxLength(250);
builder.Property(x => x.CreatedAt).IsRequired();
builder.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.BranchId, x.BusinessDate, x.Status });
builder.HasIndex(x => x.ToGlBankAccountId);
}
}
@@ -26,6 +26,8 @@ public sealed class BundleSaleConfiguration : IEntityTypeConfiguration<BundleSal
builder.Property(x => x.DiscountTotal).HasPrecision(18, 4);
builder.Property(x => x.TaxTotal).HasPrecision(18, 4);
builder.Property(x => x.GrandTotal).HasPrecision(18, 4);
builder.Property(x => x.CashAmount).HasPrecision(18, 4);
builder.Property(x => x.CardAmount).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired();
builder.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
@@ -0,0 +1,170 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Expense module tables (docs/EXPENSE_MODULE_PLAN.md §3). The four categories and their
/// titles are seeded as <c>IsSystem</c> rows. The GL codes are the children of the GL
/// "Operating Expenses" (5200) control account created for this module on 2026-10-08 — GL
/// generates codes itself, so a GL instance with a different chart needs these corrected per
/// title on the Expense Setup screen.
/// </summary>
public sealed class ExpenseCategoryConfiguration : IEntityTypeConfiguration<ExpenseCategory>
{
public const string UtilityGlCode = "5200-01";
public const string RentalGlCode = "5200-02";
public const string SalariesGlCode = "5200-03";
public const string OtherGlCode = "5200-04";
public void Configure(EntityTypeBuilder<ExpenseCategory> builder)
{
builder.ToTable("expense_categories");
builder.HasKey(c => c.ExpenseCategoryId);
builder.Property(c => c.Code).IsRequired().HasMaxLength(30);
builder.HasIndex(c => c.Code).IsUnique();
builder.Property(c => c.Name).IsRequired().HasMaxLength(100);
builder.HasIndex(c => c.Name).IsUnique();
builder.Property(c => c.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.HasMany(c => c.Titles)
.WithOne(t => t.Category)
.HasForeignKey(t => t.ExpenseCategoryId)
.OnDelete(DeleteBehavior.Restrict);
var seededAt = new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc);
builder.HasData(
new ExpenseCategory { ExpenseCategoryId = 1, Code = "UTILITY", Name = "Utility", IsSystem = true, SortOrder = 1, CreatedAt = seededAt, RowVersion = 1 },
new ExpenseCategory { ExpenseCategoryId = 2, Code = "RENTAL", Name = "Rental", IsSystem = true, SortOrder = 2, CreatedAt = seededAt, RowVersion = 1 },
new ExpenseCategory { ExpenseCategoryId = 3, Code = "SALARIES", Name = "Salaries", IsSystem = true, SortOrder = 3, CreatedAt = seededAt, RowVersion = 1 },
new ExpenseCategory { ExpenseCategoryId = 4, Code = "OTHER", Name = "Other", IsSystem = true, SortOrder = 4, CreatedAt = seededAt, RowVersion = 1 }
);
}
}
public sealed class ExpenseTitleConfiguration : IEntityTypeConfiguration<ExpenseTitle>
{
public void Configure(EntityTypeBuilder<ExpenseTitle> builder)
{
builder.ToTable("expense_titles");
builder.HasKey(t => t.ExpenseTitleId);
builder.Property(t => t.Name).IsRequired().HasMaxLength(100);
builder.HasIndex(t => new { t.ExpenseCategoryId, t.Name }).IsUnique();
builder.Property(t => t.GlExpenseAccountCode).IsRequired().HasMaxLength(20);
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
var seededAt = new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc);
var id = 0;
ExpenseTitle Seed(int categoryId, string name, string glCode, int sortOrder) => new()
{
ExpenseTitleId = ++id, ExpenseCategoryId = categoryId, Name = name, GlExpenseAccountCode = glCode,
IsSystem = true, SortOrder = sortOrder, CreatedAt = seededAt, RowVersion = 1
};
// Append only — the ids are assigned in this order, so reordering would renumber seeded rows.
builder.HasData(
Seed(1, "Electricity", ExpenseCategoryConfiguration.UtilityGlCode, 1),
Seed(1, "Water", ExpenseCategoryConfiguration.UtilityGlCode, 2),
Seed(1, "Telephone", ExpenseCategoryConfiguration.UtilityGlCode, 3),
Seed(1, "Internet", ExpenseCategoryConfiguration.UtilityGlCode, 4),
Seed(1, "Gas", ExpenseCategoryConfiguration.UtilityGlCode, 5),
Seed(2, "Building Rent", ExpenseCategoryConfiguration.RentalGlCode, 1),
Seed(2, "Vehicle Rent", ExpenseCategoryConfiguration.RentalGlCode, 2),
Seed(2, "Equipment Rent", ExpenseCategoryConfiguration.RentalGlCode, 3),
Seed(3, "Staff Salaries", ExpenseCategoryConfiguration.SalariesGlCode, 1),
Seed(3, "Wages", ExpenseCategoryConfiguration.SalariesGlCode, 2),
Seed(3, "Overtime", ExpenseCategoryConfiguration.SalariesGlCode, 3),
Seed(3, "Bonus", ExpenseCategoryConfiguration.SalariesGlCode, 4),
Seed(3, "EPF / ETF Contribution", ExpenseCategoryConfiguration.SalariesGlCode, 5),
Seed(4, "Stationery", ExpenseCategoryConfiguration.OtherGlCode, 1),
Seed(4, "Transport", ExpenseCategoryConfiguration.OtherGlCode, 2),
Seed(4, "Repairs & Maintenance", ExpenseCategoryConfiguration.OtherGlCode, 3),
Seed(4, "Cleaning", ExpenseCategoryConfiguration.OtherGlCode, 4),
Seed(4, "Miscellaneous", ExpenseCategoryConfiguration.OtherGlCode, 5)
);
}
}
public sealed class ExpenseConfiguration : IEntityTypeConfiguration<Expense>
{
public void Configure(EntityTypeBuilder<Expense> builder)
{
builder.ToTable("expenses", t =>
t.HasCheckConstraint("ck_expenses_amount", "`Amount` > 0"));
builder.HasKey(x => x.ExpenseId);
builder.Property(x => x.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(x => x.DocNo).IsUnique();
builder.Property(x => x.ExpenseDate).IsRequired();
builder.Property(x => x.Payee).HasMaxLength(150);
builder.Property(x => x.BillReference).HasMaxLength(50);
builder.Property(x => x.Description).HasMaxLength(500);
builder.Property(x => x.PeriodMonth).HasMaxLength(7);
foreach (var p in new[] { nameof(Expense.Amount), nameof(Expense.PaidAmount), nameof(Expense.BalanceAmount) })
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(ExpenseStatus.Draft);
builder.Property(x => x.PaymentStatus)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(ExpensePaymentStatus.Unpaid);
builder.Property(x => x.GlExpenseAccountCode).HasMaxLength(20);
builder.Property(x => x.GlJournalNo).HasMaxLength(30);
builder.Property(x => x.GlReversalJournalNo).HasMaxLength(30);
builder.Property(x => x.CancelReason).HasMaxLength(250);
builder.Property(x => x.CreatedAt).IsRequired();
builder.HasOne(x => x.Category).WithMany().HasForeignKey(x => x.ExpenseCategoryId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Title).WithMany().HasForeignKey(x => x.ExpenseTitleId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Vendor).WithMany().HasForeignKey(x => x.VendorId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Branch).WithMany().HasForeignKey(x => x.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Creator).WithMany().HasForeignKey(x => x.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(x => x.Payments)
.WithOne(p => p.Expense)
.HasForeignKey(p => p.ExpenseId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => x.Status);
builder.HasIndex(x => x.PaymentStatus);
builder.HasIndex(x => x.ExpenseDate);
builder.HasIndex(x => x.BranchId);
}
}
public sealed class ExpensePaymentConfiguration : IEntityTypeConfiguration<ExpensePayment>
{
public void Configure(EntityTypeBuilder<ExpensePayment> builder)
{
builder.ToTable("expense_payments");
builder.HasKey(p => p.ExpensePaymentId);
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.PaymentMethod).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.ChequeNo).HasMaxLength(30);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
builder.HasOne(p => p.Creator).WithMany().HasForeignKey(p => p.CreatedBy).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(p => p.ExpenseId);
}
}
@@ -84,7 +84,13 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 },
new Permission { PermissionId = 53, Code = "NAV:products.tax-rates", SubNavItemId = 43 }
new Permission { PermissionId = 53, Code = "NAV:products.tax-rates", SubNavItemId = 43 },
new Permission { PermissionId = 54, Code = "NAV:accounts.expenses", SubNavItemId = 44 },
new Permission { PermissionId = 55, Code = "NAV:accounts.expense-setup", SubNavItemId = 45 },
new Permission { PermissionId = 56, Code = "NAV:sales.day-end-report", SubNavItemId = 46 },
new Permission { PermissionId = 57, Code = "NAV:accounts.bank-deposits", SubNavItemId = 47 },
new Permission { PermissionId = 58, Code = "NAV:products.price-adjustments", SubNavItemId = 48 },
new Permission { PermissionId = 59, Code = "NAV:settings.approvals", SubNavItemId = 49 }
);
}
}
@@ -70,6 +70,9 @@ public sealed class SalesInvoicePaymentConfiguration : IEntityTypeConfiguration<
builder.Property(p => p.Amount).HasPrecision(18, 4);
builder.Property(p => p.PaymentDate).IsRequired();
builder.Property(p => p.BankAccountName).IsRequired().HasMaxLength(200);
builder.Property(p => p.PaymentMethod)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesPaymentMethod.Cash);
builder.Property(p => p.Reference).HasMaxLength(100);
builder.Property(p => p.GlJournalNo).HasMaxLength(30);
builder.Property(p => p.CreatedAt).IsRequired();
@@ -38,7 +38,7 @@ public sealed class SalesSlipConfiguration : IEntityTypeConfiguration<SalesSlip>
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(SalesSlipStatus.Draft);
foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount) })
foreach (var p in new[] { nameof(SalesSlip.Subtotal), nameof(SalesSlip.DiscountTotal), nameof(SalesSlip.TaxTotal), nameof(SalesSlip.GrandTotal), nameof(SalesSlip.PaidAmount), nameof(SalesSlip.BalanceAmount), nameof(SalesSlip.CashAmount), nameof(SalesSlip.CardAmount) })
builder.Property<decimal>(p).HasPrecision(18, 4);
builder.Property(x => x.CreatedAt).IsRequired();
@@ -14,9 +14,12 @@ public sealed class StockAdjustmentConfiguration : IEntityTypeConfiguration<Stoc
builder.Property(a => a.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(a => a.DocNo).IsUnique();
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
// 30, not 20: "PendingSecondApproval" is 21 characters.
builder.Property(a => a.Status).HasConversion<string>().HasMaxLength(30).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
builder.Property(a => a.GlJournalNo).HasMaxLength(30);
builder.Property(a => a.RejectReason).HasMaxLength(250);
builder.HasIndex(a => a.Status);
builder.HasOne(a => a.Warehouse).WithMany().HasForeignKey(a => a.WarehouseId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(a => a.ReasonCode).WithMany().HasForeignKey(a => a.ReasonCodeId).OnDelete(DeleteBehavior.Restrict);
@@ -35,6 +38,8 @@ public sealed class StockAdjustmentLineConfiguration : IEntityTypeConfiguration<
builder.HasKey(l => l.AdjLineId);
builder.Property(l => l.QtyDelta).HasPrecision(18, 4);
builder.Property(l => l.RequestedSerialIds).HasMaxLength(4000);
builder.Property(l => l.RequestedSerialNos).HasMaxLength(4000);
builder.HasOne(l => l.Adjustment).WithMany(a => a.Lines).HasForeignKey(l => l.AdjustmentId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.Item).WithMany().HasForeignKey(l => l.ItemId).OnDelete(DeleteBehavior.Restrict);
@@ -32,17 +32,20 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 43, NavItemId = 2, Code = "products.tax-rates", Label = "Tax Rates", Href = "/dashboard/products/tax-rates", SortOrder = 7 },
new SubNavItem { SubNavItemId = 48, NavItemId = 2, Code = "products.price-adjustments", Label = "Price Adjustments", Href = "/dashboard/products/price-adjustments", SortOrder = 8 },
new SubNavItem { SubNavItemId = 29, NavItemId = 9, Code = "settings.branches", Label = "Branches", Href = "/dashboard/settings/branches", SortOrder = 0 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
new SubNavItem { SubNavItemId = 49, NavItemId = 9, Code = "settings.approvals", Label = "Approvals", Href = "/dashboard/settings/approvals", SortOrder = 4 },
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 },
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Approved Invoices", Href = "/dashboard/sales/day-end", SortOrder = 4 },
new SubNavItem { SubNavItemId = 46, NavItemId = 13, Code = "sales.day-end-report", Label = "Day-End Report", Href = "/dashboard/sales/day-end-report", SortOrder = 5 },
// Procurement (NavItemId 4) children — mirror the hub page order.
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now.
@@ -67,6 +70,9 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
new SubNavItem { SubNavItemId = 44, NavItemId = 12, Code = "accounts.expenses", Label = "Expenses", Href = "/dashboard/accounts/expenses", SortOrder = 4 },
new SubNavItem { SubNavItemId = 45, NavItemId = 12, Code = "accounts.expense-setup", Label = "Expense Setup", Href = "/dashboard/accounts/expense-setup", SortOrder = 5 },
new SubNavItem { SubNavItemId = 47, NavItemId = 12, Code = "accounts.bank-deposits", Label = "Bank Deposits", Href = "/dashboard/accounts/bank-deposits", SortOrder = 6 },
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
// ID 27 (not 24): 24 collides with settings.notification-config above — that ID was
// reused by mistake in migration 20260820100345, which upserts row 24 to become the
@@ -90,6 +90,22 @@ public class ErpDbContext : DbContext
public DbSet<PurchaseInvoiceLine> PurchaseInvoiceLines => Set<PurchaseInvoiceLine>();
public DbSet<PurchaseInvoicePayment> PurchaseInvoicePayments => Set<PurchaseInvoicePayment>();
// --- Accounts: expenses (docs/EXPENSE_MODULE_PLAN.md) ---
public DbSet<ExpenseCategory> ExpenseCategories => Set<ExpenseCategory>();
public DbSet<ExpenseTitle> ExpenseTitles => Set<ExpenseTitle>();
public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<ExpensePayment> ExpensePayments => Set<ExpensePayment>();
// --- Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md) ---
public DbSet<BankDeposit> BankDeposits => Set<BankDeposit>();
// --- Dual approval: stock & price adjustments (docs/DUAL_APPROVAL_PLAN.md) ---
public DbSet<DocumentApproval> DocumentApprovals => Set<DocumentApproval>();
public DbSet<ApprovalConfig> ApprovalConfig => Set<ApprovalConfig>();
public DbSet<PriceAdjustment> PriceAdjustments => Set<PriceAdjustment>();
public DbSet<PriceAdjustmentLine> PriceAdjustmentLines => Set<PriceAdjustmentLine>();
public DbSet<ItemPriceHistory> ItemPriceHistory => Set<ItemPriceHistory>();
// --- Batch / Serial (docs/10 Part C.4) ---
public DbSet<Batch> Batches => Set<Batch>();
public DbSet<Serial> Serials => Set<Serial>();
@@ -221,6 +237,8 @@ public class ErpDbContext : DbContext
modelBuilder.Entity<Rfq>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<PurchaseInvoice>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<Expense>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
modelBuilder.Entity<BankDeposit>().HasQueryFilter(e => !_currentBranch.IsActive || e.BranchId == _currentBranch.BranchId);
// Optimistic concurrency token. On PostgreSQL this was the virtual `xmin` system
// column via .IsRowVersion(), which the engine maintained for free. MariaDB has no