notification module
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Notifications;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface INotificationConfigService
|
||||
{
|
||||
Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ERPCore.Domain.Notifications;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface INotificationModuleCatalog
|
||||
{
|
||||
IReadOnlyList<NotificationModuleDefinition> GetAll();
|
||||
NotificationModuleDefinition? Find(string moduleKey);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Config;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Items;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -108,9 +109,11 @@ public sealed class ItemService : IItemService
|
||||
if (await _items.Query().AnyAsync(i => i.Sku == request.Sku, ct))
|
||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
await ValidateReferencesAsync(
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
request.BaseUomId, request.DefaultVendorId, config, ct);
|
||||
ValidateFeatureConfiguration(request, config);
|
||||
|
||||
ItemContent.ValidatePair(request.ContentQty, request.ContentUnit);
|
||||
var (contentBaseQty, contentBaseUnit) = ItemContent.Normalize(request.ContentQty, request.ContentUnit);
|
||||
@@ -163,9 +166,11 @@ public sealed class ItemService : IItemService
|
||||
&& await _items.Query().AnyAsync(i => i.Sku == request.Sku && i.ItemId != itemId, ct))
|
||||
throw new DomainException(ErrorCodes.SkuDuplicate, $"An item with SKU '{request.Sku}' already exists.", 400);
|
||||
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
await ValidateReferencesAsync(
|
||||
request.CategoryId, request.SubCategoryId, request.BrandId,
|
||||
request.BaseUomId, request.DefaultVendorId, ct);
|
||||
request.BaseUomId, request.DefaultVendorId, config, ct);
|
||||
ValidateFeatureConfiguration(request, config);
|
||||
|
||||
// The base UOM is the sole meaning of every quantity recorded against this item —
|
||||
// stock layers, ledger rows and document lines are all plain counts of it. Once any
|
||||
@@ -280,10 +285,9 @@ public sealed class ItemService : IItemService
|
||||
/// it is advisory and honoured by the frontend only (docs/11 §2.8).
|
||||
/// </summary>
|
||||
private async Task ValidateReferencesAsync(
|
||||
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId, CancellationToken ct)
|
||||
int categoryId, int? subCategoryId, int? brandId, int baseUomId, int? defaultVendorId,
|
||||
ProductConfigDto config, CancellationToken ct)
|
||||
{
|
||||
var config = (await _config.GetAsync(ct)).Value;
|
||||
|
||||
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
|
||||
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} does not exist.", 422);
|
||||
|
||||
@@ -337,6 +341,46 @@ public sealed class ItemService : IItemService
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateFeatureConfiguration(CreateItemRequest request, ProductConfigDto config)
|
||||
=> ValidateFeatureConfiguration(
|
||||
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
|
||||
request.TrackingMode, request.Warranty, config);
|
||||
|
||||
private static void ValidateFeatureConfiguration(UpdateItemRequest request, ProductConfigDto config)
|
||||
=> ValidateFeatureConfiguration(
|
||||
request.SellingMinPrice, request.SellingMaxPrice, request.SellingFlatPrice,
|
||||
request.TrackingMode, request.Warranty, config);
|
||||
|
||||
private static void ValidateFeatureConfiguration(
|
||||
decimal? sellingMinPrice, decimal? sellingMaxPrice, decimal? sellingFlatPrice,
|
||||
TrackingMode trackingMode, Warranty warranty, ProductConfigDto config)
|
||||
{
|
||||
if (!config.SellingPricesEnabled &&
|
||||
(sellingMinPrice is not null || sellingMaxPrice is not null || sellingFlatPrice is not null))
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Selling prices are disabled in the product configuration; sellingMinPrice, sellingMaxPrice and sellingFlatPrice must be null.",
|
||||
422);
|
||||
|
||||
if (!config.SerialNumbersEnabled && trackingMode == TrackingMode.Serial)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Serial numbers are disabled in the product configuration; trackingMode cannot be Serial.",
|
||||
422);
|
||||
|
||||
if (!config.BatchListsEnabled && trackingMode == TrackingMode.Batch)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Batch lists are disabled in the product configuration; trackingMode cannot be Batch.",
|
||||
422);
|
||||
|
||||
if (!config.WarrantyPeriodsEnabled && warranty == Warranty.Warranty)
|
||||
throw new DomainException(
|
||||
ErrorCodes.ConfigDisabled,
|
||||
"Warranty periods are disabled in the product configuration; warranty cannot be Warranty.",
|
||||
422);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A warranty-tracked item must declare a coverage period (one of
|
||||
/// <see cref="WarrantyPeriods.AllowedMonths"/>); a non-warranty item carries none —
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Notifications;
|
||||
using ERPCore.Dtos.Notifications;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class NotificationConfigService : INotificationConfigService
|
||||
{
|
||||
private readonly IRepository<NotificationConfig> _config;
|
||||
private readonly IRepository<NotificationConfigModule> _modules;
|
||||
private readonly INotificationModuleCatalog _catalog;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public NotificationConfigService(
|
||||
IRepository<NotificationConfig> config,
|
||||
IRepository<NotificationConfigModule> modules,
|
||||
INotificationModuleCatalog catalog,
|
||||
ICurrentUser currentUser,
|
||||
IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_modules = modules;
|
||||
_catalog = catalog;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<NotificationConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.Include(c => c.Modules)
|
||||
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Notification configuration has not been seeded.");
|
||||
|
||||
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<NotificationConfigDto>> UpdateAsync(UpdateNotificationConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query()
|
||||
.Include(c => c.Modules)
|
||||
.FirstOrDefaultAsync(c => c.NotificationConfigId == NotificationConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Notification configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
|
||||
|
||||
var catalog = _catalog.GetAll();
|
||||
var inputs = request.Modules ?? new List<UpdateNotificationModuleRequest>();
|
||||
if (inputs.Any(x => string.IsNullOrWhiteSpace(x.ModuleKey)))
|
||||
throw new DomainException(ErrorCodes.Validation, "Notification module keys cannot be empty.", 400);
|
||||
if (inputs.Select(x => x.ModuleKey.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Count() != inputs.Count)
|
||||
throw new DomainException(ErrorCodes.Validation, "Duplicate notification module keys are not allowed.", 400);
|
||||
|
||||
var byKey = inputs.ToDictionary(x => x.ModuleKey.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||
var unknown = byKey.Keys.Where(k => catalog.All(m => !string.Equals(m.Key, k, StringComparison.OrdinalIgnoreCase))).ToList();
|
||||
if (unknown.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Unknown notification module key(s): {string.Join(", ", unknown)}.", 422);
|
||||
|
||||
var missing = catalog.Where(m => !byKey.ContainsKey(m.Key)).Select(m => m.Key).ToList();
|
||||
if (missing.Count > 0)
|
||||
throw new DomainException(ErrorCodes.Validation, $"Missing notification module settings for: {string.Join(", ", missing)}.", 422);
|
||||
|
||||
config.Modules.Clear();
|
||||
foreach (var module in catalog)
|
||||
{
|
||||
var input = byKey[module.Key];
|
||||
config.Modules.Add(new NotificationConfigModule
|
||||
{
|
||||
NotificationConfigId = config.NotificationConfigId,
|
||||
ModuleKey = module.Key,
|
||||
EmailEnabled = input.EmailEnabled!.Value,
|
||||
SmsEnabled = input.SmsEnabled!.Value,
|
||||
InAppEnabled = input.InAppEnabled!.Value
|
||||
});
|
||||
}
|
||||
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
await SaveGuardingConcurrencyAsync(ct);
|
||||
return new ETagged<NotificationConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<NotificationChannel>> ResolveEnabledChannelsAsync(string moduleKey, CancellationToken ct = default)
|
||||
{
|
||||
var key = moduleKey?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(key)) return Array.Empty<NotificationChannel>();
|
||||
|
||||
var row = await _modules.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(m => m.ModuleKey == key, ct);
|
||||
|
||||
if (row is null) return Array.Empty<NotificationChannel>();
|
||||
|
||||
var channels = new List<NotificationChannel>(3);
|
||||
if (row.EmailEnabled) channels.Add(NotificationChannel.Email);
|
||||
if (row.SmsEnabled) channels.Add(NotificationChannel.Sms);
|
||||
if (row.InAppEnabled) channels.Add(NotificationChannel.InApp);
|
||||
return channels;
|
||||
}
|
||||
|
||||
private async Task SaveGuardingConcurrencyAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The notification configuration was modified by another request.", 412);
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationConfigDto Map(NotificationConfig config)
|
||||
{
|
||||
var rows = config.Modules
|
||||
.ToDictionary(m => m.ModuleKey, m => m, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var modules = _catalog.GetAll()
|
||||
.Select(m =>
|
||||
{
|
||||
rows.TryGetValue(m.Key, out var row);
|
||||
return new NotificationModuleConfigDto(
|
||||
m.Key,
|
||||
m.Label,
|
||||
m.Description,
|
||||
row?.EmailEnabled ?? false,
|
||||
row?.SmsEnabled ?? false,
|
||||
row?.InAppEnabled ?? false);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new NotificationConfigDto(modules, config.UpdatedAt, config.UpdatedBy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Reflection;
|
||||
using ERPCore.Domain.Notifications;
|
||||
using ERPCore.Domain.Notifications.Modules;
|
||||
using ERPCore.Services.Interfaces;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class NotificationModuleCatalog : INotificationModuleCatalog
|
||||
{
|
||||
private readonly IReadOnlyList<NotificationModuleDefinition> _modules;
|
||||
|
||||
public NotificationModuleCatalog()
|
||||
{
|
||||
var moduleType = typeof(SaleCreateNotificationModule).Assembly;
|
||||
_modules = moduleType.GetTypes()
|
||||
.Select(t => new { Type = t, Attr = t.GetCustomAttribute<NotificationModuleAttribute>() })
|
||||
.Where(x => x.Attr is not null)
|
||||
.Select(x => new NotificationModuleDefinition(x.Attr!.Key, x.Attr.Label, x.Attr.Description, x.Attr.SortOrder))
|
||||
.OrderBy(x => x.SortOrder)
|
||||
.ThenBy(x => x.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var duplicates = _modules.GroupBy(m => m.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.Key)
|
||||
.ToList();
|
||||
if (duplicates.Count > 0)
|
||||
throw new InvalidOperationException($"Duplicate notification module key(s) found: {string.Join(", ", duplicates)}.");
|
||||
}
|
||||
|
||||
public IReadOnlyList<NotificationModuleDefinition> GetAll() => _modules;
|
||||
|
||||
public NotificationModuleDefinition? Find(string moduleKey)
|
||||
=> _modules.FirstOrDefault(m => string.Equals(m.Key, moduleKey, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace ERPCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Product configuration service (FR-MD-11) over the singleton row seeded by DataSeeder.
|
||||
/// Reads are never gated — only writes consult the flags — so switching a feature off
|
||||
/// Reads are never gated - only writes consult the flags - so switching a feature off
|
||||
/// leaves existing data readable (docs/11-BACKEND-PHASE1.md §2.8).
|
||||
/// </summary>
|
||||
public sealed class ProductConfigService : IProductConfigService
|
||||
@@ -46,11 +46,15 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
// Non-null by [Required] on the nullable bools — an omitted flag is a 400, so the
|
||||
// Non-null by [Required] on the nullable bools - an omitted flag is a 400, so the
|
||||
// model binder has already rejected anything that would reach here with a null.
|
||||
config.SubcategoriesEnabled = request.SubcategoriesEnabled!.Value;
|
||||
config.BrandsEnabled = request.BrandsEnabled!.Value;
|
||||
config.ItemTypesEnabled = request.ItemTypesEnabled!.Value;
|
||||
config.SellingPricesEnabled = request.SellingPricesEnabled!.Value;
|
||||
config.SerialNumbersEnabled = request.SerialNumbersEnabled!.Value;
|
||||
config.BatchListsEnabled = request.BatchListsEnabled!.Value;
|
||||
config.WarrantyPeriodsEnabled = request.WarrantyPeriodsEnabled!.Value;
|
||||
config.UpdatedAt = DateTime.UtcNow;
|
||||
config.UpdatedBy = _currentUser.AuditUserId;
|
||||
|
||||
@@ -67,5 +71,7 @@ public sealed class ProductConfigService : IProductConfigService
|
||||
}
|
||||
|
||||
private static ProductConfigDto Map(ProductConfig c) => new(
|
||||
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled, c.UpdatedAt, c.UpdatedBy);
|
||||
c.SubcategoriesEnabled, c.BrandsEnabled, c.ItemTypesEnabled,
|
||||
c.SellingPricesEnabled, c.SerialNumbersEnabled, c.BatchListsEnabled, c.WarrantyPeriodsEnabled,
|
||||
c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user