diff --git a/Backend/ERPCore.Tests/ERPCore.Tests.csproj b/Backend/ERPCore.Tests/ERPCore.Tests.csproj
new file mode 100644
index 0000000..b2bafa9
--- /dev/null
+++ b/Backend/ERPCore.Tests/ERPCore.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Backend/ERPCore.Tests/NotificationConfigServiceTests.cs b/Backend/ERPCore.Tests/NotificationConfigServiceTests.cs
new file mode 100644
index 0000000..6588fd3
--- /dev/null
+++ b/Backend/ERPCore.Tests/NotificationConfigServiceTests.cs
@@ -0,0 +1,152 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Domain.Notifications;
+using ERPCore.Repositories;
+using ERPCore.Services;
+using ERPCore.System.Errors;
+using ERPCore.Tests.TestSupport;
+using Microsoft.EntityFrameworkCore;
+
+namespace ERPCore.Tests;
+
+public class NotificationConfigServiceTests
+{
+ [Fact]
+ public async Task GetAsync_MergesSavedValuesWithDiscoveredModules()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db, new[]
+ {
+ new NotificationConfigModule
+ {
+ ModuleKey = "sale.create",
+ EmailEnabled = true,
+ SmsEnabled = false,
+ InAppEnabled = true
+ }
+ });
+
+ var service = CreateService(db);
+
+ var result = await service.GetAsync();
+
+ Assert.Equal(7u, result.RowVersion);
+ Assert.Contains(result.Value.Modules, m => m.ModuleKey == "sale.create" && m.EmailEnabled && !m.SmsEnabled && m.InAppEnabled);
+ Assert.Contains(result.Value.Modules, m => m.ModuleKey == "grn.confirmed" && !m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_PersistsEmptyAndMultiChannelSelections()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db);
+
+ var service = CreateService(db);
+ var catalog = new NotificationModuleCatalog();
+ var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
+ {
+ Modules = catalog.GetAll().Select((module, index) => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
+ {
+ ModuleKey = module.Key,
+ EmailEnabled = index == 0,
+ SmsEnabled = index == 1,
+ InAppEnabled = index == 1
+ }).ToList()
+ };
+
+ var updated = await service.UpdateAsync(request, 7);
+
+ Assert.Equal(7u, updated.RowVersion);
+ Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
+ Assert.Contains(updated.Value.Modules, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
+
+ await using var verifyDb = NotificationConfigTestSupport.CreateContext(dbName);
+ var persisted = await verifyDb.NotificationConfigModules.AsNoTracking().ToListAsync();
+ Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[0].Key && m.EmailEnabled && !m.SmsEnabled && !m.InAppEnabled);
+ Assert.Contains(persisted, m => m.ModuleKey == catalog.GetAll()[1].Key && !m.EmailEnabled && m.SmsEnabled && m.InAppEnabled);
+ }
+
+ [Fact]
+ public async Task ResolveEnabledChannelsAsync_ReturnsEmptyWhenNothingEnabled()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db, new[]
+ {
+ new NotificationConfigModule
+ {
+ ModuleKey = "sale.create",
+ EmailEnabled = false,
+ SmsEnabled = false,
+ InAppEnabled = false
+ }
+ });
+
+ var service = CreateService(db);
+
+ var channels = await service.ResolveEnabledChannelsAsync("sale.create");
+
+ Assert.Empty(channels);
+ }
+
+ [Fact]
+ public async Task ResolveEnabledChannelsAsync_ReturnsMultipleChannels()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db, new[]
+ {
+ new NotificationConfigModule
+ {
+ ModuleKey = "sale.create",
+ EmailEnabled = true,
+ SmsEnabled = true,
+ InAppEnabled = false
+ }
+ });
+
+ var service = CreateService(db);
+
+ var channels = await service.ResolveEnabledChannelsAsync("sale.create");
+
+ Assert.Equal(new[] { NotificationChannel.Email, NotificationChannel.Sms }, channels);
+ }
+
+ [Fact]
+ public async Task UpdateAsync_MapsConcurrencyFailuresToDomainException()
+ {
+ var dbName = Guid.NewGuid().ToString("N");
+ await using var db = NotificationConfigTestSupport.CreateContext(dbName);
+ await NotificationConfigTestSupport.SeedAsync(db);
+
+ var service = CreateService(db, new ThrowingUnitOfWork());
+ var catalog = new NotificationModuleCatalog();
+ var request = new ERPCore.Dtos.Notifications.UpdateNotificationConfigRequest
+ {
+ Modules = catalog.GetAll().Select(module => new ERPCore.Dtos.Notifications.UpdateNotificationModuleRequest
+ {
+ ModuleKey = module.Key,
+ EmailEnabled = false,
+ SmsEnabled = false,
+ InAppEnabled = false
+ }).ToList()
+ };
+
+ var ex = await Assert.ThrowsAsync(() => service.UpdateAsync(request, 7));
+ Assert.Equal(ErrorCodes.ConcurrencyConflict, ex.Code);
+ Assert.Equal(412, ex.StatusCode);
+ }
+
+ private static NotificationConfigService CreateService(ERPCore.Infra.Persistence.ErpDbContext db, ERPCore.Infra.UoW.IUnitOfWork? uow = null)
+ {
+ var configRepo = new Repository(db);
+ var moduleRepo = new Repository(db);
+ return new NotificationConfigService(
+ configRepo,
+ moduleRepo,
+ new NotificationModuleCatalog(),
+ new TestCurrentUser(),
+ uow ?? new ERPCore.Infra.UoW.UnitOfWork(db));
+ }
+}
diff --git a/Backend/ERPCore.Tests/NotificationModuleCatalogTests.cs b/Backend/ERPCore.Tests/NotificationModuleCatalogTests.cs
new file mode 100644
index 0000000..c4230e2
--- /dev/null
+++ b/Backend/ERPCore.Tests/NotificationModuleCatalogTests.cs
@@ -0,0 +1,17 @@
+using ERPCore.Services;
+
+namespace ERPCore.Tests;
+
+public class NotificationModuleCatalogTests
+{
+ [Fact]
+ public void GetAll_ReturnsStableUniqueModuleKeys()
+ {
+ var catalog = new NotificationModuleCatalog();
+
+ Assert.NotEmpty(catalog.GetAll());
+ Assert.Contains(catalog.GetAll(), m => m.Key == "sale.create");
+ Assert.Contains(catalog.GetAll(), m => m.Key == "grn.confirmed");
+ Assert.Equal(catalog.GetAll().Count, catalog.GetAll().Select(m => m.Key).Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ }
+}
diff --git a/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs b/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs
new file mode 100644
index 0000000..55128d3
--- /dev/null
+++ b/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs
@@ -0,0 +1,70 @@
+using ERPCore.Domain.Entities;
+using ERPCore.Infra.Auth;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage;
+
+namespace ERPCore.Tests.TestSupport;
+
+internal sealed class TestCurrentUser : ICurrentUser
+{
+ public string UserId => "system";
+ public int AuditUserId => User.SystemUserId;
+ public bool IsAuthenticated => false;
+}
+
+internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
+{
+ public Task SaveChangesAsync(CancellationToken ct = default)
+ => throw new DbUpdateConcurrencyException("Test concurrency conflict.");
+
+ public Task ExecuteInTransactionAsync(Func action, CancellationToken ct = default)
+ => throw new NotSupportedException();
+
+ public Task ExecuteInTransactionAsync(Func> action, CancellationToken ct = default)
+ => throw new NotSupportedException();
+}
+
+internal static class NotificationConfigTestSupport
+{
+ private static readonly InMemoryDatabaseRoot Root = new();
+
+ public static ErpDbContext CreateContext(string name)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(name, Root)
+ .Options;
+
+ return new ErpDbContext(options, new TestCurrentUser());
+ }
+
+ public static async Task SeedAsync(ErpDbContext db, IEnumerable? modules = null)
+ {
+ db.Users.Add(new User
+ {
+ UserId = User.SystemUserId,
+ Username = "system",
+ DisplayName = "System"
+ });
+
+ var config = new NotificationConfig
+ {
+ NotificationConfigId = NotificationConfig.SingletonId,
+ UpdatedBy = User.SystemUserId,
+ UpdatedAt = DateTime.UtcNow,
+ RowVersion = 7,
+ };
+
+ if (modules is not null)
+ {
+ foreach (var module in modules)
+ {
+ module.NotificationConfigId = NotificationConfig.SingletonId;
+ config.Modules.Add(module);
+ }
+ }
+
+ db.NotificationConfig.Add(config);
+ await db.SaveChangesAsync();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/NotificationConfigController.cs b/Backend/ERPCore/Controllers/NotificationConfigController.cs
new file mode 100644
index 0000000..13b035b
--- /dev/null
+++ b/Backend/ERPCore/Controllers/NotificationConfigController.cs
@@ -0,0 +1,34 @@
+using ERPCore.Dtos.Notifications;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+[Route("api/v1/notification-config")]
+public sealed class NotificationConfigController : ApiControllerBase
+{
+ private readonly INotificationConfigService _config;
+
+ public NotificationConfigController(INotificationConfigService config) => _config = config;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
+ public async Task> Get(CancellationToken ct)
+ {
+ var result = await _config.GetAsync(ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPut]
+ [ProducesResponseType(typeof(NotificationConfigDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> Update([FromBody] UpdateNotificationConfigRequest 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/Domain/Entities/NotificationConfig.cs b/Backend/ERPCore/Domain/Entities/NotificationConfig.cs
new file mode 100644
index 0000000..5c1824e
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/NotificationConfig.cs
@@ -0,0 +1,19 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Notification configuration (new settings module) - singleton root row.
+///
+public class NotificationConfig
+{
+ public const int SingletonId = 1;
+
+ public int NotificationConfigId { get; set; }
+
+ public DateTime? UpdatedAt { get; set; }
+ public int? UpdatedBy { get; set; }
+ public User? UpdatedByUser { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Modules { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/NotificationConfigModule.cs b/Backend/ERPCore/Domain/Entities/NotificationConfigModule.cs
new file mode 100644
index 0000000..7051721
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/NotificationConfigModule.cs
@@ -0,0 +1,14 @@
+namespace ERPCore.Domain.Entities;
+
+public class NotificationConfigModule
+{
+ public int NotificationConfigModuleId { get; set; }
+
+ public int NotificationConfigId { get; set; }
+ public NotificationConfig? NotificationConfig { get; set; }
+
+ public string ModuleKey { get; set; } = string.Empty;
+ public bool EmailEnabled { get; set; }
+ public bool SmsEnabled { get; set; }
+ public bool InAppEnabled { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/ProductConfig.cs b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
index 4120cdc..ca55f16 100644
--- a/Backend/ERPCore/Domain/Entities/ProductConfig.cs
+++ b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
@@ -1,22 +1,24 @@
namespace ERPCore.Domain.Entities;
///
-/// Product configuration (FR-MD-11) — a singleton row (single-tenant, docs/00-CORE §1)
+/// Product configuration (FR-MD-11) - a singleton row (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
///
/// and are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
-/// rejected with CONFIG_DISABLED. is
-/// advisory only — items carry no item-type reference (see ),
-/// so there is nothing on a write to reject; the frontend honours it by hiding the
-/// builder's type section. Reads are never gated, so existing data stays visible after a
-/// flag is switched off.
+/// rejected with CONFIG_DISABLED. is advisory only
+/// - items carry no item-type reference (see ), so there is nothing
+/// on a write to reject; the frontend honours it by hiding the variant builder.
+/// , ,
+/// and are enforced
+/// on item writes and drive the create/edit screens. Reads are never gated, so existing
+/// data stays visible after a flag is switched off.
///
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
///
public class ProductConfig
{
- /// Always 1 — the singleton row's id.
+ /// Always 1 - the singleton row's id.
public const int SingletonId = 1;
public int ConfigId { get; set; }
@@ -24,6 +26,10 @@ public class ProductConfig
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
+ public bool SellingPricesEnabled { get; set; } = true;
+ public bool SerialNumbersEnabled { get; set; } = true;
+ public bool BatchListsEnabled { get; set; } = true;
+ public bool WarrantyPeriodsEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs b/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs
new file mode 100644
index 0000000..49c4efb
--- /dev/null
+++ b/Backend/ERPCore/Domain/Notifications/Modules/NotificationModules.cs
@@ -0,0 +1,34 @@
+namespace ERPCore.Domain.Notifications.Modules;
+
+[NotificationModule("sale.create", "Sale Created", "Raised when a sales document is created.", 10)]
+public sealed class SaleCreateNotificationModule;
+
+[NotificationModule("sale.posted", "Sale Posted", "Raised when a sales document is posted.", 20)]
+public sealed class SalePostedNotificationModule;
+
+[NotificationModule("grn.created", "GRN Created", "Raised when a goods receipt note is created.", 30)]
+public sealed class GrnCreatedNotificationModule;
+
+[NotificationModule("grn.confirmed", "GRN Confirmed", "Raised when a goods receipt note is confirmed.", 40)]
+public sealed class GrnConfirmedNotificationModule;
+
+[NotificationModule("purchase.order.created", "Purchase Order Created", "Raised when a purchase order is created.", 50)]
+public sealed class PurchaseOrderCreatedNotificationModule;
+
+[NotificationModule("purchase.order.approved", "Purchase Order Approved", "Raised when a purchase order is approved.", 60)]
+public sealed class PurchaseOrderApprovedNotificationModule;
+
+[NotificationModule("purchase.return.created", "Purchase Return Created", "Raised when a purchase return is created.", 70)]
+public sealed class PurchaseReturnCreatedNotificationModule;
+
+[NotificationModule("stock.adjustment.created", "Stock Adjustment Created", "Raised when a stock adjustment is created.", 80)]
+public sealed class StockAdjustmentCreatedNotificationModule;
+
+[NotificationModule("stock.transfer.created", "Stock Transfer Created", "Raised when a stock transfer is created.", 90)]
+public sealed class StockTransferCreatedNotificationModule;
+
+[NotificationModule("item.created", "Item Created", "Raised when an item master record is created.", 100)]
+public sealed class ItemCreatedNotificationModule;
+
+[NotificationModule("item.updated", "Item Updated", "Raised when an item master record is updated.", 110)]
+public sealed class ItemUpdatedNotificationModule;
diff --git a/Backend/ERPCore/Domain/Notifications/NotificationChannel.cs b/Backend/ERPCore/Domain/Notifications/NotificationChannel.cs
new file mode 100644
index 0000000..eae5ae7
--- /dev/null
+++ b/Backend/ERPCore/Domain/Notifications/NotificationChannel.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Notifications;
+
+public enum NotificationChannel
+{
+ Email,
+ Sms,
+ InApp
+}
diff --git a/Backend/ERPCore/Domain/Notifications/NotificationModuleAttribute.cs b/Backend/ERPCore/Domain/Notifications/NotificationModuleAttribute.cs
new file mode 100644
index 0000000..c2db649
--- /dev/null
+++ b/Backend/ERPCore/Domain/Notifications/NotificationModuleAttribute.cs
@@ -0,0 +1,18 @@
+namespace ERPCore.Domain.Notifications;
+
+[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
+public sealed class NotificationModuleAttribute : Attribute
+{
+ public string Key { get; }
+ public string Label { get; }
+ public string Description { get; }
+ public int SortOrder { get; }
+
+ public NotificationModuleAttribute(string key, string label, string description, int sortOrder)
+ {
+ Key = key;
+ Label = label;
+ Description = description;
+ SortOrder = sortOrder;
+ }
+}
diff --git a/Backend/ERPCore/Domain/Notifications/NotificationModuleDefinition.cs b/Backend/ERPCore/Domain/Notifications/NotificationModuleDefinition.cs
new file mode 100644
index 0000000..039e7ed
--- /dev/null
+++ b/Backend/ERPCore/Domain/Notifications/NotificationModuleDefinition.cs
@@ -0,0 +1,7 @@
+namespace ERPCore.Domain.Notifications;
+
+public sealed record NotificationModuleDefinition(
+ string Key,
+ string Label,
+ string Description,
+ int SortOrder);
diff --git a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
index f4e2e71..67d98d1 100644
--- a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
+++ b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
@@ -4,10 +4,14 @@ namespace ERPCore.Dtos.Config;
///
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
-/// is advisory (frontend-honoured) — see the entity docs.
+/// is advisory (frontend-honoured) - see the entity docs.
+/// , ,
+/// and are enforced
+/// when items are written.
///
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
+ bool SellingPricesEnabled, bool SerialNumbersEnabled, bool BatchListsEnabled, bool WarrantyPeriodsEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
///
@@ -15,8 +19,8 @@ public sealed record ProductConfigDto(
///
/// The flags are ? deliberately: [Required] on a non-nullable bool
/// is a no-op (it always has a value), so a body of {} would bind every flag to
-/// false and silently switch all three features off. Nullable makes the requirement
-/// actually bind — an omitted flag is a 400, not an accidental disable.
+/// false and silently switch everything off. Nullable makes the requirement actually
+/// bind - an omitted flag is a 400, not an accidental disable.
///
///
public sealed class UpdateProductConfigRequest
@@ -24,4 +28,8 @@ public sealed class UpdateProductConfigRequest
[Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { get; set; }
+ [Required] public bool? SellingPricesEnabled { get; set; }
+ [Required] public bool? SerialNumbersEnabled { get; set; }
+ [Required] public bool? BatchListsEnabled { get; set; }
+ [Required] public bool? WarrantyPeriodsEnabled { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/Notifications/NotificationConfigDtos.cs b/Backend/ERPCore/Dtos/Notifications/NotificationConfigDtos.cs
new file mode 100644
index 0000000..9233d00
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Notifications/NotificationConfigDtos.cs
@@ -0,0 +1,29 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Notifications;
+
+public sealed record NotificationModuleConfigDto(
+ string ModuleKey,
+ string Label,
+ string Description,
+ bool EmailEnabled,
+ bool SmsEnabled,
+ bool InAppEnabled);
+
+public sealed record NotificationConfigDto(
+ IReadOnlyList Modules,
+ DateTime? UpdatedAt,
+ int? UpdatedBy);
+
+public sealed class UpdateNotificationModuleRequest
+{
+ [Required, StringLength(100)] public string ModuleKey { get; set; } = string.Empty;
+ [Required] public bool? EmailEnabled { get; set; }
+ [Required] public bool? SmsEnabled { get; set; }
+ [Required] public bool? InAppEnabled { get; set; }
+}
+
+public sealed class UpdateNotificationConfigRequest
+{
+ [Required, MinLength(0)] public List Modules { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigConfiguration.cs
new file mode 100644
index 0000000..710b1c5
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigConfiguration.cs
@@ -0,0 +1,29 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class NotificationConfigConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("notification_config", t =>
+ t.HasCheckConstraint("ck_notification_config_singleton", $"\"NotificationConfigId\" = {NotificationConfig.SingletonId}"));
+
+ builder.HasKey(c => c.NotificationConfigId);
+ builder.Property(c => c.NotificationConfigId).ValueGeneratedNever();
+
+ builder.Property(c => c.RowVersion).IsRowVersion();
+
+ builder.HasMany(c => c.Modules)
+ .WithOne(m => m.NotificationConfig)
+ .HasForeignKey(m => m.NotificationConfigId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ builder.HasOne(c => c.UpdatedByUser)
+ .WithMany()
+ .HasForeignKey(c => c.UpdatedBy)
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigModuleConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigModuleConfiguration.cs
new file mode 100644
index 0000000..2c598bf
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NotificationConfigModuleConfiguration.cs
@@ -0,0 +1,21 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+public sealed class NotificationConfigModuleConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("notification_config_modules");
+ builder.HasKey(m => m.NotificationConfigModuleId);
+
+ builder.Property(m => m.ModuleKey).IsRequired().HasMaxLength(100);
+ builder.HasIndex(m => m.ModuleKey).IsUnique();
+
+ builder.Property(m => m.EmailEnabled).IsRequired().HasDefaultValue(false);
+ builder.Property(m => m.SmsEnabled).IsRequired().HasDefaultValue(false);
+ builder.Property(m => m.InAppEnabled).IsRequired().HasDefaultValue(false);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
index d916d09..7580b94 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
@@ -25,6 +25,10 @@ public sealed class ProductConfigConfiguration : IEntityTypeConfiguration c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.SellingPricesEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.SerialNumbersEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.BatchListsEnabled).IsRequired().HasDefaultValue(true);
+ builder.Property(c => c.WarrantyPeriodsEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
index d2d7862..37dac63 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
@@ -33,6 +33,7 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
+ {
+ if (await db.NotificationConfig.AnyAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)) return false;
+
+ db.NotificationConfig.Add(new NotificationConfig
+ {
+ NotificationConfigId = NotificationConfig.SingletonId
});
return true;
}
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index c03fc5f..56ece71 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -38,6 +38,9 @@ public class ErpDbContext : DbContext
/// Singleton row (FR-MD-11).
public DbSet ProductConfig => Set();
+ /// Singleton row for notification delivery preferences.
+ public DbSet NotificationConfig => Set();
+ public DbSet NotificationConfigModules => Set();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet Users => Set();
diff --git a/Backend/ERPCore/Migrations/20260820100345_AddNotificationConfigModulePreferences.Designer.cs b/Backend/ERPCore/Migrations/20260820100345_AddNotificationConfigModulePreferences.Designer.cs
new file mode 100644
index 0000000..19e78c5
--- /dev/null
+++ b/Backend/ERPCore/Migrations/20260820100345_AddNotificationConfigModulePreferences.Designer.cs
@@ -0,0 +1,7580 @@
+//
+using System;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ERPCore.Migrations
+{
+ [DbContext(typeof(ErpDbContext))]
+ [Migration("20260820100345_AddNotificationConfigModulePreferences")]
+ partial class AddNotificationConfigModulePreferences
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b =>
+ {
+ b.Property("AttendanceRecordId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceRecordId"));
+
+ b.Property("AttendanceDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("AttendanceStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("AttendanceUploadBatchId")
+ .HasColumnType("integer");
+
+ b.Property("CheckIn")
+ .HasColumnType("interval");
+
+ b.Property("CheckOut")
+ .HasColumnType("interval");
+
+ b.Property("DuplicateOfAttendanceRecordId")
+ .HasColumnType("integer");
+
+ b.Property("EarlyLeaveMinutes")
+ .HasColumnType("integer");
+
+ b.Property("EditedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EditedBy")
+ .HasColumnType("integer");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("IsManualOverride")
+ .HasColumnType("boolean");
+
+ b.Property("LateMinutes")
+ .HasColumnType("integer");
+
+ b.Property("Notes")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("OvertimeMinutes")
+ .HasColumnType("integer");
+
+ b.Property("RowValidationStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("integer");
+
+ b.Property("WorkingMinutes")
+ .HasColumnType("integer");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceUploadBatchId"));
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConfirmedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(260)
+ .HasColumnType("character varying(260)");
+
+ b.Property("PeriodEnd")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PeriodStart")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowCountDuplicate")
+ .HasColumnType("integer");
+
+ b.Property("RowCountError")
+ .HasColumnType("integer");
+
+ b.Property("RowCountTotal")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SourceType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UploadedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UploadedBy")
+ .HasColumnType("integer");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("ChangeSet")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EntityId")
+ .HasColumnType("integer");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId"));
+
+ b.Property("BatchNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("date");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.HasKey("BatchId");
+
+ b.HasIndex("ItemId", "BatchNo")
+ .IsUnique();
+
+ b.ToTable("batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
+ {
+ b.Property("BinId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId"));
+
+ b.Property("BinType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BranchId"));
+
+ b.Property("Address")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BranchId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("hr_branches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
+ {
+ b.Property("BrandId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleId"));
+
+ b.Property("BundleCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BundleDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("BundleName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BundleNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BundlePrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("integer");
+
+ b.Property("CashierUserId")
+ .HasColumnType("integer");
+
+ b.Property("ComponentSubtotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer");
+
+ b.Property("CustomerSnapshotName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("DayEndId")
+ .HasColumnType("integer");
+
+ b.Property("DiscountTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("GrandTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("MarginAmount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Draft");
+
+ b.Property("TaxTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleLineId"));
+
+ b.Property("BundleSaleId")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("IncludeInBundle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("IsComponent")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("LineTotal")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ParentLineId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleTemplateId"));
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TemplateCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("TemplateName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BundleSaleTemplateLineId"));
+
+ b.Property("BundleSaleTemplateId")
+ .HasColumnType("integer");
+
+ b.Property("IncludeInBundle")
+ .HasColumnType("boolean");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("SortOrder")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CustomerId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(250)
+ .HasColumnType("character varying(250)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(250)
+ .HasColumnType("character varying(250)");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreditDays")
+ .HasColumnType("integer");
+
+ b.Property("CreditLimit")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CustomerCode")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CustomerType")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("B2C");
+
+ b.Property("DefaultWarehouseId")
+ .HasColumnType("integer");
+
+ b.Property("DisplayName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Email")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Phone")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TaxRegistrationNo")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("CustomerId");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DepartmentId"));
+
+ b.Property("BranchId")
+ .HasColumnType("integer");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HeadEmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ParentDepartmentId")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DesignationId"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeId"));
+
+ b.Property("AddressLine1")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("AddressLine2")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BranchId")
+ .HasColumnType("integer");
+
+ b.Property("City")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ConfirmationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Country")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("DateOfBirth")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DepartmentId")
+ .HasColumnType("integer");
+
+ b.Property("DesignationId")
+ .HasColumnType("integer");
+
+ b.Property("Email")
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)");
+
+ b.Property("EmergencyContactName")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("EmergencyContactPhone")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EmergencyContactRelationship")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("EmployeeCode")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EmploymentTypeId")
+ .HasColumnType("integer");
+
+ b.Property("EpfNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("EtfNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Gender")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("HireDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastWorkingDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Nationality")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Nic")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("PersonalMobile")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("PostalCode")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("ProfilePhotoPath")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("ReportingManagerId")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("TaxIdentificationNumber")
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.Property("WorkShiftId")
+ .HasColumnType("integer");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeBankDetailId"));
+
+ b.Property("AccountHolderName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("AccountNumber")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("BankName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("BranchName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("SwiftCode")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ 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("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeDocumentId"));
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("EmployeeId")
+ .HasColumnType("integer");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property