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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user