branch crud and intergration
This commit is contained in:
@@ -95,7 +95,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
|
||||
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _adjustments.Query().AsNoTracking();
|
||||
|
||||
@@ -105,6 +105,7 @@ public sealed class AdjustmentService : IAdjustmentService
|
||||
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
|
||||
}
|
||||
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
|
||||
if (branchId is not null) q = q.Where(a => a.Warehouse!.BranchId == branchId);
|
||||
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.System.Errors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ERPCore.Services;
|
||||
|
||||
public sealed class BranchAccessService : IBranchAccessService
|
||||
{
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IRepository<Employee> _employees;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
|
||||
public BranchAccessService(ICurrentUser currentUser, IRepository<Employee> employees, IBranchConfigService branchConfig)
|
||||
{
|
||||
_currentUser = currentUser;
|
||||
_employees = employees;
|
||||
_branchConfig = branchConfig;
|
||||
}
|
||||
|
||||
public async Task EnsureBranchVisibleAsync(int branchId, CancellationToken ct = default)
|
||||
{
|
||||
if (!_currentUser.IsAuthenticated || _currentUser.IsAdmin) return;
|
||||
if (!await _branchConfig.IsBranchingEnabledAsync(ct)) return;
|
||||
|
||||
var ownBranchId = await _employees.Query().AsNoTracking()
|
||||
.Where(e => e.UserId == _currentUser.AuditUserId)
|
||||
.Select(e => (int?)e.BranchId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (ownBranchId is null || ownBranchId != branchId)
|
||||
throw new NotFoundException("The requested record was not found.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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>
|
||||
/// Branch configuration service over the singleton row seeded by DataSeeder. Also owns
|
||||
/// resolving the effective branch id for an Employee/Warehouse write — the one place that
|
||||
/// knows how to fall back to a default branch when branching is switched off.
|
||||
/// </summary>
|
||||
public sealed class BranchConfigService : IBranchConfigService
|
||||
{
|
||||
private const string DefaultBranchCode = "BR-000";
|
||||
private const string DefaultBranchName = "Main Branch";
|
||||
|
||||
private readonly IRepository<BranchConfig> _config;
|
||||
private readonly IRepository<Branch> _branches;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public BranchConfigService(IRepository<BranchConfig> config, IRepository<Branch> branches, ICurrentUser currentUser, IUnitOfWork uow)
|
||||
{
|
||||
_config = config;
|
||||
_branches = branches;
|
||||
_currentUser = currentUser;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Branch configuration has not been seeded.");
|
||||
|
||||
return new ETagged<BranchConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<BranchConfigDto>> UpdateAsync(
|
||||
UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.GetByIdAsync(BranchConfig.SingletonId, ct)
|
||||
?? throw new NotFoundException("Branch configuration has not been seeded.");
|
||||
|
||||
if (config.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The configuration was modified by another request.", 412);
|
||||
|
||||
config.BranchingEnabled = request.BranchingEnabled!.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 new ETagged<BranchConfigDto>(Map(config), config.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default)
|
||||
{
|
||||
var config = await _config.Query().AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.ConfigId == BranchConfig.SingletonId, ct);
|
||||
// Seeded at startup (DataSeeder) — missing only for a database built some other way,
|
||||
// in which case the safer default is "on" (today's mandatory behaviour), not silently off.
|
||||
return config?.BranchingEnabled ?? true;
|
||||
}
|
||||
|
||||
public async Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default)
|
||||
{
|
||||
if (await IsBranchingEnabledAsync(ct))
|
||||
{
|
||||
if (requestedBranchId <= 0)
|
||||
throw new DomainException(ErrorCodes.Validation, "Branch is required.", 422);
|
||||
return requestedBranchId;
|
||||
}
|
||||
|
||||
// Branching is off — ignore whatever was posted and use the deployment's one default
|
||||
// branch, creating it on first use (same placeholder MakeEmployeeBranchRequired/
|
||||
// AddWarehouseBranch backfill onto for pre-existing rows).
|
||||
var existingId = await _branches.Query().AsNoTracking()
|
||||
.Where(b => b.Code == DefaultBranchCode).Select(b => b.BranchId).FirstOrDefaultAsync(ct);
|
||||
if (existingId != 0) return existingId;
|
||||
|
||||
var branch = new Branch { Code = DefaultBranchCode, Name = DefaultBranchName, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow };
|
||||
await _branches.AddAsync(branch, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
return branch.BranchId;
|
||||
}
|
||||
|
||||
private static BranchConfigDto Map(BranchConfig c) => new(c.BranchingEnabled, c.UpdatedAt, c.UpdatedBy);
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public sealed class CountService : ICountService
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
|
||||
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
|
||||
PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _counts.Query().AsNoTracking();
|
||||
|
||||
@@ -108,6 +108,7 @@ public sealed class CountService : ICountService
|
||||
}
|
||||
if (status is not null) q = q.Where(c => c.Status == status);
|
||||
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
|
||||
if (branchId is not null) q = q.Where(c => c.Warehouse!.BranchId == branchId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(c => c.CountId)
|
||||
|
||||
@@ -40,18 +40,19 @@ public sealed class DashboardService : IDashboardService
|
||||
_reorder = reorder;
|
||||
}
|
||||
|
||||
public async Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default)
|
||||
public async Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
// Sequential, not Task.WhenAll: these repositories share one scoped DbContext,
|
||||
// which cannot run concurrent operations.
|
||||
var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
var onHandWarehouses = await _layers.Query().AsNoTracking()
|
||||
.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
|
||||
var stockValuationTotal = await _layers.Query().AsNoTracking()
|
||||
.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
|
||||
var layers = _layers.Query().AsNoTracking();
|
||||
if (branchId is not null) layers = layers.Where(l => l.Warehouse!.BranchId == branchId);
|
||||
|
||||
var onHandTotal = await layers.SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m;
|
||||
var onHandWarehouses = await layers.Select(l => l.WarehouseId).Distinct().CountAsync(ct);
|
||||
var stockValuationTotal = await layers.SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m;
|
||||
// EF can't translate constructing WarehouseValuationDto directly inside the GroupBy
|
||||
// Select — project to an anonymous type first, then materialize into the record.
|
||||
var stockValuationByWarehouseRaw = await _layers.Query().AsNoTracking()
|
||||
var stockValuationByWarehouseRaw = await layers
|
||||
.GroupBy(l => l.WarehouseId)
|
||||
.Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) })
|
||||
.OrderByDescending(w => w.Total)
|
||||
@@ -59,17 +60,26 @@ public sealed class DashboardService : IDashboardService
|
||||
var stockValuationByWarehouse = stockValuationByWarehouseRaw
|
||||
.Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total))
|
||||
.ToList();
|
||||
var pendingGrns = await _grns.Query().AsNoTracking().CountAsync(g => g.Status == GrnStatus.Draft, ct);
|
||||
|
||||
var grns = _grns.Query().AsNoTracking();
|
||||
if (branchId is not null) grns = grns.Where(g => g.Warehouse!.BranchId == branchId);
|
||||
var pendingGrns = await grns.CountAsync(g => g.Status == GrnStatus.Draft, ct);
|
||||
|
||||
var counts = _counts.Query().AsNoTracking();
|
||||
if (branchId is not null) counts = counts.Where(c => c.Warehouse!.BranchId == branchId);
|
||||
var pendingCounts = await counts.CountAsync(c => c.Status == CountStatus.Counted, ct);
|
||||
|
||||
// PurchaseOrder/Requisition/Rfq carry no warehouse/branch at header level yet
|
||||
// (docs/BRANCH_ISOLATION_PLAN.md classifies them as needing a new BranchId column,
|
||||
// not yet added) — these three stay global regardless of the selected branch.
|
||||
var pendingApprovalPOs = await _pos.Query().AsNoTracking()
|
||||
.CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct);
|
||||
var openRequisitions = await _requisitions.Query().AsNoTracking()
|
||||
.CountAsync(r => r.Status == RequisitionStatus.Submitted, ct);
|
||||
var pendingCounts = await _counts.Query().AsNoTracking()
|
||||
.CountAsync(c => c.Status == CountStatus.Counted, ct);
|
||||
var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct);
|
||||
|
||||
// PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging.
|
||||
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, new PageQuery { Page = 1, PageSize = 1 }, ct))
|
||||
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
|
||||
.Pagination.TotalItems;
|
||||
|
||||
return new DashboardStatsDto(
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class GrnService : IGrnService
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
|
||||
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _grns.Query().AsNoTracking();
|
||||
|
||||
@@ -86,6 +86,9 @@ public sealed class GrnService : IGrnService
|
||||
if (poId is not null) q = q.Where(g => g.PoId == poId);
|
||||
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
|
||||
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
|
||||
// GRN carries no BranchId of its own — it inherits branch scope transitively via its
|
||||
// Warehouse, same as Sales (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism").
|
||||
if (branchId is not null) q = q.Where(g => g.Warehouse!.BranchId == branchId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(g => g.GrnId)
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class BranchService : IBranchService
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(b => b.Name)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(b => new BranchDto(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt))
|
||||
.Select(b => new BranchDto(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<BranchDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -50,15 +50,12 @@ public sealed class BranchService : IBranchService
|
||||
|
||||
public async Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var code = request.Code.Trim();
|
||||
if (await _branches.Query().AnyAsync(b => b.Code.ToLower() == code.ToLower(), ct))
|
||||
throw new ConflictException($"A branch with code '{code}' already exists.");
|
||||
|
||||
var branch = new Branch
|
||||
{
|
||||
Code = code,
|
||||
Code = await GenerateCodeAsync(ct),
|
||||
Name = request.Name.Trim(),
|
||||
Address = request.Address?.Trim(),
|
||||
PhoneNumber = request.PhoneNumber?.Trim(),
|
||||
Status = EntityStatus.Active,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -69,6 +66,28 @@ public sealed class BranchService : IBranchService
|
||||
return new ETagged<BranchDto>(Map(branch), branch.RowVersion);
|
||||
}
|
||||
|
||||
private const string CodePrefix = "BR-";
|
||||
|
||||
/// <summary>
|
||||
/// Next sequential branch code ("BR-001", "BR-002", ...) — codes are never user-entered.
|
||||
/// Parses the numeric suffix of existing codes rather than trusting a row count, since a
|
||||
/// deactivated-not-deleted branch never frees its slot and counts would drift.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateCodeAsync(CancellationToken ct)
|
||||
{
|
||||
var existingCodes = await _branches.Query().AsNoTracking()
|
||||
.Where(b => b.Code.StartsWith(CodePrefix))
|
||||
.Select(b => b.Code)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var nextNumber = existingCodes
|
||||
.Select(c => int.TryParse(c.AsSpan(CodePrefix.Length), out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max() + 1;
|
||||
|
||||
return $"{CodePrefix}{nextNumber:D3}";
|
||||
}
|
||||
|
||||
public async Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default)
|
||||
{
|
||||
var branch = await _branches.GetByIdAsync(branchId, ct)
|
||||
@@ -79,6 +98,7 @@ public sealed class BranchService : IBranchService
|
||||
|
||||
branch.Name = request.Name.Trim();
|
||||
branch.Address = request.Address?.Trim();
|
||||
branch.PhoneNumber = request.PhoneNumber?.Trim();
|
||||
branch.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
@@ -103,5 +123,5 @@ public sealed class BranchService : IBranchService
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt);
|
||||
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -21,14 +21,19 @@ public sealed class EmployeeService : IEmployeeService
|
||||
private readonly IRepository<Employee> _employees;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<EmployeeBankDetail> _bankDetails;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IBranchAccessService _branchAccess;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public EmployeeService(
|
||||
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails, IUnitOfWork uow)
|
||||
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
|
||||
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
|
||||
{
|
||||
_employees = employees;
|
||||
_users = users;
|
||||
_bankDetails = bankDetails;
|
||||
_branchConfig = branchConfig;
|
||||
_branchAccess = branchAccess;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
@@ -66,7 +71,10 @@ public sealed class EmployeeService : IEmployeeService
|
||||
public async Task<ETagged<EmployeeDetailDto>?> GetAsync(int employeeId, CancellationToken ct = default)
|
||||
{
|
||||
var employee = await _employees.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmployeeId == employeeId, ct);
|
||||
return employee is null ? null : new ETagged<EmployeeDetailDto>(Map(employee), employee.RowVersion);
|
||||
if (employee is null) return null;
|
||||
|
||||
await _branchAccess.EnsureBranchVisibleAsync(employee.BranchId, ct);
|
||||
return new ETagged<EmployeeDetailDto>(Map(employee), employee.RowVersion);
|
||||
}
|
||||
|
||||
public async Task<ETagged<EmployeeDetailDto>> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default)
|
||||
@@ -85,6 +93,8 @@ public sealed class EmployeeService : IEmployeeService
|
||||
linkedUserId = user.UserId;
|
||||
}
|
||||
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
|
||||
var employee = new Employee
|
||||
{
|
||||
EmployeeCode = code,
|
||||
@@ -107,7 +117,7 @@ public sealed class EmployeeService : IEmployeeService
|
||||
DepartmentId = request.DepartmentId,
|
||||
DesignationId = request.DesignationId,
|
||||
EmploymentTypeId = request.EmploymentTypeId,
|
||||
BranchId = request.BranchId,
|
||||
BranchId = branchId,
|
||||
WorkShiftId = request.WorkShiftId,
|
||||
ReportingManagerId = request.ReportingManagerId,
|
||||
EpfNumber = request.EpfNumber?.Trim(),
|
||||
@@ -131,6 +141,8 @@ public sealed class EmployeeService : IEmployeeService
|
||||
var employee = await _employees.GetByIdAsync(employeeId, ct)
|
||||
?? throw new NotFoundException($"Employee {employeeId} was not found.");
|
||||
|
||||
await _branchAccess.EnsureBranchVisibleAsync(employee.BranchId, ct);
|
||||
|
||||
if (employee.RowVersion != expectedRowVersion)
|
||||
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412);
|
||||
|
||||
@@ -154,7 +166,7 @@ public sealed class EmployeeService : IEmployeeService
|
||||
employee.DepartmentId = request.DepartmentId;
|
||||
employee.DesignationId = request.DesignationId;
|
||||
employee.EmploymentTypeId = request.EmploymentTypeId;
|
||||
employee.BranchId = request.BranchId;
|
||||
employee.BranchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
employee.WorkShiftId = request.WorkShiftId;
|
||||
employee.ReportingManagerId = request.ReportingManagerId;
|
||||
employee.EpfNumber = request.EpfNumber?.Trim();
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface IAdjustmentService
|
||||
{
|
||||
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
|
||||
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
|
||||
PageQuery query, int? warehouseId, int? branchId, int? reasonCodeId, CancellationToken ct = default);
|
||||
|
||||
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side branch-scoping guard for detail/update endpoints (docs/BRANCH_ISOLATION_PLAN.md
|
||||
/// "What this doesn't solve by itself" — the Phase-2 enforcement layer the plan flags as
|
||||
/// missing). List endpoints filter by an explicit <c>branchId</c> query param the frontend
|
||||
/// supplies; this instead protects a single record fetched/edited by id, where a non-admin
|
||||
/// could otherwise guess or type another branch's id directly.
|
||||
/// </summary>
|
||||
public interface IBranchAccessService
|
||||
{
|
||||
/// <summary>
|
||||
/// No-op when branching is disabled (docs/BRANCH_ISOLATION_PLAN.md's toggle) or the caller
|
||||
/// is Admin. Otherwise throws <see cref="ERPCore.System.Errors.NotFoundException"/> — not a
|
||||
/// 403 — when <paramref name="branchId"/> isn't the caller's own branch, so a record in
|
||||
/// another branch reads as "doesn't exist" rather than confirming it exists elsewhere.
|
||||
/// </summary>
|
||||
Task EnsureBranchVisibleAsync(int branchId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ERPCore.Common.Http;
|
||||
using ERPCore.Dtos.Config;
|
||||
|
||||
namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>Branch configuration business logic. Singleton.</summary>
|
||||
public interface IBranchConfigService
|
||||
{
|
||||
Task<ETagged<BranchConfigDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ETagged<BranchConfigDto>> UpdateAsync(
|
||||
UpdateBranchConfigRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>True when this deployment requires a branch to be picked explicitly.</summary>
|
||||
Task<bool> IsBranchingEnabledAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// The branch id an Employee/Warehouse write should actually use: <paramref name="requestedBranchId"/>
|
||||
/// as-is when branching is enabled (rejecting an unset one), or the deployment's single
|
||||
/// default branch when branching is disabled — creating that default branch on first use.
|
||||
/// </summary>
|
||||
Task<int> ResolveBranchIdAsync(int requestedBranchId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface ICountService
|
||||
{
|
||||
Task<PagedResponse<CountSummaryDto>> ListAsync(
|
||||
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default);
|
||||
PageQuery query, CountStatus? status, int? warehouseId, int? branchId, CancellationToken ct = default);
|
||||
|
||||
Task<CountDto?> GetAsync(int countId, CancellationToken ct = default);
|
||||
Task<CountDto> CreateAsync(CreateCountRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -5,5 +5,5 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Cross-domain aggregate stats for the dashboard overview.</summary>
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardStatsDto> GetStatsAsync(CancellationToken ct = default);
|
||||
Task<DashboardStatsDto> GetStatsAsync(int? branchId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface IGrnService
|
||||
{
|
||||
Task<PagedResponse<GrnSummaryDto>> ListAsync(
|
||||
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default);
|
||||
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, int? branchId, CancellationToken ct = default);
|
||||
|
||||
Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default);
|
||||
Task<GrnDto> CreateAsync(CreateGrnRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -7,6 +7,6 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Reorder alerts and suggested requisitions (docs/11 §5.7; FR-STK-10).</summary>
|
||||
public interface IReorderService
|
||||
{
|
||||
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, PageQuery query, CancellationToken ct = default);
|
||||
Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
|
||||
Task<RequisitionDto> CreateSuggestedRequisitionAsync(int itemId, int warehouseId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesInvoiceService
|
||||
{
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default);
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -10,10 +10,10 @@ public interface IStockService
|
||||
|
||||
/// <summary>On-hand for every (item, warehouse) pair holding stock — backs the enquiry list.</summary>
|
||||
Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
|
||||
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default);
|
||||
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
Task<StockValuationDto> GetValuationAsync(int itemId, int warehouseId, CancellationToken ct = default);
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface ITransferService
|
||||
{
|
||||
Task<PagedResponse<TransferSummaryDto>> ListAsync(
|
||||
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default);
|
||||
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, int? branchId, CancellationToken ct = default);
|
||||
|
||||
Task<TransferDto?> GetAsync(int transferId, CancellationToken ct = default);
|
||||
Task<TransferDto> CreateAsync(CreateTransferRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -12,7 +12,7 @@ public interface IUserManagementService
|
||||
{
|
||||
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
|
||||
@@ -26,10 +26,11 @@ public sealed class ReorderService : IReorderService
|
||||
_requisitions = requisitions;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, PageQuery query, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<ReorderAlertDto>> GetAlertsAsync(int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _reorders.Query().AsNoTracking();
|
||||
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
|
||||
if (branchId is not null) q = q.Where(r => r.Warehouse!.BranchId == branchId);
|
||||
var policies = await q.OrderBy(r => r.ItemId).ToListAsync(ct);
|
||||
|
||||
var alerts = new List<ReorderAlertDto>();
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -15,20 +16,28 @@ namespace ERPCore.Services;
|
||||
|
||||
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 ICurrentUser _currentUser;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public RoleService(
|
||||
IRepository<Role> roles, IRepository<RolePermission> rolePermissions, IRepository<Permission> permissions,
|
||||
IAuthHexClient authHex, IUnitOfWork uow)
|
||||
IRepository<Employee> employees, ICurrentUser currentUser, IAuthHexClient authHex, IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
{
|
||||
_roles = roles;
|
||||
_rolePermissions = rolePermissions;
|
||||
_permissions = permissions;
|
||||
_employees = employees;
|
||||
_currentUser = currentUser;
|
||||
_authHex = authHex;
|
||||
_branchConfig = branchConfig;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
@@ -184,17 +193,30 @@ public sealed class RoleService : IRoleService
|
||||
|
||||
public async Task<MeResponseDto> GetMeAsync(string? roleCode, 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
|
||||
// when the logged-in account has no linked Employee (docs/12-BACKEND-HRM.md A.5).
|
||||
var employee = _currentUser.IsAuthenticated
|
||||
? await _employees.Query().AsNoTracking()
|
||||
.Include(e => e.Branch)
|
||||
.FirstOrDefaultAsync(e => e.UserId == _currentUser.AuditUserId, ct)
|
||||
: null;
|
||||
var branchId = employee?.BranchId;
|
||||
var branchName = employee?.Branch?.Name;
|
||||
var branchingEnabled = await _branchConfig.IsBranchingEnabledAsync(ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(roleCode))
|
||||
return new MeResponseDto(null, null, Array.Empty<string>());
|
||||
return new MeResponseDto(null, null, Array.Empty<string>(), false, branchId, branchName, branchingEnabled);
|
||||
|
||||
// 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);
|
||||
if (role is null)
|
||||
return new MeResponseDto(roleCode, null, Array.Empty<string>());
|
||||
return new MeResponseDto(roleCode, null, Array.Empty<string>(), isAdmin, branchId, branchName, branchingEnabled);
|
||||
|
||||
var permissions = await _rolePermissions.Query().AsNoTracking()
|
||||
.Where(rp => rp.RoleId == role.RoleId)
|
||||
@@ -210,7 +232,7 @@ public sealed class RoleService : IRoleService
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return new MeResponseDto(role.Code, role.Name, navCodes);
|
||||
return new MeResponseDto(role.Code, role.Name, navCodes, isAdmin, branchId, branchName, branchingEnabled);
|
||||
}
|
||||
|
||||
private static RoleDto Map(Role r) => new(
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -60,6 +60,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
if (status is not null) q = q.Where(x => x.Status == status);
|
||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||
// Sales carries no BranchId of its own — it inherits branch scope transitively via
|
||||
// its Warehouse (docs/BRANCH_ISOLATION_PLAN.md "Anchor mechanism: Warehouse carries the branch").
|
||||
if (branchId is not null) q = q.Where(x => x.Warehouse!.BranchId == branchId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
|
||||
@@ -14,17 +14,30 @@ public sealed class StockService : IStockService
|
||||
private readonly IRepository<StockLayer> _layers;
|
||||
private readonly IRepository<StockLedger> _ledger;
|
||||
private readonly IRepository<StockTransferLine> _transferLines;
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
|
||||
public StockService(
|
||||
IFifoCostingService fifo, IRepository<StockLayer> layers,
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines)
|
||||
IRepository<StockLedger> ledger, IRepository<StockTransferLine> transferLines, IRepository<Warehouse> warehouses)
|
||||
{
|
||||
_fifo = fifo;
|
||||
_layers = layers;
|
||||
_ledger = ledger;
|
||||
_transferLines = transferLines;
|
||||
_warehouses = warehouses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ids of every warehouse in <paramref name="branchId"/>'s branch, or null when no branch
|
||||
/// filter applies. Stock entities carry only a raw <c>WarehouseId</c> (no navigation
|
||||
/// property, by design — see <c>StockLedger</c>'s doc comment), so branch-scoping goes
|
||||
/// through this id list rather than a LINQ join on a nav property.
|
||||
/// </summary>
|
||||
private async Task<List<int>?> ResolveBranchWarehouseIdsAsync(int? branchId, CancellationToken ct)
|
||||
=> branchId is null
|
||||
? null
|
||||
: await _warehouses.Query().AsNoTracking().Where(w => w.BranchId == branchId).Select(w => w.WarehouseId).ToListAsync(ct);
|
||||
|
||||
public async Task<StockOnHandDto> GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var onHand = await _fifo.GetOnHandAsync(itemId, warehouseId, ct);
|
||||
@@ -58,11 +71,13 @@ public sealed class StockService : IStockService
|
||||
/// warehouse simply does not appear.
|
||||
/// </summary>
|
||||
public async Task<PagedResponse<StockOnHandDto>> GetOnHandListAsync(
|
||||
int? itemId, int? warehouseId, PageQuery query, CancellationToken ct = default)
|
||||
int? itemId, int? warehouseId, int? branchId, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var layers = _layers.Query().AsNoTracking();
|
||||
if (itemId is not null) layers = layers.Where(l => l.ItemId == itemId);
|
||||
if (warehouseId is not null) layers = layers.Where(l => l.WarehouseId == warehouseId);
|
||||
var branchWarehouseIds = await ResolveBranchWarehouseIdsAsync(branchId, ct);
|
||||
if (branchWarehouseIds is not null) layers = layers.Where(l => branchWarehouseIds.Contains(l.WarehouseId));
|
||||
|
||||
var grouped = layers
|
||||
.GroupBy(l => new { l.ItemId, l.WarehouseId })
|
||||
@@ -116,12 +131,14 @@ public sealed class StockService : IStockService
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<StockLedgerRowDto>> GetLedgerAsync(
|
||||
int? itemId, int? warehouseId, DateOnly? from, DateOnly? to,
|
||||
int? itemId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to,
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _ledger.Query().AsNoTracking();
|
||||
if (itemId is not null) q = q.Where(l => l.ItemId == itemId);
|
||||
if (warehouseId is not null) q = q.Where(l => l.WarehouseId == warehouseId);
|
||||
var branchWarehouseIds = await ResolveBranchWarehouseIdsAsync(branchId, ct);
|
||||
if (branchWarehouseIds is not null) q = q.Where(l => branchWarehouseIds.Contains(l.WarehouseId));
|
||||
if (from is not null) q = q.Where(l => l.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(l => l.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
// Source-doc filter: the ledger references its originating document polymorphically
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class TransferService : ITransferService
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<TransferSummaryDto>> ListAsync(
|
||||
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, CancellationToken ct = default)
|
||||
PageQuery query, TransferStatus? status, int? srcWarehouseId, int? destWarehouseId, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
var q = _transfers.Query().AsNoTracking();
|
||||
|
||||
@@ -62,6 +62,9 @@ public sealed class TransferService : ITransferService
|
||||
if (status is not null) q = q.Where(t => t.Status == status);
|
||||
if (srcWarehouseId is not null) q = q.Where(t => t.SrcWarehouseId == srcWarehouseId);
|
||||
if (destWarehouseId is not null) q = q.Where(t => t.DestWarehouseId == destWarehouseId);
|
||||
// A transfer touches two branches when it crosses one — show it on either side
|
||||
// rather than only the branch that happens to hold the source warehouse.
|
||||
if (branchId is not null) q = q.Where(t => t.SrcWarehouse!.BranchId == branchId || t.DestWarehouse!.BranchId == branchId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(t => t.TransferId)
|
||||
|
||||
@@ -2,6 +2,7 @@ using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.UoW;
|
||||
@@ -16,20 +17,24 @@ public sealed class UserManagementService : IUserManagementService
|
||||
{
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<Role> _roles;
|
||||
private readonly IRepository<Employee> _employees;
|
||||
private readonly IAuthUserService _authUsers;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IEmployeeUserLinkService _links;
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public UserManagementService(
|
||||
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex,
|
||||
IEmployeeUserLinkService links, IUnitOfWork uow)
|
||||
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
|
||||
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
|
||||
{
|
||||
_users = users;
|
||||
_roles = roles;
|
||||
_employees = employees;
|
||||
_authUsers = authUsers;
|
||||
_authHex = authHex;
|
||||
_links = links;
|
||||
_employeeService = employeeService;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
@@ -57,11 +62,30 @@ public sealed class UserManagementService : IUserManagementService
|
||||
return user is null ? null : Map(user);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, CancellationToken ct = default)
|
||||
public async Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default)
|
||||
{
|
||||
var role = await _roles.GetByIdAsync(request.RoleId, ct)
|
||||
?? throw new NotFoundException($"Role {request.RoleId} was not found.");
|
||||
|
||||
// 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);
|
||||
string? employeeCode = null;
|
||||
if (!isAdminRole && request.LinkEmployeeId is null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.EmployeeCode) || request.DepartmentId is null
|
||||
|| request.DesignationId is null || request.EmploymentTypeId is null || request.WorkShiftId is null)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.Validation,
|
||||
"Non-admin users must be assigned to a branch: employeeCode, branchId, departmentId, designationId, employmentTypeId and workShiftId are required.", 422);
|
||||
}
|
||||
|
||||
employeeCode = request.EmployeeCode.Trim();
|
||||
if (await _employees.Query().AnyAsync(e => e.EmployeeCode.ToLower() == employeeCode.ToLower(), ct))
|
||||
throw new DomainException(ErrorCodes.EmployeeCodeDuplicate, $"An employee with code '{employeeCode}' already exists.", 400);
|
||||
}
|
||||
|
||||
var username = request.Username.Trim();
|
||||
if (await _users.Query().AnyAsync(u => u.Username == username, ct))
|
||||
throw new ConflictException($"A user with username '{username}' already exists.");
|
||||
@@ -103,7 +127,27 @@ public sealed class UserManagementService : IUserManagementService
|
||||
|
||||
// Explicit, human-confirmed link to an existing Staff record (never automatic).
|
||||
if (request.LinkEmployeeId is not null)
|
||||
{
|
||||
await _links.LinkAsync(request.LinkEmployeeId.Value, user.UserId, ct);
|
||||
}
|
||||
else if (!isAdminRole)
|
||||
{
|
||||
await _employeeService.CreateAsync(new CreateEmployeeRequest
|
||||
{
|
||||
EmployeeCode = employeeCode!,
|
||||
FullName = request.FullName.Trim(),
|
||||
Email = request.Email.Trim(),
|
||||
PersonalMobile = request.MobileNumber,
|
||||
Nic = request.Nic,
|
||||
HireDate = request.HireDate ?? DateTime.UtcNow.Date,
|
||||
DepartmentId = request.DepartmentId!.Value,
|
||||
DesignationId = request.DesignationId!.Value,
|
||||
EmploymentTypeId = request.EmploymentTypeId!.Value,
|
||||
BranchId = request.BranchId ?? 0,
|
||||
WorkShiftId = request.WorkShiftId!.Value,
|
||||
LinkUserId = user.UserId
|
||||
}, actorUserId, ct);
|
||||
}
|
||||
|
||||
user.Role = role;
|
||||
return Map(user);
|
||||
|
||||
@@ -13,18 +13,24 @@ public sealed class WarehouseService : IWarehouseService
|
||||
{
|
||||
private readonly IRepository<Warehouse> _warehouses;
|
||||
private readonly IRepository<Bin> _bins;
|
||||
private readonly IRepository<Branch> _branches;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public WarehouseService(IRepository<Warehouse> warehouses, IRepository<Bin> bins, IUnitOfWork uow)
|
||||
public WarehouseService(
|
||||
IRepository<Warehouse> warehouses, IRepository<Bin> bins, IRepository<Branch> branches,
|
||||
IBranchConfigService branchConfig, IUnitOfWork uow)
|
||||
{
|
||||
_warehouses = warehouses;
|
||||
_bins = bins;
|
||||
_branches = branches;
|
||||
_branchConfig = branchConfig;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _warehouses.Query().AsNoTracking();
|
||||
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
@@ -34,7 +40,7 @@ public sealed class WarehouseService : IWarehouseService
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(w => w.Code)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name))
|
||||
.Select(w => new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.BranchId, w.Branch != null ? w.Branch.Name : null))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<WarehouseDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
@@ -42,9 +48,9 @@ public sealed class WarehouseService : IWarehouseService
|
||||
|
||||
public async Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default)
|
||||
{
|
||||
var w = await _warehouses.Query().AsNoTracking()
|
||||
var w = await _warehouses.Query().AsNoTracking().Include(x => x.Branch)
|
||||
.FirstOrDefaultAsync(x => x.WarehouseId == warehouseId, ct);
|
||||
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name);
|
||||
return w is null ? null : new WarehouseDto(w.WarehouseId, w.Code, w.Name, w.BranchId, w.Branch?.Name);
|
||||
}
|
||||
|
||||
public async Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default)
|
||||
@@ -53,11 +59,14 @@ public sealed class WarehouseService : IWarehouseService
|
||||
if (await _warehouses.Query().AnyAsync(w => w.Code == code, ct))
|
||||
throw new ConflictException($"A warehouse with code '{code}' already exists.");
|
||||
|
||||
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim() };
|
||||
var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct);
|
||||
var warehouse = new Warehouse { Code = code, Name = request.Name.Trim(), BranchId = branchId };
|
||||
await _warehouses.AddAsync(warehouse, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name);
|
||||
var branchName = await _branches.Query().AsNoTracking()
|
||||
.Where(b => b.BranchId == warehouse.BranchId).Select(b => b.Name).FirstOrDefaultAsync(ct);
|
||||
return new WarehouseDto(warehouse.WarehouseId, warehouse.Code, warehouse.Name, warehouse.BranchId, branchName);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BinDto>> ListBinsAsync(int warehouseId, CancellationToken ct = default)
|
||||
|
||||
Reference in New Issue
Block a user