feat: add vendor optional fields and configuration hooks

- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
This commit is contained in:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
@@ -0,0 +1,79 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
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;
/// <summary>
/// Access configuration over the singleton row seeded by DataSeeder. The default role is what every first sign-in
/// (and every role-less person) receives, so it is held to a hard rule: it must exist, be active, and NEVER be an
/// administrator role — otherwise anyone who can register would become an administrator.
/// </summary>
public sealed class AccessConfigService : IAccessConfigService
{
private readonly IRepository<AccessConfig> _config;
private readonly IRepository<Role> _roles;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public AccessConfigService(IRepository<AccessConfig> config, IRepository<Role> roles, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_roles = roles;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<AccessConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking().Include(c => c.DefaultRole)
.FirstOrDefaultAsync(c => c.ConfigId == AccessConfig.SingletonId, ct)
?? throw new NotFoundException("Access configuration has not been seeded.");
return new ETagged<AccessConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<AccessConfigDto>> UpdateAsync(
UpdateAccessConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(AccessConfig.SingletonId, ct)
?? throw new NotFoundException("Access configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
if (request.DefaultRoleId is int roleId)
{
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
if (role.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Role '{role.Name}' is inactive and cannot be the default.", 422);
if (role.IsAdmin)
throw new DomainException(ErrorCodes.Validation, "The administrator role cannot be the default role — everyone who signs in would become an administrator.", 422);
}
config.DefaultRoleId = request.DefaultRoleId;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return await GetAsync(ct);
}
private static AccessConfigDto Map(AccessConfig c) => new(c.DefaultRoleId, c.DefaultRole?.Name, c.UpdatedAt, c.UpdatedBy);
}
@@ -89,5 +89,5 @@ public sealed class ApprovalInboxService
}
private async Task<bool> CanApproveScreenAsync(string navCode, CancellationToken ct)
=> _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleCode, navCode, RolePermissionAction.Approve, ct);
=> _currentUser.IsAdmin || await _nav.HasActionAsync(_currentUser.RoleId, navCode, RolePermissionAction.Approve, ct);
}
+31 -17
View File
@@ -18,20 +18,21 @@ namespace ERPCore.Services;
/// </summary>
public sealed class ApprovalService : IApprovalService
{
private const string AdminRoleCode = "Admin";
private readonly IRepository<ApprovalConfig> _config;
private readonly IRepository<DocumentApproval> _approvals;
private readonly IRepository<User> _users;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Role> _roles;
private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications;
private readonly IUnitOfWork _uow;
public ApprovalService(
IRepository<ApprovalConfig> config, IRepository<DocumentApproval> approvals, IRepository<User> users,
IRepository<RolePermission> rolePermissions, ICurrentUser currentUser, INotificationService notifications, IUnitOfWork uow)
IRepository<RolePermission> rolePermissions, IRepository<Role> roles, ICurrentUser currentUser,
INotificationService notifications, IUnitOfWork uow)
{
_roles = roles;
_config = config;
_approvals = approvals;
_users = users;
@@ -56,8 +57,15 @@ public sealed class ApprovalService : IApprovalService
config.StockAdjustmentLevel2ThresholdValue = Math.Max(0m, request.StockAdjustmentLevel2ThresholdValue);
config.PriceAdjustmentLevels = Math.Clamp(request.PriceAdjustmentLevels, 0, 2);
config.PriceAdjustmentLevel2ThresholdPercent = Math.Max(0m, request.PriceAdjustmentLevel2ThresholdPercent);
var roles = request.Level2RoleCodes.Select(r => r.Trim()).Where(r => r.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
config.Level2RoleCodes = roles.Count == 0 ? null : string.Join(",", roles);
var roleIds = request.Level2RoleIds.Where(id => id > 0).Distinct().ToList();
if (roleIds.Count > 0)
{
var known = await _roles.Query().AsNoTracking().Where(r => roleIds.Contains(r.RoleId)).Select(r => r.RoleId).ToListAsync(ct);
var unknown = roleIds.Except(known).ToList();
if (unknown.Count > 0)
throw new DomainException(ErrorCodes.Validation, $"Role(s) not found: {string.Join(", ", unknown)}.", 422);
}
config.Level2RoleIds = roleIds.Count == 0 ? null : string.Join(",", roleIds);
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -117,9 +125,13 @@ public sealed class ApprovalService : IApprovalService
return ("You gave the first approval — a different person has to give the second.", ErrorCodes.ApprovalSamePerson);
if (target.CurrentLevel >= 2)
{
var roles = Level2Roles(await LoadConfigAsync(ct));
if (roles.Count > 0 && !_currentUser.IsAdmin && !roles.Contains(_currentUser.RoleCode ?? string.Empty))
return ($"Only {string.Join(", ", roles)} can give the second approval.", ErrorCodes.ApprovalRoleRequired);
var allowed = Level2RoleIds(await LoadConfigAsync(ct));
if (allowed.Count > 0 && !_currentUser.IsAdmin && !(_currentUser.RoleId is int roleId && allowed.Contains(roleId)))
{
var names = await _roles.Query().AsNoTracking().Where(r => allowed.Contains(r.RoleId)).OrderBy(r => r.Name)
.Select(r => r.Name).ToListAsync(ct);
return ($"Only {string.Join(", ", names)} can give the second approval.", ErrorCodes.ApprovalRoleRequired);
}
}
return (null, null);
}
@@ -141,15 +153,15 @@ public sealed class ApprovalService : IApprovalService
var approverRoleIds = grants.Where(g => g.Actions.HasFlag(RolePermissionAction.Approve)).Select(g => g.RoleId).ToHashSet();
var candidates = await _users.Query().AsNoTracking()
.Where(u => u.Status == EntityStatus.Active && u.RoleId != null)
.Select(u => new { u.UserId, RoleId = u.RoleId!.Value, RoleCode = u.Role!.Code })
.Where(u => u.Status == EntityStatus.Active && u.RoleId != null && u.Role!.Status == EntityStatus.Active)
.Select(u => new { u.UserId, RoleId = u.RoleId!.Value, RoleIsAdmin = u.Role!.IsAdmin })
.ToListAsync(ct);
var level2Roles = target.CurrentLevel >= 2 ? Level2Roles(await LoadConfigAsync(ct)) : [];
var level2Roles = target.CurrentLevel >= 2 ? Level2RoleIds(await LoadConfigAsync(ct)) : [];
return candidates
.Where(u => string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase) || approverRoleIds.Contains(u.RoleId))
.Where(u => u.RoleIsAdmin || approverRoleIds.Contains(u.RoleId))
.Where(u => u.UserId != target.RequestedBy && u.UserId != target.Level1ApprovedBy)
.Where(u => level2Roles.Count == 0 || level2Roles.Contains(u.RoleCode) || string.Equals(u.RoleCode, AdminRoleCode, StringComparison.OrdinalIgnoreCase))
.Where(u => level2Roles.Count == 0 || level2Roles.Contains(u.RoleId) || u.RoleIsAdmin)
.Select(u => u.UserId)
.ToList();
}
@@ -166,13 +178,15 @@ public sealed class ApprovalService : IApprovalService
=> await _config.Query().AsNoTracking().FirstOrDefaultAsync(c => c.ApprovalConfigId == ApprovalConfig.SingletonId, ct)
?? new ApprovalConfig { ApprovalConfigId = ApprovalConfig.SingletonId };
private static HashSet<string> Level2Roles(ApprovalConfig c)
=> (c.Level2RoleCodes ?? string.Empty)
private static HashSet<int> Level2RoleIds(ApprovalConfig c)
=> (c.Level2RoleIds ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
.Select(s => int.TryParse(s, out var id) ? id : 0)
.Where(id => id > 0)
.ToHashSet();
private static ApprovalConfigDto Map(ApprovalConfig c) => new(
c.StockAdjustmentLevels, c.StockAdjustmentLevel2ThresholdValue,
c.PriceAdjustmentLevels, c.PriceAdjustmentLevel2ThresholdPercent,
Level2Roles(c).ToList(), c.UpdatedAt);
Level2RoleIds(c).Order().ToList(), c.UpdatedAt);
}
@@ -15,6 +15,9 @@ public sealed class AuthUserService : IAuthUserService
public async Task<AuthSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default)
{
request.UserId ??= Guid.NewGuid();
// A self-registering person never chooses their role (this used to accept any AuthHex role id, including an
// admin one). They sign in with the ERP's default role until an administrator assigns a real one.
request.RoleId = null;
var result = await _authHex.RegisterAsync(request, ct);
return ToSessionResult(result);
}
@@ -209,7 +209,12 @@ public sealed class BankDepositService : IBankDepositService
private async Task<(int BranchId, int? CashScope)> ResolveBranchAsync(int requested, CancellationToken ct)
{
if (_currentBranch.IsActive && _currentBranch.BranchId is { } own)
{
// A branch-less person in a branching deployment has no branch to deposit for.
if (own == CurrentBranchContext.NoBranchId)
throw new DomainException(ErrorCodes.Validation, "Your account is not linked to a branch, so you cannot record bank deposits. Ask an administrator to link your employee record to a branch.", 403);
return (own, own);
}
var branchId = await _branchConfig.ResolveBranchIdAsync(requested, ct);
return await _branchConfig.IsBranchingEnabledAsync(ct) ? (branchId, branchId) : (branchId, null);
+53 -16
View File
@@ -139,6 +139,7 @@ public sealed class CustomerService : ICustomerService
if (!exists) throw new NotFoundException($"Warehouse {request.DefaultWarehouseId} was not found.");
}
var fields = await GetFieldSwitchesAsync(ct);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var customer = new Customer
{
@@ -146,15 +147,15 @@ public sealed class CustomerService : ICustomerService
CustomerType = request.CustomerType,
Name = name,
DisplayName = Normalize(request.DisplayName),
Phone = Normalize(request.Phone),
Email = Normalize(request.Email),
AddressLine1 = Normalize(request.AddressLine1),
AddressLine2 = Normalize(request.AddressLine2),
City = Normalize(request.City),
Country = Normalize(request.Country),
Phone = Gate(fields.Phone, request.Phone, null, "phone numbers"),
Email = Gate(fields.Email, request.Email, null, "email addresses"),
AddressLine1 = Gate(fields.Address, request.AddressLine1, null, "addresses"),
AddressLine2 = Gate(fields.Address, request.AddressLine2, null, "addresses"),
City = Gate(fields.City, request.City, null, "cities"),
Country = Gate(fields.Country, request.Country, null, "countries"),
TaxRegistrationNo = Normalize(request.TaxRegistrationNo),
CreditLimit = request.CreditLimit,
CreditDays = request.CreditDays,
CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, 0m, "credit limits"),
CreditDays = Gate(fields.CreditDays, request.CreditDays, 0, "credit days"),
DefaultWarehouseId = request.DefaultWarehouseId,
Status = EntityStatus.Active,
BranchId = branchId,
@@ -197,15 +198,17 @@ public sealed class CustomerService : ICustomerService
customer.CustomerType = request.CustomerType;
customer.Name = name;
customer.DisplayName = Normalize(request.DisplayName);
customer.Phone = Normalize(request.Phone);
customer.Email = Normalize(request.Email);
customer.AddressLine1 = Normalize(request.AddressLine1);
customer.AddressLine2 = Normalize(request.AddressLine2);
customer.City = Normalize(request.City);
customer.Country = Normalize(request.Country);
// A field switched off in Sales Configuration keeps whatever is stored; only a *different* value is refused.
var fields = await GetFieldSwitchesAsync(ct);
customer.Phone = Gate(fields.Phone, request.Phone, customer.Phone, "phone numbers");
customer.Email = Gate(fields.Email, request.Email, customer.Email, "email addresses");
customer.AddressLine1 = Gate(fields.Address, request.AddressLine1, customer.AddressLine1, "addresses");
customer.AddressLine2 = Gate(fields.Address, request.AddressLine2, customer.AddressLine2, "addresses");
customer.City = Gate(fields.City, request.City, customer.City, "cities");
customer.Country = Gate(fields.Country, request.Country, customer.Country, "countries");
customer.TaxRegistrationNo = Normalize(request.TaxRegistrationNo);
customer.CreditLimit = request.CreditLimit;
customer.CreditDays = request.CreditDays;
customer.CreditLimit = Gate(fields.CreditLimit, request.CreditLimit, customer.CreditLimit, "credit limits");
customer.CreditDays = Gate(fields.CreditDays, request.CreditDays, customer.CreditDays, "credit days");
customer.DefaultWarehouseId = request.DefaultWarehouseId;
customer.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
customer.UpdatedAt = DateTime.UtcNow;
@@ -238,4 +241,38 @@ public sealed class CustomerService : ICustomerService
c.CreditLimit, c.CreditDays, c.DefaultWarehouseId, c.Status, c.BranchId, c.CreatedAt, c.UpdatedAt);
private static string? Normalize(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Which optional customer fields Sales Configuration has switched on (all on when the row is missing).</summary>
private sealed record FieldSwitches(bool Phone, bool Email, bool Address, bool City, bool Country, bool CreditLimit, bool CreditDays);
private async Task<FieldSwitches> GetFieldSwitchesAsync(CancellationToken ct) =>
await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => new FieldSwitches(
c.CustomerPhoneEnabled, c.CustomerEmailEnabled, c.CustomerAddressEnabled, c.CustomerCityEnabled,
c.CustomerCountryEnabled, c.CustomerCreditLimitEnabled, c.CustomerCreditDaysEnabled))
.FirstOrDefaultAsync(ct)
?? new FieldSwitches(true, true, true, true, true, true, true);
/// <summary>On: the (trimmed) requested value. Off: the stored value is kept; a different value is a 422 CONFIG_DISABLED
/// (the UI hides the field, so only a stale or hand-built request gets here).</summary>
private static string? Gate(bool enabled, string? requested, string? stored, string what)
{
var value = Normalize(requested);
if (enabled) return value;
if (value is null || string.Equals(value, stored, StringComparison.Ordinal)) return stored;
throw new DomainException(ErrorCodes.ConfigDisabled, $"Customer {what} are turned off in Sales configuration.", 422);
}
private static decimal Gate(bool enabled, decimal requested, decimal stored, string what)
{
if (enabled || requested == 0m || requested == stored) return enabled ? requested : stored;
throw new DomainException(ErrorCodes.ConfigDisabled, $"Customer {what} are turned off in Sales configuration.", 422);
}
private static int Gate(bool enabled, int requested, int stored, string what)
{
if (enabled || requested == 0 || requested == stored) return enabled ? requested : stored;
throw new DomainException(ErrorCodes.ConfigDisabled, $"Customer {what} are turned off in Sales configuration.", 422);
}
}
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ERPCore.Infra.Gl;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
@@ -13,7 +14,11 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
PropertyNameCaseInsensitive = true,
// GL's request DTOs have non-nullable strings (e.g. JournalType) that its [ApiController] validation treats as
// required: an explicit `"journalType": null` is a 400, while an omitted property just takes the default.
// So never send nulls.
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly IGeneralLedgerClient _client;
@@ -82,7 +87,9 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
if (response.StatusCode is < 200 or >= 300 || envelope is null || !envelope.Success || envelope.Data is null)
{
var message = envelope?.Message ?? "The General Ledger service rejected the request.";
var message = (string.IsNullOrWhiteSpace(envelope?.Message) ? null : envelope!.Message)
?? DescribeProblem(response.Body)
?? "The General Ledger service rejected the request.";
var status = response.StatusCode is >= 400 and < 500 ? response.StatusCode : 502;
throw new DomainException(ErrorCodes.GlRequestFailed, message, status);
}
@@ -90,6 +97,43 @@ public sealed class GeneralLedgerService : IGeneralLedgerService
return envelope.Data;
}
/// <summary>
/// GL answers some failures (request-validation errors) with an ASP.NET ProblemDetails body rather than its own
/// envelope; turn that into a readable sentence instead of a generic "rejected the request".
/// </summary>
private static string? DescribeProblem(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return null;
var details = new List<string>();
foreach (var prop in root.EnumerateObject())
{
if (!string.Equals(prop.Name, "errors", StringComparison.OrdinalIgnoreCase) || prop.Value.ValueKind != JsonValueKind.Object) continue;
foreach (var field in prop.Value.EnumerateObject())
{
var text = field.Value.ValueKind == JsonValueKind.Array
? string.Join(" ", field.Value.EnumerateArray().Select(v => v.GetString()))
: field.Value.ToString();
details.Add($"{field.Name}: {text}");
}
}
if (details.Count > 0) return "The General Ledger service rejected the request — " + string.Join("; ", details);
if (root.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String)
return "The General Ledger service rejected the request — " + title.GetString();
}
catch (JsonException)
{
// Not JSON at all (a proxy page, say): fall through to the generic message.
}
return null;
}
private sealed class GlEnvelope<T>
{
public int StatusCode { get; set; }
+2
View File
@@ -12,6 +12,8 @@ public sealed class GlJournalEntryRequest
{
public int PeriodId { get; set; }
public DateOnly EntryDate { get; set; }
/// <summary>Free-text journal type (max 30) — e.g. OPENING_BALANCE. Left null by the flows that never set one.</summary>
public string? JournalType { get; set; }
public string? SourceModule { get; set; }
public string? Reference { get; set; }
public string? Narration { get; set; }
@@ -1,5 +1,6 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Search;
using ERPCore.Infra.Auth;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
@@ -9,7 +10,9 @@ namespace ERPCore.Services;
/// <summary>
/// Backs the global command palette (⌘K). Fans a single free-text term out across
/// Items, Purchase Orders, Sales Slips (invoices) and Employees in parallel and
/// returns a combined, capped result set — no persistence, read-only.
/// returns a combined, capped result set — no persistence, read-only. Each kind is searched only when
/// the caller's role may open that area (an administrator may open all of them), so the palette can't be used to read
/// staff names, invoices or purchase orders the person has no menu entry for.
/// </summary>
public sealed class GlobalSearchService : IGlobalSearchService
{
@@ -17,19 +20,28 @@ public sealed class GlobalSearchService : IGlobalSearchService
private readonly IRepository<PurchaseOrder> _purchaseOrders;
private readonly IRepository<SalesSlip> _salesSlips;
private readonly IRepository<Employee> _employees;
private readonly ICurrentUser _currentUser;
private readonly INavPermissionService _navPermissions;
public GlobalSearchService(
IRepository<Item> items,
IRepository<PurchaseOrder> purchaseOrders,
IRepository<SalesSlip> salesSlips,
IRepository<Employee> employees)
IRepository<Employee> employees,
ICurrentUser currentUser,
INavPermissionService navPermissions)
{
_items = items;
_purchaseOrders = purchaseOrders;
_salesSlips = salesSlips;
_employees = employees;
_currentUser = currentUser;
_navPermissions = navPermissions;
}
private async Task<bool> MayOpenAsync(string navCode, CancellationToken ct)
=> _currentUser.IsAdmin || await _navPermissions.HasAccessAsync(_currentUser.RoleId, navCode, ct);
public async Task<IReadOnlyList<GlobalSearchResultDto>> SearchAsync(string term, int takePerType = 6, CancellationToken ct = default)
{
var q = term?.Trim() ?? string.Empty;
@@ -39,28 +51,28 @@ public sealed class GlobalSearchService : IGlobalSearchService
// Sequential, not parallel: these share one scoped DbContext, which EF Core
// does not allow concurrent operations against.
var items = await _items.Query().AsNoTracking()
var items = !await MayOpenAsync("products", ct) ? new List<GlobalSearchResultDto>() : await _items.Query().AsNoTracking()
.Where(i => EF.Functions.Like(i.Sku, pattern) || EF.Functions.Like(i.Name, pattern))
.OrderBy(i => i.Name)
.Take(takePerType)
.Select(i => new GlobalSearchResultDto(GlobalSearchResultType.Item, i.ItemId, i.Sku, i.Name, i.Sku))
.ToListAsync(ct);
var pos = await _purchaseOrders.Query().AsNoTracking()
var pos = !await MayOpenAsync("procurement", ct) ? new List<GlobalSearchResultDto>() : await _purchaseOrders.Query().AsNoTracking()
.Where(p => EF.Functions.Like(p.DocNo, pattern))
.OrderByDescending(p => p.PoId)
.Take(takePerType)
.Select(p => new GlobalSearchResultDto(GlobalSearchResultType.PurchaseOrder, p.PoId, p.DocNo, p.DocNo, p.Status.ToString()))
.ToListAsync(ct);
var invoices = await _salesSlips.Query().AsNoTracking()
var invoices = !await MayOpenAsync("sales", ct) ? new List<GlobalSearchResultDto>() : await _salesSlips.Query().AsNoTracking()
.Where(s => EF.Functions.Like(s.SlipNo, pattern) || EF.Functions.Like(s.CustomerSnapshotName, pattern))
.OrderByDescending(s => s.SalesSlipId)
.Take(takePerType)
.Select(s => new GlobalSearchResultDto(GlobalSearchResultType.Invoice, s.SalesSlipId, s.SlipNo, s.SlipNo, s.CustomerSnapshotName))
.ToListAsync(ct);
var employees = await _employees.Query().AsNoTracking()
var employees = !await MayOpenAsync("hrm.employees", ct) ? new List<GlobalSearchResultDto>() : await _employees.Query().AsNoTracking()
.Where(e => EF.Functions.Like(e.FullName, pattern) || EF.Functions.Like(e.EmployeeCode, pattern))
.OrderBy(e => e.FullName)
.Take(takePerType)
+86 -11
View File
@@ -49,6 +49,7 @@ public sealed class GrnService : IGrnService
private readonly IGeneralLedgerService _gl;
private readonly INotificationService _notifications;
private readonly IGlAccountConfigService _glAccounts;
private readonly IProcurementConfigService _procurementConfig;
public GrnService(
IRepository<Grn> grns, IRepository<PurchaseOrder> pos, IRepository<PoLine> poLines,
@@ -56,7 +57,8 @@ public sealed class GrnService : IGrnService
IRepository<Bin> bins, IRepository<Vendor> vendors, IRepository<Batch> batches, IRepository<Serial> serials, IRepository<SerialMovement> serialMovements, ISerialTrackingService serialTracking,
IRepository<StockLayer> layers, IRepository<StockLedger> ledger, IRepository<TaxRate> taxRates,
IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IBranchConfigService branchConfig, IGrnConfigService grnConfig,
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts)
IUnitOfWork uow, IGeneralLedgerService gl, INotificationService notifications, IGlAccountConfigService glAccounts,
IProcurementConfigService procurementConfig)
{
_grns = grns;
_pos = pos;
@@ -81,17 +83,28 @@ public sealed class GrnService : IGrnService
_gl = gl;
_notifications = notifications;
_glAccounts = glAccounts;
_procurementConfig = procurementConfig;
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, CancellationToken ct = default)
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId,
DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
{
var q = _grns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(g => EF.Functions.Like(g.DocNo, $"%{term}%"));
// Doc no., the PO's doc no., or the vendor's name / code (the vendor is the table's first column).
var vendorIdsMatching = _vendors.Query()
.Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%"))
.Select(v => v.VendorId);
var poIdsMatching = _pos.Query()
.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%"))
.Select(p => p.PoId);
q = q.Where(g => EF.Functions.Like(g.DocNo, $"%{term}%")
|| vendorIdsMatching.Contains(g.VendorId)
|| (g.PoId != null && poIdsMatching.Contains(g.PoId.Value)));
}
if (status is not null) q = q.Where(g => g.Status == status);
if (poId is not null) q = q.Where(g => g.PoId == poId);
@@ -99,17 +112,76 @@ public sealed class GrnService : IGrnService
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
// Warehouses are shared across branches — GRN carries its own BranchId, set at creation.
if (branchId is not null) q = q.Where(g => g.BranchId == branchId);
// Created date, as a business (Colombo) day: [start of `from`, start of the day after `to`).
if (from is DateOnly fromDay)
{
var start = BusinessClock.StartOfDayUtc(fromDay);
q = q.Where(g => g.CreatedAt >= start);
}
if (to is DateOnly toDay)
{
var end = BusinessClock.StartOfDayUtc(toDay.AddDays(1));
q = q.Where(g => g.CreatedAt < end);
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
var rows = await ApplySort(q, query.Sort)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
.Select(g => new
{
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.BranchId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count, g.Lines.Sum(l => l.LineTotal),
g.PurchaseInvoiceId, g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null))
g.CreatedBy, g.CreatedAt, g.PostedAt, LineCount = g.Lines.Count, TotalValue = g.Lines.Sum(l => l.LineTotal),
g.PurchaseInvoiceId, PurchaseInvoiceNo = g.PurchaseInvoice != null ? g.PurchaseInvoice.DocNo : null
})
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
var flags = await _procurementConfig.GetFlagsAsync(ct);
var vendorIds = rows.Select(r => r.VendorId).Distinct().ToList();
var vendorMap = await _vendors.Query().AsNoTracking()
.Where(v => vendorIds.Contains(v.VendorId))
.ToDictionaryAsync(v => v.VendorId, ct);
var poIds = rows.Where(r => r.PoId != null).Select(r => r.PoId!.Value).Distinct().ToList();
var poDocNos = await _pos.Query().AsNoTracking()
.Where(p => poIds.Contains(p.PoId))
.ToDictionaryAsync(p => p.PoId, p => p.DocNo, ct);
var dtos = rows.Select(r => new GrnSummaryDto(
r.GrnId, r.DocNo, r.PoId, r.VendorId, r.WarehouseId, r.BranchId, r.Status,
r.CreatedBy, r.CreatedAt, r.PostedAt, r.LineCount, r.TotalValue,
r.PurchaseInvoiceId, r.PurchaseInvoiceNo,
r.PoId is int pid && poDocNos.TryGetValue(pid, out var poDoc) ? poDoc : null,
vendorMap.TryGetValue(r.VendorId, out var v)
? new Dtos.Procurement.PoVendorDto(
v.VendorId, v.Code, v.Name,
flags.VendorContactMode == VendorContactMode.Mobile ? v.Mobile : null,
flags.VendorContactMode == VendorContactMode.Email ? v.Email : null,
flags.VendorAddressEnabled ? v.Address : null)
: null)).ToList();
return PagedResponse<GrnSummaryDto>.Create(dtos, query.Page, query.PageSize, total);
}
/// <summary>
/// Server-side sort so paging stays correct: <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading
/// <c>-</c> for descending. Default (and any unknown key) is newest first; GrnId is the tiebreaker.
/// </summary>
private IQueryable<Grn> ApplySort(IQueryable<Grn> q, string? sort)
{
var key = (sort ?? "-created").Trim();
var desc = key.StartsWith('-');
key = key.TrimStart('-', '+').ToLowerInvariant();
IOrderedQueryable<Grn> ordered = key switch
{
"vendor" => desc
? q.OrderByDescending(g => _vendors.Query().Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault())
: q.OrderBy(g => _vendors.Query().Where(v => v.VendorId == g.VendorId).Select(v => v.Name).FirstOrDefault()),
"total" => desc ? q.OrderByDescending(g => g.Lines.Sum(l => l.LineTotal)) : q.OrderBy(g => g.Lines.Sum(l => l.LineTotal)),
"status" => desc ? q.OrderByDescending(g => g.Status) : q.OrderBy(g => g.Status),
"created" => desc ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
_ => q.OrderByDescending(g => g.CreatedAt)
};
return ordered.ThenByDescending(g => g.GrnId);
}
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
@@ -132,8 +204,6 @@ public sealed class GrnService : IGrnService
if (!await _warehouses.Query().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {request.WarehouseId} does not exist.", 422);
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
// Resolve vendor + PO context.
PurchaseOrder? po = null;
int vendorId;
@@ -155,6 +225,10 @@ public sealed class GrnService : IGrnService
vendorId = request.VendorId.Value;
}
// The branch is the purchase order's own (it was fixed when the PO was raised), so receiving against a PO never
// asks again; only a direct receipt names its branch.
var branchId = po?.BranchId ?? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var (allowDiscounts, allowVat) = await _grnConfig.GetLineOptionsAsync(ct);
if (!allowDiscounts && request.Lines.Any(l => l.DiscountPct > 0m))
throw new DomainException(ErrorCodes.Validation, "Discounts are turned off in GRN Configuration.", 422);
@@ -314,7 +388,8 @@ public sealed class GrnService : IGrnService
var entry = await _fifo.PostLedgerAsync(
line.ItemId, grn.WarehouseId, line.BinId, line.BatchId, null, actor,
Direction.In, qtyBase, unitCostBase, bal, DocumentTypes.Grn, grn.GrnId, now, token);
Direction.In, qtyBase, unitCostBase, bal, DocumentTypes.Grn, grn.GrnId, now, token,
postGlJournal: false); // the GRN posts its own balanced journal below (with VAT)
ledgerRefs.Add(entry);
if (line.PoLineId is not null)
@@ -0,0 +1,12 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>Access configuration (the default role for people who have none). Singleton.</summary>
public interface IAccessConfigService
{
Task<ETagged<AccessConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<AccessConfigDto>> UpdateAsync(UpdateAccessConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
}
@@ -63,11 +63,17 @@ public interface IFifoCostingService
/// would drift from the pool (FR-MFG-13). Also used by leftover and cancel returns, whose
/// value is the exact consumed residual. Omit for every rate-driven movement.
/// </param>
/// <param name="postGlJournal">
/// Also post the generic per-movement "STOCK" journal (Dr Inventory / Cr Clearing, or the reverse for an issue).
/// Pass <c>false</c> when the calling document posts its own complete journal (GRN, sales invoice, purchase
/// return, adjustment, sales return): otherwise the inventory movement reaches the General Ledger twice.
/// Production runs and transfers have no document journal of their own and keep the default.
/// </param>
Task<StockLedger> PostLedgerAsync(
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default,
decimal? valueOverride = null);
decimal? valueOverride = null, bool postGlJournal = true);
/// <summary>Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse.</summary>
Task<decimal> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default);
@@ -8,7 +8,8 @@ namespace ERPCore.Services.Interfaces;
public interface IGrnService
{
Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, CancellationToken ct = default);
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId,
DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
@@ -3,28 +3,28 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Resolves whether a role can reach a given nav module — the same
/// <c>RolePermission</c> data <c>/auth/me</c> already reduces to <c>navCodes</c>
/// (<see cref="RoleService.GetMeAsync"/>), reused here to gate the API itself instead
/// of only the sidebar (docs/02-SECURITY.md AR-01).
/// "What may this role do?" — the permission data the sidebar, <c>/auth/me</c> and the API policies all share.
/// Keyed by the role's ID (stable under renames), read from the ERP's own tables, and cached per role until it
/// is explicitly invalidated: whoever edits a role's permissions, status or deletes it calls
/// <see cref="Invalidate"/> so the change applies on the next request.
/// </summary>
public interface INavPermissionService
{
/// <summary>
/// True when <paramref name="roleCode"/> is granted <paramref name="navCode"/> itself,
/// or any sub-nav item nested under it (code prefixed <c>"{navCode}."</c>). Does not
/// special-case Admin — callers (e.g. <see cref="Infra.Auth.NavAuthorizationHandler"/>)
/// decide whether Admin bypasses this entirely.
/// Whether the role may reach <paramref name="navCode"/>: it holds that nav item, or any child of it
/// (<c>"procurement"</c> matches a grant of <c>"procurement.vendors"</c>). False for a missing, unknown or inactive role.
/// </summary>
Task<bool> HasAccessAsync(string? roleCode, string navCode, CancellationToken ct = default);
Task<bool> HasAccessAsync(int? roleId, string navCode, CancellationToken ct = default);
/// <summary>
/// True when <paramref name="roleCode"/>'s grant on <paramref name="navCode"/> (exact
/// code match — no sub-nav prefix expansion, unlike <see cref="HasAccessAsync"/>, since
/// an action applies to one specific module's permission row) includes
/// <paramref name="action"/> in its <c>RolePermission.Actions</c> bitmask. False if the
/// role has no permission row for that exact nav code at all. Does not special-case
/// Admin — see <see cref="HasAccessAsync"/>.
/// Whether the role holds a specific <paramref name="action"/> (Create, Delete, Approve, …) on exactly that nav code
/// — no parent/child expansion, unlike <see cref="HasAccessAsync"/>. The caller handles Admin before asking.
/// </summary>
Task<bool> HasActionAsync(string? roleCode, string navCode, RolePermissionAction action, CancellationToken ct = default);
Task<bool> HasActionAsync(int? roleId, string navCode, RolePermissionAction action, CancellationToken ct = default);
/// <summary>Every nav code the role holds, with its granted actions. Empty for a missing, unknown or inactive role.</summary>
Task<IReadOnlyDictionary<string, RolePermissionAction>> GetPermissionsAsync(int roleId, CancellationToken ct = default);
/// <summary>Forgets what is cached for one role, so its next lookup re-reads the database.</summary>
void Invalidate(int roleId);
}
@@ -0,0 +1,17 @@
using ERPCore.Dtos.OpeningBalances;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Opening balances. Each kind is posted once: one balanced journal in the General Ledger
/// (journal type OPENING_BALANCE) against the "Opening Balance Equity" account, plus a local
/// <see cref="Domain.Entities.OpeningBalanceRun"/> that makes a second posting impossible.
/// </summary>
public interface IOpeningBalanceService
{
/// <summary>The cash and bank accounts to enter amounts for, or the posted record if already done.</summary>
Task<CashBankOpeningStateDto> GetCashBankAsync(CancellationToken ct = default);
/// <summary>Posts the cash and bank opening balances. 409 OPENING_BALANCE_ALREADY_POSTED if it was done before.</summary>
Task<OpeningBalanceRunDto> PostCashBankAsync(PostCashBankOpeningRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,17 @@
using ERPCore.Dtos.OpeningBalances;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Opening stock: items already on the shelf when the system starts. Each line becomes a FIFO layer
/// at the given unit cost, and ONE General Ledger journal debits Inventory against Opening Balance Equity.
/// Stock can be loaded in several runs, but an item in a warehouse can be opened only once.
/// </summary>
public interface IOpeningStockService
{
Task<IReadOnlyList<OpeningStockRunSummaryDto>> ListAsync(CancellationToken ct = default);
Task<OpeningStockRunDto?> GetAsync(int runId, CancellationToken ct = default);
Task<OpeningStockRunDto> PostAsync(PostOpeningStockRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Dtos.Config;
namespace ERPCore.Services.Interfaces;
/// <summary>Procurement configuration business logic. Singleton.</summary>
public interface IProcurementConfigService
{
Task<ETagged<ProcurementConfigDto>> GetAsync(CancellationToken ct = default);
Task<ETagged<ProcurementConfigDto>> UpdateAsync(
UpdateProcurementConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
/// <summary>The current flags for server-side gating; defaults (all off) when the row is missing.</summary>
Task<ProcurementConfigDto> GetFlagsAsync(CancellationToken ct = default);
}
@@ -8,8 +8,13 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Purchase-order business logic (docs/11 §3.3; FR-PROC-03..07).</summary>
public interface IPurchaseOrderService
{
/// <param name="from">Created on or after this business day.</param>
/// <param name="to">Created on or before this business day.</param>
/// <remarks><c>query.Sort</c>: <c>created</c> (default, newest first), <c>vendor</c>, <c>total</c> or <c>status</c>;
/// a leading <c>-</c> flips the direction (<c>-created</c> is the default's own direction, <c>created</c> is oldest first).</remarks>
Task<PagedResponse<PurchaseOrderSummaryDto>> ListAsync(
PageQuery query, PurchaseOrderStatus? status, int? vendorId, CancellationToken ct = default);
PageQuery query, PurchaseOrderStatus? status, int? vendorId, DateOnly? from = null, DateOnly? to = null,
CancellationToken ct = default);
Task<ETagged<PurchaseOrderDto>?> GetAsync(int poId, CancellationToken ct = default);
Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default);
@@ -24,5 +24,6 @@ public interface IRoleService
Task<RolePermissionsDto> AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default);
/// <summary>Resolves the nav codes a role (by AuthHex `RoleCode` claim) may see. Used by `GET /auth/me`.</summary>
Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default);
/// <summary>The signed-in person's role, permissions (nav code -> granted actions bitmask) and branch, from the ERP's own tables.</summary>
Task<MeResponseDto> GetMeAsync(CancellationToken ct = default);
}
@@ -12,7 +12,8 @@ namespace ERPCore.Services.Interfaces;
/// </summary>
public interface IUserManagementService
{
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default);
/// <param name="isAdmin">True = only users whose role is the administrator role (by flag, not by name); false = everyone else.</param>
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, bool? isAdmin = null, CancellationToken ct = default);
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
@@ -48,4 +49,11 @@ public interface IUserManagementService
/// touch ERPCore's local `users` table at all — only AuthHex's separate identity store.
/// </summary>
Task<DeleteAuthHexUserResult> PurgeAuthHexIdentityAsync(PurgeAuthHexIdentityRequest request, CancellationToken ct = default);
/// <summary>
/// One-time migration helper: for every user that has no role yet, reads the role AuthHex still holds for them and
/// stores the matching ERP role in <c>users.RoleId</c>. Run it BEFORE AuthHex stops returning roles; users it cannot
/// map are reported, never guessed.
/// </summary>
Task<BackfillRolesResultDto> BackfillRolesFromAuthHexAsync(CancellationToken ct = default);
}
@@ -8,15 +8,15 @@ using Microsoft.Extensions.Caching.Memory;
namespace ERPCore.Services;
/// <summary>
/// See <see cref="INavPermissionService"/>. A role's permitted nav-code set changes
/// rarely (an admin editing Roles → Permissions) but is looked up on every gated
/// request, so it's cached per role code for a short window rather than re-querying
/// <c>RolePermission</c> every call — a Roles→Permissions edit takes up to
/// <see cref="CacheTtl"/> to take effect for already-issued tokens of that role.
/// See <see cref="INavPermissionService"/>. A role's permission set changes rarely (an admin editing
/// Roles → Permissions) but is read on every gated request, so it is cached per role ID. Edits evict the entry
/// explicitly (<see cref="Invalidate"/>), so a change is live on the next request; the short
/// <see cref="CacheTtl"/> is only a backstop for a change made some other way (a direct database edit, another
/// server instance).
/// </summary>
public sealed class NavPermissionService : INavPermissionService
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(2);
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(1);
private readonly IRepository<Role> _roles;
private readonly IRepository<RolePermission> _rolePermissions;
@@ -29,85 +29,56 @@ public sealed class NavPermissionService : INavPermissionService
_cache = cache;
}
public async Task<bool> HasAccessAsync(string? roleCode, string navCode, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(roleCode)) return false;
private static string Key(int roleId) => $"nav-permissions:{roleId}";
var codes = await GetNavCodesAsync(roleCode.Trim(), ct);
return codes.Contains(navCode) || codes.Any(c => c.StartsWith(navCode + ".", StringComparison.OrdinalIgnoreCase));
public async Task<bool> HasAccessAsync(int? roleId, string navCode, CancellationToken ct = default)
{
if (roleId is null) return false;
var granted = await GetPermissionsAsync(roleId.Value, ct);
return granted.ContainsKey(navCode)
|| granted.Keys.Any(c => c.StartsWith(navCode + ".", StringComparison.OrdinalIgnoreCase));
}
public async Task<bool> HasActionAsync(string? roleCode, string navCode, RolePermissionAction action, CancellationToken ct = default)
public async Task<bool> HasActionAsync(int? roleId, string navCode, RolePermissionAction action, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(roleCode)) return false;
if (roleId is null) return false;
var actionsByCode = await GetActionsByNavCodeAsync(roleCode.Trim(), ct);
return actionsByCode.TryGetValue(navCode, out var granted) && granted.HasFlag(action);
var granted = await GetPermissionsAsync(roleId.Value, ct);
return granted.TryGetValue(navCode, out var actions) && actions.HasFlag(action);
}
private async Task<HashSet<string>> GetNavCodesAsync(string roleCode, CancellationToken ct)
public async Task<IReadOnlyDictionary<string, RolePermissionAction>> GetPermissionsAsync(int roleId, CancellationToken ct = default)
{
var cacheKey = $"nav-permission-codes:{roleCode.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out HashSet<string>? cached) && cached is not null)
if (_cache.TryGetValue(Key(roleId), out Dictionary<string, RolePermissionAction>? cached) && cached is not null)
return cached;
// Same shape as RoleService.GetMeAsync's navCodes computation — mirrored rather
// than shared because that method also carries the unauthenticated/no-role
// early-return branches this lookup doesn't need.
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == roleCode.ToLower(), ct);
var byCode = new Dictionary<string, RolePermissionAction>(StringComparer.OrdinalIgnoreCase);
var codes = role is null
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: (await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
.Include(rp => rp.Permission!).ThenInclude(p => p.NavItem)
.Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem)
.Select(rp => rp.Permission!)
.ToListAsync(ct))
.Select(p => p.NavItem?.Code ?? p.SubNavItem?.Code)
.Where(c => c is not null)
.Select(c => c!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
_cache.Set(cacheKey, codes, CacheTtl);
return codes;
}
private async Task<Dictionary<string, RolePermissionAction>> GetActionsByNavCodeAsync(string roleCode, CancellationToken ct)
{
var cacheKey = $"nav-permission-actions:{roleCode.ToLowerInvariant()}";
if (_cache.TryGetValue(cacheKey, out Dictionary<string, RolePermissionAction>? cached) && cached is not null)
return cached;
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == roleCode.ToLower(), ct);
var actionsByCode = new Dictionary<string, RolePermissionAction>(StringComparer.OrdinalIgnoreCase);
if (role is not null)
// An inactive (or missing) role grants nothing.
var active = await _roles.Query().AsNoTracking()
.AnyAsync(r => r.RoleId == roleId && r.Status == EntityStatus.Active, ct);
if (active)
{
var rows = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
.Include(rp => rp.Permission!).ThenInclude(p => p.NavItem)
.Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem)
.Select(rp => new { rp.Actions, rp.Permission!.NavItem, rp.Permission!.SubNavItem })
.Where(rp => rp.RoleId == roleId)
.Select(rp => new { rp.Actions, NavCode = rp.Permission!.NavItem!.Code, SubCode = rp.Permission!.SubNavItem!.Code })
.ToListAsync(ct);
foreach (var row in rows)
{
var code = row.NavItem?.Code ?? row.SubNavItem?.Code;
var code = row.NavCode ?? row.SubCode;
if (code is null) continue;
// A role could in principle hold two permission rows resolving to the same
// code (shouldn't happen given one Permission per NavItem/SubNavItem, but
// OR rather than overwrite so a duplicate never silently narrows access).
actionsByCode[code] = actionsByCode.TryGetValue(code, out var existing)
? existing | row.Actions
: row.Actions;
// A role could in principle hold two permission rows resolving to the same code (not expected, since
// there is one Permission per nav item). OR rather than overwrite, so a duplicate never narrows access.
byCode[code] = byCode.TryGetValue(code, out var existing) ? existing | row.Actions : row.Actions;
}
}
_cache.Set(cacheKey, actionsByCode, CacheTtl);
return actionsByCode;
_cache.Set(Key(roleId), byCode, CacheTtl);
return byCode;
}
public void Invalidate(int roleId) => _cache.Remove(Key(roleId));
}
@@ -0,0 +1,178 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Opening balances for cash and bank accounts: ONE balanced General Ledger journal (journal type
/// OPENING_BALANCE) — a debit per account with an amount, and the offsetting credit to the
/// "Opening Balance Equity" account — plus a local <see cref="OpeningBalanceRun"/> row.
/// <para>
/// The General Ledger does not stop a second opening entry, so the run row is the guard. It is inserted
/// (and saved) BEFORE the GL is called, inside the transaction: its unique index on the kind makes two
/// concurrent attempts queue up, and the second fails before it can post anything. The accepted
/// failure mode is the same as the other posting services (<c>BankDepositService</c>): if the GL post
/// succeeds and the local commit then fails, a journal exists that the run row does not reference.
/// </para>
/// </summary>
public sealed class OpeningBalanceService : IOpeningBalanceService
{
private const string BaseCurrency = "LKR";
private const string SourceModule = "OPENING";
private const string JournalType = "OPENING_BALANCE";
private readonly IRepository<OpeningBalanceRun> _runs;
private readonly IGeneralLedgerService _gl;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public OpeningBalanceService(
IRepository<OpeningBalanceRun> runs, IGeneralLedgerService gl, ICurrentUser currentUser, IUnitOfWork uow)
{
_runs = runs;
_gl = gl;
_currentUser = currentUser;
_uow = uow;
}
public async Task<CashBankOpeningStateDto> GetCashBankAsync(CancellationToken ct = default)
{
var run = await _runs.Query().AsNoTracking().Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.Kind == OpeningBalanceKind.CashBank, ct);
var accounts = await _gl.ListBankAccountsAsync(ct);
var posted = run?.Lines.ToDictionary(l => l.AccountCode, l => l.Amount) ?? new Dictionary<string, decimal>();
var rows = accounts
.OrderBy(a => a.AccountType).ThenBy(a => a.AccountName)
.Select(a => new OpeningCashBankAccountDto(
a.AccountType, a.AccountName, a.BankName, a.AccountNumber, a.GlAccountCode, a.CurrencyCode,
posted.TryGetValue(a.GlAccountCode, out var amount) ? amount : null))
.ToList();
return new CashBankOpeningStateDto(run is null ? null : Map(run), rows, BaseCurrency);
}
public async Task<OpeningBalanceRunDto> PostCashBankAsync(PostCashBankOpeningRequest request, CancellationToken ct = default)
{
var cutover = request.CutoverDate!.Value;
if (cutover > DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1))
throw new DomainException(ErrorCodes.Validation, "The opening date cannot be in the future.", 400);
if (await AlreadyPostedAsync(ct))
throw AlreadyPostedError();
// ---- validate the lines against the real cash/bank accounts
var accounts = (await _gl.ListBankAccountsAsync(ct)).ToDictionary(a => a.GlAccountCode);
var seen = new HashSet<string>();
var lines = new List<(GlBankAccount Account, decimal Amount)>();
foreach (var input in request.Lines)
{
var code = input.GlAccountCode.Trim();
if (!seen.Add(code))
throw new DomainException(ErrorCodes.Validation, $"Account {code} appears more than once.", 400);
if (!accounts.TryGetValue(code, out var account))
throw new DomainException(ErrorCodes.Validation, $"{code} is not a cash or bank account.", 422);
if (decimal.Round(input.Amount, 2) != input.Amount)
throw new DomainException(ErrorCodes.Validation, $"The amount for {account.AccountName} can have at most 2 decimal places.", 400);
if (input.Amount == 0) continue;
if (!string.Equals(account.CurrencyCode, BaseCurrency, StringComparison.OrdinalIgnoreCase))
throw new DomainException(ErrorCodes.Validation,
$"{account.AccountName} is in {account.CurrencyCode}; only {BaseCurrency} accounts can take an opening balance here.", 422);
lines.Add((account, input.Amount));
}
if (lines.Count == 0)
throw new DomainException(ErrorCodes.Validation, "Enter an amount for at least one cash or bank account.", 400);
var net = lines.Sum(l => l.Amount);
// ---- everything the GL needs, resolved before the transaction opens
var equityCode = await OpeningEquityAccount.EnsureAsync(_gl, ct);
var period = await _gl.GetPeriodByDateAsync(cutover, ct);
var narration = $"Opening balances — cash and bank as at {cutover:yyyy-MM-dd}";
var journalLines = new List<GlJournalEntryLineRequest>();
foreach (var (account, amount) in lines)
{
var memo = $"Opening balance — {account.AccountName}";
journalLines.Add(amount > 0
? new GlJournalEntryLineRequest(account.GlAccountCode, amount, 0m, memo)
: new GlJournalEntryLineRequest(account.GlAccountCode, 0m, -amount, memo));
}
if (net != 0)
{
const string equityMemo = "Opening balance equity";
journalLines.Add(net > 0
? new GlJournalEntryLineRequest(equityCode, 0m, net, equityMemo)
: new GlJournalEntryLineRequest(equityCode, -net, 0m, equityMemo));
}
try
{
var run = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new OpeningBalanceRun
{
Kind = OpeningBalanceKind.CashBank,
OnceKey = nameof(OpeningBalanceKind.CashBank),
CutoverDate = cutover,
TotalAmount = net,
Currency = BaseCurrency,
PostedBy = _currentUser.AuditUserId,
PostedAt = DateTime.UtcNow,
Lines = lines.Select(l => new OpeningBalanceLine
{
AccountCode = l.Account.GlAccountCode,
AccountName = l.Account.AccountName,
Amount = l.Amount
}).ToList()
};
await _runs.AddAsync(entity, token);
// Claim the once-only slot first: a concurrent second attempt blocks on the unique index here
// and then fails, before it can reach the GL.
await _uow.SaveChangesAsync(token);
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = cutover,
JournalType = JournalType,
SourceModule = SourceModule,
Reference = "OPENING-CASH-BANK",
Narration = narration,
Lines = journalLines
}, token);
entity.GlJournalNo = posted.JournalNo;
return entity;
}, ct);
return Map(run);
}
catch (DbUpdateException)
{
// The unique index on Kind refused a concurrent second attempt. Anything else is a real failure.
if (await AlreadyPostedAsync(ct)) throw AlreadyPostedError();
throw;
}
}
private Task<bool> AlreadyPostedAsync(CancellationToken ct) =>
_runs.Query().AsNoTracking().AnyAsync(r => r.OnceKey == nameof(OpeningBalanceKind.CashBank), ct);
private static DomainException AlreadyPostedError() => new(
ErrorCodes.OpeningBalanceAlreadyPosted,
"The cash and bank opening balances have already been posted. Correct them with a journal entry instead.", 409);
private static OpeningBalanceRunDto Map(OpeningBalanceRun r) => new(
r.OpeningBalanceRunId, r.Kind.ToString(), r.CutoverDate, r.GlJournalNo, r.TotalAmount, r.Currency,
r.PostedAt, r.PostedBy,
r.Lines.OrderBy(l => l.AccountCode).Select(l => new OpeningBalanceLineDto(l.AccountCode, l.AccountName, l.Amount)).ToList());
}
@@ -0,0 +1,61 @@
using System.Text.Json;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Services;
/// <summary>
/// The postable "Opening Balance Equity" account both opening balances (cash/bank and stock) are offset
/// against. Looked up in the GL's chart by name and created on first use; account codes are assigned by the
/// GL (never chosen by the caller), so the code is read back from the response.
/// </summary>
internal static class OpeningEquityAccount
{
private const string AccountName = "Opening Balance Equity";
private const short EquityAccountTypeId = 3; // GL account type 3 = Equity
private const string BaseCurrency = "LKR";
public static async Task<string> EnsureAsync(IGeneralLedgerService gl, CancellationToken ct)
{
var accounts = await gl.ListAccountsAsync(ct);
var existing = accounts.FirstOrDefault(a =>
a.AccountTypeId == EquityAccountTypeId && a.IsPostable && a.IsActive
&& string.Equals(a.AccountName, AccountName, StringComparison.OrdinalIgnoreCase));
if (existing is not null) return existing.AccountCode;
var payload = JsonSerializer.SerializeToUtf8Bytes(new
{
accountName = AccountName,
accountTypeId = EquityAccountTypeId,
isControlAccount = false,
isPostable = true,
currencyCode = BaseCurrency
});
using var body = new MemoryStream(payload);
var response = await gl.ForwardAsync(HttpMethod.Post, "accounts", null, "application/json", body, ct);
string? code = null;
string? message = null;
try
{
using var doc = JsonDocument.Parse(response.Body);
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (prop.Name.Equals("message", StringComparison.OrdinalIgnoreCase)) message = prop.Value.GetString();
if (prop.Name.Equals("data", StringComparison.OrdinalIgnoreCase) && prop.Value.ValueKind == JsonValueKind.Object)
foreach (var d in prop.Value.EnumerateObject())
if (d.Name.Equals("accountCode", StringComparison.OrdinalIgnoreCase)) code = d.Value.GetString();
}
}
catch (JsonException)
{
// fall through to the failure below
}
if (response.StatusCode is < 200 or >= 300 || string.IsNullOrWhiteSpace(code))
throw new DomainException(ErrorCodes.GlRequestFailed,
message ?? $"Could not create the \"{AccountName}\" account in the General Ledger.",
response.StatusCode is >= 400 and < 500 ? response.StatusCode : 502);
return code;
}
}
@@ -0,0 +1,238 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.OpeningBalances;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Gl;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Opening stock. Each line becomes an inbound FIFO layer at the given unit cost plus a stock-ledger row
/// (source <c>OPN</c>), and ONE General Ledger journal debits Inventory for the total against
/// "Opening Balance Equity".
/// <para>
/// <b>Why this does not go through <c>StockMutator</c> / <c>FifoCostingService.PostLedgerAsync</c>.</b> That path
/// posts its own <c>STOCK</c> journal (Dr Inventory / Cr <i>Clearing</i>) for every ledger row. Opening stock
/// must credit Opening Balance Equity instead, and posting both would debit Inventory twice. So the layer and the
/// ledger row are written directly here (the same two writes, minus the journal) and this service posts the one
/// journal itself. The local <c>JournalEntryStub</c> rows the journal-listing screen reads are still written.
/// </para>
/// <para>
/// <b>One opening per item and warehouse.</b> An item that already has any stock movement in that warehouse is
/// refused — opening stock on top of real movements would be a hidden adjustment. Stock may be loaded in
/// several runs (a warehouse at a time, say). Two simultaneous runs naming the same item could both pass that
/// check; this is an admin-only, rare operation and is not serialised.
/// </para>
/// Untracked items only: serial- and batch-tracked items need per-unit identities, which a plain quantity cannot give.
/// </summary>
public sealed class OpeningStockService : IOpeningStockService
{
private const string BaseCurrency = "LKR";
private const string SourceDocType = "OPN";
private const string SourceModule = "OPENING";
private const string JournalType = "OPENING_BALANCE";
private readonly IRepository<OpeningBalanceRun> _runs;
private readonly IRepository<Item> _items;
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<StockLedger> _ledger;
private readonly IRepository<JournalEntryStub> _journal;
private readonly IFifoCostingService _fifo;
private readonly IGeneralLedgerService _gl;
private readonly IGlAccountConfigService _glAccounts;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public OpeningStockService(
IRepository<OpeningBalanceRun> runs, IRepository<Item> items, IRepository<Warehouse> warehouses,
IRepository<StockLedger> ledger, IRepository<JournalEntryStub> journal, IFifoCostingService fifo,
IGeneralLedgerService gl, IGlAccountConfigService glAccounts, ICurrentUser currentUser, IUnitOfWork uow)
{
_runs = runs;
_items = items;
_warehouses = warehouses;
_ledger = ledger;
_journal = journal;
_fifo = fifo;
_gl = gl;
_glAccounts = glAccounts;
_currentUser = currentUser;
_uow = uow;
}
public async Task<IReadOnlyList<OpeningStockRunSummaryDto>> ListAsync(CancellationToken ct = default)
{
var rows = await _runs.Query().AsNoTracking()
.Where(r => r.Kind == OpeningBalanceKind.Stock)
.OrderByDescending(r => r.OpeningBalanceRunId)
.Select(r => new
{
r.OpeningBalanceRunId, r.CutoverDate, r.GlJournalNo, r.TotalAmount, r.PostedAt, r.PostedBy,
LineCount = r.StockLines.Count
})
.ToListAsync(ct);
return rows.Select(r => new OpeningStockRunSummaryDto(
r.OpeningBalanceRunId, r.CutoverDate, r.GlJournalNo, r.TotalAmount, r.LineCount, r.PostedAt, r.PostedBy)).ToList();
}
public async Task<OpeningStockRunDto?> GetAsync(int runId, CancellationToken ct = default)
{
var run = await _runs.Query().AsNoTracking()
.Include(r => r.StockLines).ThenInclude(l => l.Item)
.Include(r => r.StockLines).ThenInclude(l => l.Warehouse)
.FirstOrDefaultAsync(r => r.OpeningBalanceRunId == runId && r.Kind == OpeningBalanceKind.Stock, ct);
return run is null ? null : Map(run);
}
public async Task<OpeningStockRunDto> PostAsync(PostOpeningStockRequest request, CancellationToken ct = default)
{
var cutover = request.CutoverDate!.Value;
if (cutover > BusinessClock.Today.AddDays(1))
throw new DomainException(ErrorCodes.Validation, "The opening date cannot be in the future.", 400);
// ---- validate every line before anything is written
var itemIds = request.Lines.Select(l => l.ItemId).Distinct().ToList();
var warehouseIds = request.Lines.Select(l => l.WarehouseId).Distinct().ToList();
var items = await _items.Query().AsNoTracking().Where(i => itemIds.Contains(i.ItemId)).ToDictionaryAsync(i => i.ItemId, ct);
var warehouses = await _warehouses.Query().AsNoTracking().Where(w => warehouseIds.Contains(w.WarehouseId)).ToDictionaryAsync(w => w.WarehouseId, ct);
var seen = new HashSet<(int, int)>();
foreach (var line in request.Lines)
{
if (!items.TryGetValue(line.ItemId, out var item))
throw new DomainException(ErrorCodes.Validation, $"Item {line.ItemId} does not exist.", 422);
if (!warehouses.ContainsKey(line.WarehouseId))
throw new DomainException(ErrorCodes.Validation, $"Warehouse {line.WarehouseId} does not exist.", 422);
if (!seen.Add((line.ItemId, line.WarehouseId)))
throw new DomainException(ErrorCodes.Validation, $"{item.Sku} appears more than once for the same warehouse.", 400);
if (item.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"{item.Sku} is inactive.", 422);
if (item.StockNature != StockNature.Stocked)
throw new DomainException(ErrorCodes.Validation, $"{item.Sku} is not a stocked item.", 422);
if (item.TrackingMode != TrackingMode.None)
throw new DomainException(ErrorCodes.Validation,
$"{item.Sku} is {item.TrackingMode.ToString().ToLowerInvariant()}-tracked; opening stock supports untracked items only.", 422);
if (line.Qty <= 0 || decimal.Round(line.Qty, 4) != line.Qty)
throw new DomainException(ErrorCodes.Validation, $"The quantity for {item.Sku} must be more than 0, with at most 4 decimals.", 400);
if (line.UnitCost < 0 || decimal.Round(line.UnitCost, 4) != line.UnitCost)
throw new DomainException(ErrorCodes.Validation, $"The unit cost for {item.Sku} must be 0 or more, with at most 4 decimals.", 400);
}
// ---- an item/warehouse that already has movements is not "opening" any more
var existing = await _ledger.Query().AsNoTracking()
.Where(l => itemIds.Contains(l.ItemId) && warehouseIds.Contains(l.WarehouseId))
.Select(l => new { l.ItemId, l.WarehouseId })
.Distinct()
.ToListAsync(ct);
var clashes = existing.Where(e => seen.Contains((e.ItemId, e.WarehouseId))).ToList();
if (clashes.Count > 0)
{
var names = string.Join(", ", clashes.Take(5).Select(c => $"{items[c.ItemId].Sku} @ {warehouses[c.WarehouseId].Code}"));
var more = clashes.Count > 5 ? $" and {clashes.Count - 5} more" : "";
throw new DomainException(ErrorCodes.Conflict,
$"Already has stock movements, so it can't be opened again: {names}{more}. Use a stock adjustment instead.", 409);
}
// ---- everything the GL needs, resolved before the transaction opens
var glCodes = await _glAccounts.GetCodesAsync(ct);
var inventoryCode = glCodes[GlAccountRole.Inventory];
var equityCode = await OpeningEquityAccount.EnsureAsync(_gl, ct);
var period = await _gl.GetPeriodByDateAsync(cutover, ct);
// An instant whose business (Colombo) date is the cutover date, so the layers sort as the oldest stock.
var receiptDate = DateTime.SpecifyKind(cutover.ToDateTime(new TimeOnly(6, 30)), DateTimeKind.Utc);
var actor = _currentUser.AuditUserId;
var run = await _uow.ExecuteInTransactionAsync(async token =>
{
var entity = new OpeningBalanceRun
{
Kind = OpeningBalanceKind.Stock,
CutoverDate = cutover,
Currency = BaseCurrency,
PostedBy = actor,
PostedAt = DateTime.UtcNow
};
await _runs.AddAsync(entity, token);
await _uow.SaveChangesAsync(token); // the run id is the ledger rows' source document id
decimal total = 0m;
foreach (var line in request.Lines)
{
var value = Math.Round(line.Qty * line.UnitCost, 4, MidpointRounding.AwayFromZero);
total += value;
await _fifo.CreateInboundLayerAsync(line.ItemId, line.WarehouseId, null, null, null, line.Qty, line.UnitCost, receiptDate, token);
await _ledger.AddAsync(new StockLedger
{
ItemId = line.ItemId,
WarehouseId = line.WarehouseId,
UserId = actor,
Direction = Direction.In,
QtyBase = line.Qty,
UnitCost = line.UnitCost,
Value = value,
RunningBalance = line.Qty, // nothing was there before (checked above)
SourceDocType = SourceDocType,
SourceDocId = entity.OpeningBalanceRunId,
CreatedAt = receiptDate
}, token);
await _journal.AddAsync(new JournalEntryStub
{
SourceDocType = SourceDocType,
SourceDocId = entity.OpeningBalanceRunId,
DebitAccount = inventoryCode,
CreditAccount = equityCode,
Amount = value
}, token);
entity.StockLines.Add(new OpeningStockLine
{
ItemId = line.ItemId,
WarehouseId = line.WarehouseId,
Qty = line.Qty,
UnitCost = line.UnitCost,
Value = value
});
}
entity.TotalAmount = total;
// A run of zero-cost items has nothing to post: a journal needs a non-zero amount.
if (total > 0)
{
var posted = await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
{
PeriodId = period.PeriodId,
EntryDate = cutover,
JournalType = JournalType,
SourceModule = SourceModule,
Reference = $"OPENING-STOCK-{entity.OpeningBalanceRunId}",
Narration = $"Opening stock as at {cutover:yyyy-MM-dd} ({request.Lines.Count} line(s))",
Lines =
[
new GlJournalEntryLineRequest(inventoryCode, total, 0m, "Opening stock"),
new GlJournalEntryLineRequest(equityCode, 0m, total, "Opening balance equity")
]
}, token);
entity.GlJournalNo = posted.JournalNo;
}
return entity;
}, ct);
return (await GetAsync(run.OpeningBalanceRunId, ct))!;
}
private static OpeningStockRunDto Map(OpeningBalanceRun r) => new(
r.OpeningBalanceRunId, r.CutoverDate, r.GlJournalNo, r.TotalAmount, r.StockLines.Count, r.PostedAt, r.PostedBy,
r.StockLines
.OrderBy(l => l.Item?.Sku).ThenBy(l => l.Warehouse?.Code)
.Select(l => new OpeningStockLineDto(
l.ItemId, l.Item?.Sku ?? $"#{l.ItemId}", l.Item?.Name ?? string.Empty,
l.WarehouseId, l.Warehouse?.Code ?? $"#{l.WarehouseId}", l.Qty, l.UnitCost, l.Value))
.ToList());
}
@@ -0,0 +1,78 @@
using ERPCore.Common.Http;
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Config;
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;
/// <summary>Procurement configuration service over the singleton row seeded by DataSeeder.</summary>
public sealed class ProcurementConfigService : IProcurementConfigService
{
private readonly IRepository<ProcurementConfig> _config;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public ProcurementConfigService(IRepository<ProcurementConfig> config, ICurrentUser currentUser, IUnitOfWork uow)
{
_config = config;
_currentUser = currentUser;
_uow = uow;
}
public async Task<ETagged<ProcurementConfigDto>> GetAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct)
?? throw new NotFoundException("Procurement configuration has not been seeded.");
return new ETagged<ProcurementConfigDto>(Map(config), config.RowVersion);
}
public async Task<ETagged<ProcurementConfigDto>> UpdateAsync(
UpdateProcurementConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var config = await _config.GetByIdAsync(ProcurementConfig.SingletonId, ct)
?? throw new NotFoundException("Procurement configuration has not been seeded.");
if (config.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
config.VendorContactMode = request.VendorContactMode!.Value;
config.VendorAddressEnabled = request.VendorAddressEnabled!.Value;
config.PoTaxEnabled = request.PoTaxEnabled!.Value;
config.VendorPaymentTermsEnabled = request.VendorPaymentTermsEnabled!.Value;
config.VendorTaxRegEnabled = request.VendorTaxRegEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
}
return await GetAsync(ct);
}
public async Task<ProcurementConfigDto> GetFlagsAsync(CancellationToken ct = default)
{
var config = await _config.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigId == ProcurementConfig.SingletonId, ct);
// No row (not seeded yet): contact/address/tax off, the long-standing terms and tax-reg fields on.
return config is null ? new ProcurementConfigDto(VendorContactMode.None, false, false, null, null, true, true) : Map(config);
}
private static ProcurementConfigDto Map(ProcurementConfig c) => new(
c.VendorContactMode, c.VendorAddressEnabled, c.PoTaxEnabled, c.UpdatedAt, c.UpdatedBy,
c.VendorPaymentTermsEnabled, c.VendorTaxRegEnabled);
}
+104 -26
View File
@@ -9,6 +9,7 @@ using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using ERPCore.System.Time;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
@@ -33,14 +34,16 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
private readonly ICurrentUser _currentUser;
private readonly INotificationService _notifications;
private readonly IBranchConfigService _branchConfig;
private readonly IProcurementConfigService _procurementConfig;
private readonly IUnitOfWork _uow;
public PurchaseOrderService(
IRepository<PurchaseOrder> pos, IRepository<Vendor> vendors, IRepository<Requisition> requisitions,
IRepository<Item> items, IRepository<Uom> uoms, IRepository<Warehouse> warehouses,
INumberSequenceService numbers, ICurrentUser currentUser, INotificationService notifications,
IBranchConfigService branchConfig, IUnitOfWork uow)
IBranchConfigService branchConfig, IProcurementConfigService procurementConfig, IUnitOfWork uow)
{
_procurementConfig = procurementConfig;
_pos = pos;
_vendors = vendors;
_requisitions = requisitions;
@@ -55,34 +58,84 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
}
public async Task<PagedResponse<PurchaseOrderSummaryDto>> ListAsync(
PageQuery query, PurchaseOrderStatus? status, int? vendorId, CancellationToken ct = default)
PageQuery query, PurchaseOrderStatus? status, int? vendorId, DateOnly? from = null, DateOnly? to = null,
CancellationToken ct = default)
{
var q = _pos.Query().AsNoTracking().Include(p => p.Lines).AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%"));
// Doc no., or the vendor's name / code (the vendor is the table's first column).
var vendorIdsMatching = _vendors.Query()
.Where(v => EF.Functions.Like(v.Name, $"%{term}%") || EF.Functions.Like(v.Code, $"%{term}%"))
.Select(v => v.VendorId);
q = q.Where(p => EF.Functions.Like(p.DocNo, $"%{term}%") || vendorIdsMatching.Contains(p.VendorId));
}
if (status is not null) q = q.Where(p => p.Status == status);
if (vendorId is not null) q = q.Where(p => p.VendorId == vendorId);
// Created date, as a business (Colombo) day: [start of `from`, start of the day after `to`).
if (from is DateOnly fromDay)
{
var start = BusinessClock.StartOfDayUtc(fromDay);
q = q.Where(p => p.CreatedAt >= start);
}
if (to is DateOnly toDay)
{
var end = BusinessClock.StartOfDayUtc(toDay.AddDays(1));
q = q.Where(p => p.CreatedAt < end);
}
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(p => p.PoId)
var rows = await ApplySort(q, query.Sort)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
var flags = await _procurementConfig.GetFlagsAsync(ct);
var vendorIds = rows.Select(p => p.VendorId).Distinct().ToList();
var vendorMap = await _vendors.Query().AsNoTracking()
.Where(v => vendorIds.Contains(v.VendorId))
.ToDictionaryAsync(v => v.VendorId, ct);
var summaries = rows.Select(p => new PurchaseOrderSummaryDto(
p.PoId, p.DocNo, p.VendorId, p.BranchId, p.Status, p.ApprovalRequired, p.CreatedBy, p.CreatedAt, ComputeTotals(p.Lines))).ToList();
p.PoId, p.DocNo, p.VendorId, p.BranchId, p.Status, p.ApprovalRequired, p.CreatedBy, p.CreatedAt, ComputeTotals(p.Lines),
vendorMap.TryGetValue(p.VendorId, out var v) ? ToVendorDto(v, flags) : null)).ToList();
return PagedResponse<PurchaseOrderSummaryDto>.Create(summaries, query.Page, query.PageSize, total);
}
/// <summary>
/// Server-side sort so paging stays correct: <c>created</c> / <c>vendor</c> / <c>total</c> / <c>status</c>, a leading
/// <c>-</c> for descending. Default (and any unknown key) is newest first. PoId is the tiebreaker, so a page
/// boundary never repeats or skips a row.
/// </summary>
private IQueryable<PurchaseOrder> ApplySort(IQueryable<PurchaseOrder> q, string? sort)
{
var key = (sort ?? "-created").Trim();
var desc = key.StartsWith('-');
key = key.TrimStart('-', '+').ToLowerInvariant();
IOrderedQueryable<PurchaseOrder> ordered = key switch
{
"vendor" => desc
? q.OrderByDescending(p => _vendors.Query().Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault())
: q.OrderBy(p => _vendors.Query().Where(v => v.VendorId == p.VendorId).Select(v => v.Name).FirstOrDefault()),
"total" => desc
? q.OrderByDescending(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount))
: q.OrderBy(p => p.Lines.Sum(l => l.Qty * l.UnitPrice + l.TaxAmount)),
"status" => desc ? q.OrderByDescending(p => p.Status) : q.OrderBy(p => p.Status),
"created" => desc ? q.OrderByDescending(p => p.CreatedAt) : q.OrderBy(p => p.CreatedAt),
_ => q.OrderByDescending(p => p.CreatedAt)
};
return ordered.ThenByDescending(p => p.PoId);
}
public async Task<ETagged<PurchaseOrderDto>?> GetAsync(int poId, CancellationToken ct = default)
{
var po = await _pos.Query().AsNoTracking()
.Include(p => p.Lines)
.FirstOrDefaultAsync(p => p.PoId == poId, ct);
return po is null ? null : new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
if (po is null) return null;
return new ETagged<PurchaseOrderDto>(await ToDtoAsync(po, ct), po.RowVersion);
}
public async Task<ETagged<PurchaseOrderDto>> CreateAsync(CreatePurchaseOrderRequest request, CancellationToken ct = default)
@@ -116,7 +169,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
if (po.Status == PurchaseOrderStatus.PendingApproval)
await _notifications.RaiseAsync("purchase.order.pending_approval", "Purchase Order Awaiting Approval", $"Purchase order {po.DocNo} is awaiting approval.", nameof(PurchaseOrder), po.PoId, ct);
return new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
return new ETagged<PurchaseOrderDto>(await ToDtoAsync(po, ct), po.RowVersion);
}
public async Task<ETagged<PurchaseOrderDto>> UpdateAsync(
@@ -154,7 +207,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The purchase order was modified by another request.", 412);
}
return new ETagged<PurchaseOrderDto>(Map(po), po.RowVersion);
return new ETagged<PurchaseOrderDto>(await ToDtoAsync(po, ct), po.RowVersion);
}
public async Task<PurchaseOrderDto> ApproveAsync(int poId, CancellationToken ct = default)
@@ -168,21 +221,22 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
await _uow.SaveChangesAsync(ct);
await _notifications.RaiseAsync("purchase.order.approved", "Purchase Order Approved", $"Purchase order {po.DocNo} was approved.", nameof(PurchaseOrder), po.PoId, ct);
return Map(po);
return await ToDtoAsync(po, ct);
}
public async Task<PurchaseOrderDto> RejectAsync(int poId, string? reason, CancellationToken ct = default)
{
var po = await LoadForApprovalDecisionAsync(poId, ct);
// Back to Draft, not Cancelled — a reject is "revise and resubmit", not "abandon".
po.Status = PurchaseOrderStatus.Draft;
// Rejected, not Cancelled — a reject is "revise and resubmit", not "abandon" - and not Draft either, so the
// list and the creator can tell it was turned down.
po.Status = PurchaseOrderStatus.Rejected;
po.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
var suffix = string.IsNullOrWhiteSpace(reason) ? "." : $": {reason}";
await _notifications.RaiseAsync("purchase.order.rejected", "Purchase Order Rejected", $"Purchase order {po.DocNo} was rejected{suffix}", nameof(PurchaseOrder), po.PoId, ct);
return Map(po);
return await ToDtoAsync(po, ct);
}
/// <summary>Shared load + gate for Approve/Reject: must be PendingApproval, and the
@@ -197,9 +251,9 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
if (po.Status != PurchaseOrderStatus.PendingApproval)
throw new DomainException(ErrorCodes.PoNotPendingApproval, $"Purchase order {poId} is {po.Status} and is not awaiting approval.", 409);
if (!_currentUser.IsAdmin)
throw new DomainException(ErrorCodes.PoApprovalForbidden, "Only an Admin may approve or reject a purchase order.", 403);
// Who may approve is the role's "Approve" permission on Procurement (an administrator always has it): the
// approve/reject endpoints require policy `Nav:procurement:Approve`. This used to be a second, hard-coded
// "Admin only" check that ignored the permission; what stays here is the maker-checker rule.
if (po.CreatedBy == _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.PoApprovalForbidden, "You cannot approve or reject your own purchase order.", 403);
@@ -223,7 +277,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
await _uow.SaveChangesAsync(ct);
}
return Map(po);
return await ToDtoAsync(po, ct);
}
public async Task<PurchaseOrderDto> SubmitAsync(int poId, CancellationToken ct = default)
@@ -233,7 +287,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Status != PurchaseOrderStatus.Draft)
if (!IsEditable(po.Status))
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be submitted.", 409);
po.Status = PurchaseOrderStatus.PendingApproval;
@@ -242,7 +296,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
await _uow.SaveChangesAsync(ct);
await _notifications.RaiseAsync("purchase.order.pending_approval", "Purchase Order Awaiting Approval", $"Purchase order {po.DocNo} is awaiting approval.", nameof(PurchaseOrder), po.PoId, ct);
return Map(po);
return await ToDtoAsync(po, ct);
}
public async Task DeleteAsync(int poId, CancellationToken ct = default)
@@ -252,8 +306,8 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
.FirstOrDefaultAsync(p => p.PoId == poId, ct)
?? throw new NotFoundException($"Purchase order {poId} was not found.");
if (po.Status != PurchaseOrderStatus.Draft)
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft can be deleted.", 409);
if (!IsEditable(po.Status))
throw new DomainException(ErrorCodes.PoNotEditable, $"Purchase order {poId} is {po.Status} and cannot be deleted; only a Draft or Rejected order can be deleted.", 409);
_pos.Remove(po);
await _uow.SaveChangesAsync(ct);
@@ -261,7 +315,7 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
// FR-PROC-05 (revised): a PO is editable/deletable only while Draft. Submitting locks it.
// Supersedes Phase-1 Option B "freely editable while open" — see docs/10 FR-PROC-05.
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft;
private static bool IsEditable(PurchaseOrderStatus status) => status is PurchaseOrderStatus.Draft or PurchaseOrderStatus.Rejected;
private static PoLine ToLine(CreatePoLineInput l) => new()
{
@@ -270,17 +324,21 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
Qty = l.Qty,
UnitPrice = l.UnitPrice,
Tax = l.Tax,
TaxAmount = TaxAmountFor(l.Qty, l.UnitPrice, l.Tax),
QtyReceived = 0
};
/// <summary>The line's tax amount, rounded once here and then stored (see <see cref="PoLine.TaxAmount"/>).</summary>
private static decimal TaxAmountFor(decimal qty, decimal unitPrice, decimal rate) =>
Math.Round(qty * unitPrice * rate, 2, MidpointRounding.AwayFromZero);
private static PoTotalsDto ComputeTotals(IEnumerable<PoLine> lines)
{
decimal sub = 0, tax = 0;
foreach (var l in lines)
{
var net = l.Qty * l.UnitPrice;
sub += net;
tax += net * l.Tax;
sub += l.Qty * l.UnitPrice;
tax += l.TaxAmount;
}
sub = Math.Round(sub, 2, MidpointRounding.AwayFromZero);
tax = Math.Round(tax, 2, MidpointRounding.AwayFromZero);
@@ -300,6 +358,11 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
&& !await _requisitions.Query().AnyAsync(r => r.RequisitionId == requisitionId, ct))
throw new DomainException(ErrorCodes.Validation, $"Requisition {requisitionId} does not exist.", 422);
// Tax is a Procurement-configuration option: with it off, the form hides the field, so a
// non-zero rate can only come from a stale or hand-built request.
if (!(await _procurementConfig.GetFlagsAsync(ct)).PoTaxEnabled && lines.Any(l => l.Tax != 0))
throw new DomainException(ErrorCodes.ConfigDisabled, "Tax on purchase orders is turned off in Procurement configuration.", 422);
await EnsureAllExistAsync(_items.Query().Select(i => i.ItemId), lines.Select(l => l.ItemId), "Item", ct);
await EnsureAllExistAsync(_warehouses.Query().Select(w => w.WarehouseId), lines.Select(l => l.WarehouseId), "Warehouse", ct);
}
@@ -328,9 +391,24 @@ public sealed class PurchaseOrderService : IPurchaseOrderService
throw new DomainException(ErrorCodes.Validation, $"{label}(s) not found: {string.Join(", ", missing)}.", 422);
}
private static PurchaseOrderDto Map(PurchaseOrder p) => new(
/// <summary>Vendor block for a PO — the optional contact/address fields only when their switch is on.</summary>
private static PoVendorDto ToVendorDto(Vendor v, Dtos.Config.ProcurementConfigDto flags) => new(
v.VendorId, v.Code, v.Name,
flags.VendorContactMode == VendorContactMode.Mobile ? v.Mobile : null,
flags.VendorContactMode == VendorContactMode.Email ? v.Email : null,
flags.VendorAddressEnabled ? v.Address : null);
private async Task<PurchaseOrderDto> ToDtoAsync(PurchaseOrder p, CancellationToken ct)
{
var vendor = await _vendors.Query().AsNoTracking().FirstOrDefaultAsync(v => v.VendorId == p.VendorId, ct);
var flags = await _procurementConfig.GetFlagsAsync(ct);
return Map(p, vendor is null ? null : ToVendorDto(vendor, flags));
}
private static PurchaseOrderDto Map(PurchaseOrder p, PoVendorDto? vendor) => new(
p.PoId, p.DocNo, p.VendorId, p.BranchId, p.RequisitionId, p.Status, p.ApprovalRequired,
p.CreatedBy, p.ApprovedBy, p.ApprovedAt, p.CreatedAt, p.UpdatedAt, ComputeTotals(p.Lines),
p.Lines.OrderBy(l => l.PoLineId).Select(l => new PoLineDto(
l.PoLineId, l.ItemId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived)).ToList());
l.PoLineId, l.ItemId, l.WarehouseId, l.Qty, l.UnitPrice, l.Tax, l.QtyReceived, l.TaxAmount)).ToList(),
vendor);
}
@@ -251,7 +251,8 @@ public sealed class PurchaseReturnService : IPurchaseReturnService
var entry = await _fifo.PostLedgerAsync(
layer.ItemId, layer.WarehouseId, null, layer.BatchId, null, actor,
Direction.Out, take, layer.UnitCost, bal, DocumentTypes.PurchaseReturn, ret.ReturnId, now, token);
Direction.Out, take, layer.UnitCost, bal, DocumentTypes.PurchaseReturn, ret.ReturnId, now, token,
postGlJournal: false); // the return posts its own journal
refs.Add(entry);
}
balances[line.ItemId] = bal;
+73 -52
View File
@@ -1,11 +1,9 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
@@ -14,32 +12,37 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Roles and their permissions. The ERP owns these completely — nothing here talks to AuthHex any more.
/// Every change that can alter what someone may do evicts that role's cached permissions
/// (<see cref="INavPermissionService.Invalidate"/>), so it applies on the person's very next request.
/// </summary>
public sealed class RoleService : IRoleService
{
private const string AdminRoleCode = "Admin";
private readonly IRepository<Role> _roles;
private readonly IRepository<RolePermission> _rolePermissions;
private readonly IRepository<Permission> _permissions;
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IRepository<AccessConfig> _accessConfig;
private readonly ICurrentUser _currentUser;
private readonly IAuthHexClient _authHex;
private readonly INavPermissionService _navPermissions;
private readonly IBranchConfigService _branchConfig;
private readonly IUnitOfWork _uow;
public RoleService(
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
IRepository<Employee> employees, IRepository<User> users, ICurrentUser currentUser, IAuthHexClient authHex,
IBranchConfigService branchConfig, IUnitOfWork uow)
IRepository<Employee> employees, IRepository<User> users, IRepository<AccessConfig> accessConfig,
ICurrentUser currentUser, INavPermissionService navPermissions, IBranchConfigService branchConfig, IUnitOfWork uow)
{
_roles = roles;
_rolePermissions = rolePermissions;
_permissions = permissions;
_employees = employees;
_users = users;
_accessConfig = accessConfig;
_currentUser = currentUser;
_authHex = authHex;
_navPermissions = navPermissions;
_branchConfig = branchConfig;
_uow = uow;
}
@@ -74,15 +77,13 @@ public sealed class RoleService : IRoleService
if (await _roles.Query().AnyAsync(r => r.Code == code, ct))
throw new ConflictException($"A role with code '{code}' already exists.");
var authRole = await _authHex.CreateRoleAsync(
new CreateAuthHexRoleRequest { Code = code, Name = request.Name.Trim() }, ct);
// IsAdmin is never settable here: an administrator role is created by the seeder, not through the API.
var role = new Role
{
AuthRoleId = authRole.RoleId,
Code = code,
Name = request.Name.Trim(),
IsSystemRole = authRole.IsSystemRole ?? false,
IsSystemRole = false,
IsAdmin = false,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
@@ -107,9 +108,7 @@ public sealed class RoleService : IRoleService
&& await _roles.Query().AnyAsync(r => r.Code == code && r.RoleId != roleId, ct))
throw new ConflictException($"A role with code '{code}' already exists.");
await _authHex.UpdateRoleAsync(
new UpdateAuthHexRoleRequest { RoleId = role.AuthRoleId, Code = code, Name = request.Name.Trim() }, ct);
// Safe to rename any role, the administrator role included: nothing joins on the code any more.
role.Code = code;
role.Name = request.Name.Trim();
role.UpdatedAt = DateTime.UtcNow;
@@ -132,11 +131,15 @@ public sealed class RoleService : IRoleService
?? throw new NotFoundException($"Role {roleId} was not found.");
if (status == EntityStatus.Inactive)
{
await EnsureRemovableAsync(role, "deactivated", ct);
await EnsureNotAssignedToAnyUserAsync(roleId, "deactivated", ct);
}
role.Status = status;
role.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
_navPermissions.Invalidate(roleId);
}
public async Task DeleteAsync(int roleId, CancellationToken ct = default)
@@ -144,19 +147,31 @@ public sealed class RoleService : IRoleService
var role = await _roles.GetByIdAsync(roleId, ct)
?? throw new NotFoundException($"Role {roleId} was not found.");
await EnsureRemovableAsync(role, "deleted", ct);
await EnsureNotAssignedToAnyUserAsync(roleId, "deleted", ct);
try
{
await _authHex.DeleteRoleAsync(role.AuthRoleId, ct);
}
catch (DomainException ex) when (ex.Message.Contains("ROLE_IN_USE", StringComparison.OrdinalIgnoreCase))
{
throw new DomainException(ErrorCodes.RoleInUse, "This role is assigned to one or more users.", 409);
}
// Its permission rows go with it (cascade); drop the cached copy too.
_roles.Remove(role);
await _uow.SaveChangesAsync(ct);
_navPermissions.Invalidate(roleId);
}
/// <summary>
/// The administrator role, system roles and the configured default role can't be deactivated or deleted:
/// losing them would lock everyone out, or leave new sign-ins with nowhere to land.
/// </summary>
private async Task EnsureRemovableAsync(Role role, string action, CancellationToken ct)
{
if (role.IsAdmin)
throw new DomainException(ErrorCodes.RoleInUse, $"The administrator role cannot be {action}.", 409);
if (role.IsSystemRole)
throw new DomainException(ErrorCodes.RoleInUse, $"'{role.Name}' is a system role and cannot be {action}.", 409);
var isDefault = await _accessConfig.Query().AsNoTracking()
.AnyAsync(c => c.ConfigId == AccessConfig.SingletonId && c.DefaultRoleId == role.RoleId, ct);
if (isDefault)
throw new DomainException(ErrorCodes.RoleInUse,
$"'{role.Name}' is the default role for new sign-ins and cannot be {action}. Choose a different default role first (Settings → Access).", 409);
}
/// <summary>
@@ -220,11 +235,13 @@ public sealed class RoleService : IRoleService
}
await _uow.SaveChangesAsync(ct);
// The change is live on the next request of everyone holding this role.
_navPermissions.Invalidate(roleId);
return await GetPermissionsAsync(roleId, ct);
}
public async Task<MeResponseDto> GetMeAsync(string? roleCode, CancellationToken ct = default)
public async Task<MeResponseDto> GetMeAsync(CancellationToken ct = default)
{
// The caller's branch, via their optional Employee link — independent of role,
// since even an Admin's own "home" branch may be worth surfacing later. Null
@@ -239,36 +256,40 @@ public sealed class RoleService : IRoleService
var branchingEnabled = await _branchConfig.IsBranchingEnabledAsync(ct);
var warehouseBranchingEnabled = await _branchConfig.IsWarehouseBranchingEnabledAsync(ct);
if (string.IsNullOrWhiteSpace(roleCode))
return new MeResponseDto(null, null, Array.Empty<string>(), false, branchId, branchName, branchingEnabled, warehouseBranchingEnabled);
// AuthHex mints the RoleCode claim independently of ERPCore's stored casing
// (e.g. token "ADMIN" vs seeded "Admin"), so match case-insensitively — an
// identity code differing only by case must not lock the user out of the nav.
var normalized = roleCode.Trim();
var isAdmin = string.Equals(normalized, AdminRoleCode, StringComparison.OrdinalIgnoreCase);
var role = await _roles.Query().AsNoTracking()
.FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct);
// The role was resolved from the ERP's own tables when the request was authenticated. No role claim means
// the person has no usable role (none assigned and no default, or the role is inactive): say so explicitly,
// so the UI can show "waiting for access" instead of an empty, unexplained menu.
var roleId = _currentUser.RoleId;
var role = roleId is null
? null
: await _roles.Query().AsNoTracking().FirstOrDefaultAsync(r => r.RoleId == roleId && r.Status == EntityStatus.Active, ct);
if (role is null)
return new MeResponseDto(roleCode, null, Array.Empty<string>(), isAdmin, branchId, branchName, branchingEnabled, warehouseBranchingEnabled);
return new MeResponseDto(null, null, Array.Empty<string>(), false, branchId, branchName, branchingEnabled,
warehouseBranchingEnabled, null, false, new Dictionary<string, int>());
var permissions = await _rolePermissions.Query().AsNoTracking()
.Where(rp => rp.RoleId == role.RoleId)
.Include(rp => rp.Permission!).ThenInclude(p => p.NavItem)
.Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem)
.Select(rp => rp.Permission!)
.ToListAsync(ct);
// An administrator holds everything, always — including nav items added after the role was last edited —
// so the UI never depends on grant rows being kept in step with new screens.
Dictionary<string, int> permissions;
if (role.IsAdmin)
{
var codes = await _permissions.Query().AsNoTracking()
.Select(p => p.NavItem!.Code ?? p.SubNavItem!.Code)
.ToListAsync(ct);
permissions = codes.Where(c => !string.IsNullOrEmpty(c))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToDictionary(c => c, _ => (int)RolePermissionAction.All, StringComparer.OrdinalIgnoreCase);
}
else
{
permissions = (await _navPermissions.GetPermissionsAsync(role.RoleId, ct))
.ToDictionary(kv => kv.Key, kv => (int)kv.Value, StringComparer.OrdinalIgnoreCase);
}
var navCodes = permissions
.Select(p => p.NavItem?.Code ?? p.SubNavItem?.Code)
.Where(code => code is not null)
.Select(code => code!)
.Distinct()
.ToList();
return new MeResponseDto(role.Code, role.Name, navCodes, isAdmin, branchId, branchName, branchingEnabled, warehouseBranchingEnabled);
return new MeResponseDto(
role.Code, role.Name, permissions.Keys.ToList(), role.IsAdmin, branchId, branchName,
branchingEnabled, warehouseBranchingEnabled, role.RoleId, true, permissions);
}
private static RoleDto Map(Role r) => new(
r.RoleId, r.Code, r.Name, r.IsSystemRole, r.Status, r.CreatedAt, r.UpdatedAt);
r.RoleId, r.Code, r.Name, r.IsSystemRole, r.Status, r.CreatedAt, r.UpdatedAt, r.IsAdmin);
}
+10 -1
View File
@@ -98,6 +98,13 @@ public sealed class SalesConfigService : ISalesConfigService
config.AllowOverStockSale = request.AllowOverStockSale!.Value;
config.AllowWarehouseSelection = allowWarehouseSelection;
config.DefaultWarehouseId = request.DefaultWarehouseId;
config.CustomerPhoneEnabled = request.CustomerPhoneEnabled!.Value;
config.CustomerEmailEnabled = request.CustomerEmailEnabled!.Value;
config.CustomerAddressEnabled = request.CustomerAddressEnabled!.Value;
config.CustomerCityEnabled = request.CustomerCityEnabled!.Value;
config.CustomerCountryEnabled = request.CustomerCountryEnabled!.Value;
config.CustomerCreditLimitEnabled = request.CustomerCreditLimitEnabled!.Value;
config.CustomerCreditDaysEnabled = request.CustomerCreditDaysEnabled!.Value;
config.UpdatedAt = DateTime.UtcNow;
config.UpdatedBy = _currentUser.AuditUserId;
@@ -140,5 +147,7 @@ public sealed class SalesConfigService : ISalesConfigService
private static SalesConfigDto Map(SalesConfig c) => new(
c.AllowCashCustomer, c.CashCustomerId, c.CashCustomer?.DisplayName ?? c.CashCustomer?.Name, c.AllowFreeItems, c.AllowDiscounts, c.AllowOverStockSale,
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy);
c.AllowWarehouseSelection, c.DefaultWarehouseId, c.DefaultWarehouse?.Name, c.UpdatedAt, c.UpdatedBy,
c.CustomerPhoneEnabled, c.CustomerEmailEnabled, c.CustomerAddressEnabled, c.CustomerCityEnabled, c.CustomerCountryEnabled,
c.CustomerCreditLimitEnabled, c.CustomerCreditDaysEnabled);
}
@@ -311,7 +311,8 @@ public sealed class SalesPostingService : ISalesPostingService
var cost = qty == 0 ? 0m : group.Sum(x => x.Qty * x.UnitCost) / qty;
await _fifo.PostLedgerAsync(line.ItemId, line.WarehouseId, null, group.Key,
serials.Count == 1 ? serials[0].SerialId : null, _currentUser.AuditUserId,
Direction.Out, qty, cost, 0m, sourceDocType, docId, now, token);
Direction.Out, qty, cost, 0m, sourceDocType, docId, now, token,
postGlJournal: false); // the invoice posts its own journal (revenue, COGS, inventory)
}
foreach (var segment in consumed)
@@ -361,7 +361,7 @@ public sealed class FifoCostingService : IFifoCostingService
int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId,
Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance,
string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default,
decimal? valueOverride = null)
decimal? valueOverride = null, bool postGlJournal = true)
{
var entry = new StockLedger
{
@@ -401,7 +401,7 @@ public sealed class FifoCostingService : IFifoCostingService
Amount = entry.Value
}, ct);
if (entry.Value != 0)
if (postGlJournal && entry.Value != 0)
{
var period = await _gl.GetPeriodByDateAsync(BusinessClock.ToBusinessDate(createdAt), ct);
await _gl.PostJournalEntryAsync(new GlJournalEntryRequest
@@ -58,7 +58,8 @@ public sealed class StockMutator : IStockMutator
bal -= qty;
entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor,
Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct);
Direction.Out, qty, unitCost, bal, sourceDocType, sourceDocId, now, ct,
postGlJournal: false); // every caller (adjustment, returns) posts its own document journal
}
else
{
@@ -75,7 +76,8 @@ public sealed class StockMutator : IStockMutator
bal += d.QtyDelta;
entry = await _fifo.PostLedgerAsync(
d.ItemId, warehouseId, d.BinId, d.BatchId, ledgerSerialId, actor,
Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct);
Direction.In, d.QtyDelta, unitCost, bal, sourceDocType, sourceDocId, now, ct,
postGlJournal: false);
}
balances[d.ItemId] = bal;
@@ -4,6 +4,7 @@ using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
@@ -21,12 +22,15 @@ public sealed class UserManagementService : IUserManagementService
private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeService _employeeService;
private readonly ICurrentUser _currentUser;
private readonly IUnitOfWork _uow;
public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees,
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService,
ICurrentUser currentUser, IUnitOfWork uow)
{
_currentUser = currentUser;
_users = users;
_roles = roles;
_employees = employees;
@@ -36,7 +40,7 @@ public sealed class UserManagementService : IUserManagementService
_uow = uow;
}
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, bool? isAdmin = null, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role)
.Where(u => !u.Username.StartsWith(User.DeletedUsernamePrefix));
@@ -47,6 +51,8 @@ public sealed class UserManagementService : IUserManagementService
}
if (status is not null) q = q.Where(u => u.Status == status);
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && u.Role.Code == roleCode);
if (isAdmin is true) q = q.Where(u => u.Role != null && u.Role.IsAdmin);
else if (isAdmin is false) q = q.Where(u => u.Role == null || !u.Role.IsAdmin);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Username)
@@ -67,11 +73,13 @@ public sealed class UserManagementService : IUserManagementService
{
var role = await _roles.GetByIdAsync(request.RoleId, ct)
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
if (role.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Role '{role.Name}' is inactive.", 422);
// Non-admin roles are branch-scoped (BranchAccessService.EnsureBranchVisibleAsync
// resolves the caller's branch via their linked Employee) — either link an existing,
// already branch-scoped Employee, or supply enough to create a new one.
var isAdminRole = string.Equals(role.Code, "Admin", StringComparison.OrdinalIgnoreCase);
var isAdminRole = role.IsAdmin;
string? employeeCode = null;
if (!isAdminRole && request.LinkEmployeeId is null)
{
@@ -103,7 +111,8 @@ public sealed class UserManagementService : IUserManagementService
await _authHex.RegisterAsync(new RegisterRequest
{
UserId = authUserId,
RoleId = role.AuthRoleId,
// No AuthHex role: the person's role lives in the ERP (users.RoleId, set below). The client fills in
// AuthHex's legacy placeholder only while the old AuthHex, which still demands one, is deployed.
UserTypeId = request.UserTypeId,
Fullname = request.FullName.Trim(),
UserName = username,
@@ -165,7 +174,22 @@ public sealed class UserManagementService : IUserManagementService
?? throw new NotFoundException($"User {userId} was not found.");
var role = await _roles.GetByIdAsync(request.RoleId, ct)
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
if (role.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Role '{role.Name}' is inactive.", 422);
// Never leave the system without an administrator, and never let someone lock themselves out.
if (!role.IsAdmin)
{
var currentRole = user.RoleId is null ? null : await _roles.GetByIdAsync(user.RoleId.Value, ct);
if (currentRole?.IsAdmin == true)
{
if (user.UserId == _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.Conflict, "You cannot remove your own administrator access. Ask another administrator.", 409);
await EnsureAnotherActiveAdminAsync(userId, "change this user's role", ct);
}
}
// The very next request of this person reads the new role from the database — nothing else to refresh.
user.RoleId = role.RoleId;
await _uow.SaveChangesAsync(ct);
@@ -205,6 +229,14 @@ public sealed class UserManagementService : IUserManagementService
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
if (status == EntityStatus.Inactive)
{
if (user.UserId == _currentUser.AuditUserId)
throw new DomainException(ErrorCodes.Conflict, "You cannot deactivate your own account.", 409);
if (user.Role?.IsAdmin == true)
await EnsureAnotherActiveAdminAsync(userId, "deactivate this user", ct);
}
// The local flag alone is cosmetic — without this, a user shown "Inactive" here
// could still log in directly against AuthHex.
if (user.AuthUserId is Guid authUserId)
@@ -247,6 +279,67 @@ public sealed class UserManagementService : IUserManagementService
return _authHex.DeleteUserAsync(request.Email, request.MobileNumber, request.Nic, request.UserName, ct);
}
/// <summary>Refuses an action that would leave no other active administrator.</summary>
private async Task EnsureAnotherActiveAdminAsync(int exceptUserId, string action, CancellationToken ct)
{
var others = await _users.Query().AsNoTracking().CountAsync(u =>
u.UserId != exceptUserId && u.Status == EntityStatus.Active
&& u.Role != null && u.Role.IsAdmin && u.Role.Status == EntityStatus.Active, ct);
if (others == 0)
throw new DomainException(ErrorCodes.Conflict,
$"You cannot {action}: it is the only active administrator. Make another user an administrator first.", 409);
}
public async Task<BackfillRolesResultDto> BackfillRolesFromAuthHexAsync(CancellationToken ct = default)
{
var candidates = await _users.Query()
.Where(u => u.RoleId == null && u.AuthUserId != null && !u.Username.StartsWith(User.DeletedUsernamePrefix))
.ToListAsync(ct);
var alreadyHad = await _users.Query().AsNoTracking().CountAsync(u => u.RoleId != null, ct);
var rolesByCode = (await _roles.Query().AsNoTracking().ToListAsync(ct))
.ToDictionary(r => r.Code, StringComparer.OrdinalIgnoreCase);
var issues = new List<BackfillRolesIssueDto>();
var assigned = 0;
foreach (var user in candidates)
{
try
{
var details = await _authHex.GetUserDetailsAsync(user.AuthUserId!.Value, ct);
var code = ReadRoleCode(details.Role);
if (code is null)
issues.Add(new BackfillRolesIssueDto(user.UserId, user.Username, "AuthHex returned no role for this user."));
else if (!rolesByCode.TryGetValue(code, out var role))
issues.Add(new BackfillRolesIssueDto(user.UserId, user.Username, $"AuthHex role '{code}' has no matching ERP role."));
else if (role.Status != EntityStatus.Active)
issues.Add(new BackfillRolesIssueDto(user.UserId, user.Username, $"ERP role '{role.Name}' is inactive."));
else
{
user.RoleId = role.RoleId;
assigned++;
}
}
catch (DomainException ex)
{
issues.Add(new BackfillRolesIssueDto(user.UserId, user.Username, $"AuthHex lookup failed: {ex.Message}"));
}
}
await _uow.SaveChangesAsync(ct);
return new BackfillRolesResultDto(candidates.Count, assigned, alreadyHad, issues);
}
/// <summary>AuthHex's role comes back as a loose JSON object; its code may be cased either way.</summary>
private static string? ReadRoleCode(global::System.Text.Json.JsonElement? role)
{
if (role is not { ValueKind: global::System.Text.Json.JsonValueKind.Object } obj) return null;
foreach (var property in obj.EnumerateObject())
if (property.Name.Equals("code", StringComparison.OrdinalIgnoreCase) && property.Value.ValueKind == global::System.Text.Json.JsonValueKind.String)
return property.Value.GetString();
return null;
}
private static ManagedUserDto Map(User u) => new(
u.UserId, u.Username, u.DisplayName, u.Email, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name);
}
+75 -6
View File
@@ -1,5 +1,7 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using System.Text.RegularExpressions;
using ERPCore.Domain;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Vendors;
@@ -15,12 +17,16 @@ public sealed class VendorService : IVendorService
{
private readonly IRepository<Vendor> _vendors;
private readonly IBranchConfigService _branchConfig;
private readonly IProcurementConfigService _procurementConfig;
private readonly IUnitOfWork _uow;
public VendorService(IRepository<Vendor> vendors, IBranchConfigService branchConfig, IUnitOfWork uow)
public VendorService(
IRepository<Vendor> vendors, IBranchConfigService branchConfig,
IProcurementConfigService procurementConfig, IUnitOfWork uow)
{
_vendors = vendors;
_branchConfig = branchConfig;
_procurementConfig = procurementConfig;
_uow = uow;
}
@@ -56,13 +62,19 @@ public sealed class VendorService : IVendorService
throw new ConflictException($"A vendor with code '{code}' already exists.");
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var flags = await _procurementConfig.GetFlagsAsync(ct);
var (mobile, email, address, terms, taxReg) =
ValidateOptionalFields(flags, request.Mobile, request.Email, request.Address, request.Terms, request.TaxReg);
var vendor = new Vendor
{
Code = code,
Name = request.Name.Trim(),
Terms = request.Terms,
TaxReg = request.TaxReg,
Terms = terms,
TaxReg = taxReg,
Currency = request.Currency.Trim().ToUpperInvariant(),
Mobile = mobile,
Email = email,
Address = address,
Status = EntityStatus.Active,
BranchId = branchId,
CreatedAt = DateTime.UtcNow
@@ -89,12 +101,20 @@ public sealed class VendorService : IVendorService
throw new ConflictException($"A vendor with code '{code}' already exists.");
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
var flags = await _procurementConfig.GetFlagsAsync(ct);
var (mobile, email, address, terms, taxReg) =
ValidateOptionalFields(flags, request.Mobile, request.Email, request.Address, request.Terms, request.TaxReg);
vendor.Code = code;
vendor.Name = request.Name.Trim();
vendor.Terms = request.Terms;
vendor.TaxReg = request.TaxReg;
vendor.Currency = request.Currency.Trim().ToUpperInvariant();
// A field that is switched off in Procurement configuration is left as stored (not erased),
// so re-enabling it later brings the old values back.
if (flags.VendorContactMode == VendorContactMode.Mobile) vendor.Mobile = mobile;
if (flags.VendorContactMode == VendorContactMode.Email) vendor.Email = email;
if (flags.VendorAddressEnabled) vendor.Address = address;
if (flags.VendorPaymentTermsEnabled) vendor.Terms = terms;
if (flags.VendorTaxRegEnabled) vendor.TaxReg = taxReg;
vendor.BranchId = branchId;
vendor.UpdatedAt = DateTime.UtcNow;
@@ -140,6 +160,55 @@ public sealed class VendorService : IVendorService
}
}
private static readonly Regex MobilePattern = new(@"^\+?[0-9][0-9 \-()]{5,18}$", RegexOptions.Compiled);
/// <summary>
/// Trims the optional fields, rejects any that Procurement configuration has switched off
/// (422 CONFIG_DISABLED — the UI hides them, so only a stale or hand-built request gets here)
/// and format-checks the ones that are on. Blank means "not provided".
/// </summary>
private static (string? Mobile, string? Email, string? Address, string? Terms, string? TaxReg) ValidateOptionalFields(
Dtos.Config.ProcurementConfigDto flags, string? mobile, string? email, string? address, string? terms, string? taxReg)
{
mobile = string.IsNullOrWhiteSpace(mobile) ? null : mobile.Trim();
email = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
address = string.IsNullOrWhiteSpace(address) ? null : address.Trim();
terms = string.IsNullOrWhiteSpace(terms) ? null : terms.Trim();
taxReg = string.IsNullOrWhiteSpace(taxReg) ? null : taxReg.Trim();
if (mobile is not null && flags.VendorContactMode != VendorContactMode.Mobile)
throw new DomainException(ErrorCodes.ConfigDisabled, "Vendor mobile numbers are turned off in Procurement configuration.", 422);
if (email is not null && flags.VendorContactMode != VendorContactMode.Email)
throw new DomainException(ErrorCodes.ConfigDisabled, "Vendor email addresses are turned off in Procurement configuration.", 422);
if (address is not null && !flags.VendorAddressEnabled)
throw new DomainException(ErrorCodes.ConfigDisabled, "Vendor addresses are turned off in Procurement configuration.", 422);
if (terms is not null && !flags.VendorPaymentTermsEnabled)
throw new DomainException(ErrorCodes.ConfigDisabled, "Vendor payment terms are turned off in Procurement configuration.", 422);
if (taxReg is not null && !flags.VendorTaxRegEnabled)
throw new DomainException(ErrorCodes.ConfigDisabled, "Vendor tax registration is turned off in Procurement configuration.", 422);
if (mobile is not null && !MobilePattern.IsMatch(mobile))
throw new DomainException(ErrorCodes.Validation, "Enter a valid mobile number (digits, optional leading +).", 422);
if (email is not null && !IsValidEmail(email))
throw new DomainException(ErrorCodes.Validation, "Enter a valid email address.", 422);
return (mobile, email, address, terms, taxReg);
}
private static bool IsValidEmail(string value)
{
try
{
var parsed = new global::System.Net.Mail.MailAddress(value);
return parsed.Address == value && parsed.Host.Contains('.');
}
catch (FormatException)
{
return false;
}
}
private static VendorDto Map(Vendor v) => new(
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt);
v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt,
v.Mobile, v.Email, v.Address);
}