emloyee and user fixings

This commit is contained in:
2026-09-11 10:32:47 +05:30
parent 629f3d1f44
commit cb41d503c4
61 changed files with 35831 additions and 1228 deletions
+56 -1
View File
@@ -1,7 +1,10 @@
using System.Text.Json;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Audit;
using ERPCore.Dtos.Common;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
@@ -12,11 +15,19 @@ public sealed class AuditService : IAuditService
{
private readonly IRepository<AuditLog> _logs;
private readonly IRepository<JournalEntryStub> _journal;
private readonly IRepository<User> _users;
private readonly IRepository<LoginActivity> _loginActivities;
private readonly IUnitOfWork _uow;
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal)
public AuditService(
IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal, IRepository<User> users,
IRepository<LoginActivity> loginActivities, IUnitOfWork uow)
{
_logs = logs;
_journal = journal;
_users = users;
_loginActivities = loginActivities;
_uow = uow;
}
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
@@ -55,4 +66,48 @@ public sealed class AuditService : IAuditService
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default)
{
var userId = await _users.Query().AsNoTracking()
.Where(u => u.AuthUserId == authUserId)
.Select(u => (int?)u.UserId)
.FirstOrDefaultAsync(ct);
// JIT provisioning may not have created the local shadow row yet on a brand-new
// account's very first login — nothing to attach the log to in that case.
if (userId is null) return;
var now = DateTime.UtcNow;
await _logs.AddAsync(new AuditLog
{
UserId = userId.Value,
EntityType = "User",
EntityId = userId.Value,
Action = AuditAction.Login,
ChangeSet = "{}",
CreatedAt = now
}, ct);
await _loginActivities.AddAsync(new LoginActivity
{
UserId = userId.Value,
IpAddress = ipAddress,
UserAgent = userAgent,
DeviceLabel = UserAgentLabeler.Label(userAgent),
LoginAt = now
}, ct);
await _uow.SaveChangesAsync(ct);
}
public async Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default)
{
var q = _loginActivities.Query().AsNoTracking().Where(l => l.UserId == userId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(l => l.LoginActivityId)
.Skip(query.Skip).Take(query.PageSize)
.Select(l => new LoginActivityDto(l.LoginActivityId, l.IpAddress, l.DeviceLabel, l.LoginAt))
.ToListAsync(ct);
return PagedResponse<LoginActivityDto>.Create(rows, query.Page, query.PageSize, total);
}
}
@@ -123,5 +123,21 @@ public sealed class BranchService : IBranchService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int branchId, CancellationToken ct = default)
{
var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found.");
_branches.Remove(branch);
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
throw new DomainException(ErrorCodes.MasterInUse, "This branch is referenced by other records and cannot be deleted.", 409);
}
}
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
}
@@ -3,6 +3,7 @@ using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
@@ -21,17 +22,31 @@ public sealed class EmployeeService : IEmployeeService
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IRepository<EmployeeDocument> _documents;
private readonly IRepository<EmployeeSalaryStructure> _salaryStructures;
private readonly IRepository<EmployeeLoan> _loans;
private readonly IRepository<LeaveBalance> _leaveBalances;
private readonly IRepository<PayrollLine> _payrollLines;
private readonly IFileStorageService _fileStorage;
private readonly IBranchConfigService _branchConfig;
private readonly IBranchAccessService _branchAccess;
private readonly IUnitOfWork _uow;
public EmployeeService(
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
IRepository<EmployeeDocument> documents, IRepository<EmployeeSalaryStructure> salaryStructures,
IRepository<EmployeeLoan> loans, IRepository<LeaveBalance> leaveBalances, IRepository<PayrollLine> payrollLines,
IFileStorageService fileStorage, IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
{
_employees = employees;
_users = users;
_bankDetails = bankDetails;
_documents = documents;
_salaryStructures = salaryStructures;
_loans = loans;
_leaveBalances = leaveBalances;
_payrollLines = payrollLines;
_fileStorage = fileStorage;
_branchConfig = branchConfig;
_branchAccess = branchAccess;
_uow = uow;
@@ -197,6 +212,48 @@ public sealed class EmployeeService : IEmployeeService
await _uow.SaveChangesAsync(ct);
}
public async Task DeleteAsync(int employeeId, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
if (await _payrollLines.Query().AnyAsync(l => l.EmployeeId == employeeId, ct))
throw new DomainException(ErrorCodes.EmployeeHasPayrollHistory, "This employee has payroll history and cannot be deleted.", 409);
// A linked system-user account survives the employee row (Employee.UserId → User is
// Restrict, so cascading it away isn't even an option) — deactivate it (same as the
// Active/Deactivate toggle on Settings > Users) rather than leaving a login standing
// with no staff record behind it. The User row itself is never removed.
if (employee.UserId is not null)
{
var linkedUser = await _users.GetByIdAsync(employee.UserId.Value, ct);
if (linkedUser is not null) linkedUser.Status = EntityStatus.Inactive;
}
// Bank details and documents cascade at the DB level, but documents also have a
// physical file on disk that cascading the row would silently orphan — delete those
// explicitly first. Salary structures, loans, and leave balances are Restrict FKs, so
// they must be removed before the employee row itself will delete.
var documents = await _documents.Query().Where(d => d.EmployeeId == employeeId).ToListAsync(ct);
foreach (var doc in documents)
{
await _fileStorage.DeleteAsync(doc.RelativePath, ct);
_documents.Remove(doc);
}
var salaryStructures = await _salaryStructures.Query().Where(s => s.EmployeeId == employeeId).ToListAsync(ct);
foreach (var structure in salaryStructures) _salaryStructures.Remove(structure);
var loans = await _loans.Query().Where(l => l.EmployeeId == employeeId).ToListAsync(ct);
foreach (var loan in loans) _loans.Remove(loan);
var leaveBalances = await _leaveBalances.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct);
foreach (var balance in leaveBalances) _leaveBalances.Remove(balance);
_employees.Remove(employee);
await _uow.SaveChangesAsync(ct);
}
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
{
return await _bankDetails.Query().AsNoTracking()
@@ -5,7 +5,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
/// 02-SECURITY B.2.3). Both are append-only; no write API.
/// 02-SECURITY B.2.3). Both are append-only — entity mutations are captured
/// automatically by ErpDbContext.SaveChangesAsync and never written here directly.
/// The one deliberate exception is <see cref="RecordLoginAsync"/>: a successful
/// sign-in changes no entity, so nothing would otherwise appear in the trail for it.
/// </summary>
public interface IAuditService
{
@@ -14,4 +17,12 @@ public interface IAuditService
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
/// <summary>Writes a Login audit row and a login-device history entry for the local User backing this
/// AuthHex identity, if one exists yet.</summary>
Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default);
/// <summary>Past logins for a user — a history ERPCore captures itself, not a live "currently active
/// sessions" view (AuthHex has no admin-scoped call to inspect another user's sessions).</summary>
Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default);
}
@@ -13,4 +13,5 @@ public interface IBranchService
Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default);
Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default);
Task DeleteAsync(int branchId, CancellationToken ct = default);
}
@@ -15,6 +15,14 @@ public interface IEmployeeService
Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default);
Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default);
/// <summary>
/// Permanently deletes the employee and every dependent record (bank details, documents
/// incl. their stored files, salary structure history, loans, leave balances). Blocked with
/// 409 if the employee has any payroll history, since that's financial/audit record that
/// must never be destroyed.
/// </summary>
Task DeleteAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
@@ -10,11 +11,21 @@ namespace ERPCore.Services.Interfaces;
/// </summary>
public interface IUserManagementService
{
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, 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);
/// <summary>
/// Flips the local shadow row's status (Active/Inactive) — this is what
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
/// is self-service, acting on whoever's bearer token is passed), so the
/// credential itself stays live upstream; this only revokes the account's
/// standing inside ERPCore.
/// </summary>
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
}
@@ -6,7 +6,7 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Warehouse &amp; bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public interface IWarehouseService
{
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default);
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default);
Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default);
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
@@ -18,27 +18,25 @@ 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, IRepository<Employee> employees, IAuthUserService authUsers,
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees,
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
{
_users = users;
_roles = roles;
_employees = employees;
_authUsers = authUsers;
_authHex = authHex;
_links = links;
_employeeService = employeeService;
_uow = uow;
}
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
{
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -46,6 +44,8 @@ public sealed class UserManagementService : IUserManagementService
var term = query.Q.Trim();
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
}
if (status is not null) q = q.Where(u => u.Status == status);
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && EF.Functions.ILike(u.Role.Code, roleCode));
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(u => u.Username)
@@ -93,8 +93,13 @@ public sealed class UserManagementService : IUserManagementService
var authUserId = Guid.NewGuid();
// Source of truth: AuthHex creates the credential + emails it (registerUser,
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs).
await _authUsers.RegisterAsync(new RegisterRequest
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs). Calls the raw
// AuthHex client directly rather than IAuthUserService.RegisterAsync — that
// wrapper is shaped for self-registration (AuthController.Register) and demands
// a session (access/refresh tokens) in the response, which makes no sense here:
// an admin creating an account for someone else never logs in as them, so
// AuthHex's registerUser response for this case carries no tokens to demand.
await _authHex.RegisterAsync(new RegisterRequest
{
UserId = authUserId,
RoleId = role.AuthRoleId,
@@ -167,6 +172,17 @@ public sealed class UserManagementService : IUserManagementService
return Map(user);
}
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
{
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
user.Status = status;
await _uow.SaveChangesAsync(ct);
return Map(user);
}
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
{
var userTypes = await _authHex.ListUserTypesAsync(ct);
+2 -1
View File
@@ -28,7 +28,7 @@ public sealed class WarehouseService : IWarehouseService
_uow = uow;
}
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default)
{
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -36,6 +36,7 @@ public sealed class WarehouseService : IWarehouseService
var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
}
if (branchId is not null) q = q.Where(w => w.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Code)