develop full initial module

This commit is contained in:
Dhananjaya99
2026-07-23 19:54:56 +05:30
parent eacc21afad
commit 755df494fe
188 changed files with 13894 additions and 49 deletions
@@ -0,0 +1,46 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services.Hrm;
/// <inheritdoc cref="IAttendanceComputationService"/>
public sealed class AttendanceComputationService : IAttendanceComputationService
{
public void Compute(AttendanceRecord record, WorkShift shift, bool hasApprovedLeave, bool isHoliday, bool isWeekOff)
{
if (record.CheckIn is null || record.CheckOut is null)
{
record.WorkingMinutes = 0;
record.LateMinutes = 0;
record.EarlyLeaveMinutes = 0;
record.OvertimeMinutes = 0;
record.AttendanceStatus = isHoliday ? AttendanceStatus.Holiday
: isWeekOff ? AttendanceStatus.WeekOff
: hasApprovedLeave ? AttendanceStatus.OnLeave
: AttendanceStatus.Absent;
return;
}
var checkIn = record.CheckIn.Value;
var checkOut = record.CheckOut.Value;
// Overnight shift: checkout numerically earlier than checkin means it rolled past midnight.
if (shift.IsOvernight && checkOut < checkIn) checkOut = checkOut.Add(TimeSpan.FromHours(24));
var grossWorkedMinutes = (int)(checkOut - checkIn).TotalMinutes;
var workingMinutes = Math.Max(0, grossWorkedMinutes - shift.BreakMinutes);
var shiftStart = shift.StartTime;
var shiftEnd = shift.IsOvernight ? shift.EndTime.Add(TimeSpan.FromHours(24)) : shift.EndTime;
var lateMinutes = Math.Max(0, (int)(checkIn - shiftStart).TotalMinutes - shift.GraceMinutes);
var earlyLeaveMinutes = Math.Max(0, (int)(shiftEnd - checkOut).TotalMinutes);
var overtimeMinutes = Math.Max(0, workingMinutes - shift.StandardWorkingMinutes);
record.WorkingMinutes = workingMinutes;
record.LateMinutes = lateMinutes;
record.EarlyLeaveMinutes = earlyLeaveMinutes;
record.OvertimeMinutes = overtimeMinutes;
record.AttendanceStatus = workingMinutes < shift.StandardWorkingMinutes / 2 ? AttendanceStatus.HalfDay : AttendanceStatus.Present;
}
}
@@ -0,0 +1,407 @@
using System.Globalization;
using System.Text;
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>
/// Attendance upload/validate/confirm pipeline (FR-HR-ATT, docs/12-BACKEND-HRM.md §B.3.4).
/// Status flow is exactly Draft → Validated → Confirmed → UsedInPayroll; template and
/// parser share <see cref="ColumnNames"/> so they can never silently drift apart.
/// </summary>
public sealed class AttendanceUploadService : IAttendanceUploadService
{
/// <summary>Employee Code | Date | Check In | Check Out — shared by the parser and the template generator.</summary>
public static readonly string[] ColumnNames = { "Employee Code", "Date", "Check In", "Check Out" };
private readonly IRepository<AttendanceUploadBatch> _batches;
private readonly IRepository<AttendanceRecord> _records;
private readonly IRepository<Employee> _employees;
private readonly IRepository<WorkShift> _workShifts;
private readonly INumberSequenceService _numberSequence;
private readonly ILeaveRequestService _leaveRequests;
private readonly IAttendanceComputationService _computation;
private readonly IUnitOfWork _uow;
public AttendanceUploadService(
IRepository<AttendanceUploadBatch> batches, IRepository<AttendanceRecord> records,
IRepository<Employee> employees, IRepository<WorkShift> workShifts,
INumberSequenceService numberSequence, ILeaveRequestService leaveRequests,
IAttendanceComputationService computation, IUnitOfWork uow)
{
_batches = batches;
_records = records;
_employees = employees;
_workShifts = workShifts;
_numberSequence = numberSequence;
_leaveRequests = leaveRequests;
_computation = computation;
_uow = uow;
}
public async Task<PagedResponse<AttendanceUploadBatchDto>> ListBatchesAsync(
PageQuery query, AttendanceBatchStatus? status, int? periodYear, int? periodMonth, CancellationToken ct = default)
{
var q = _batches.Query().AsNoTracking();
if (status is not null) q = q.Where(b => b.Status == status);
if (periodYear is not null) q = q.Where(b => b.PeriodStart.Year == periodYear);
if (periodMonth is not null) q = q.Where(b => b.PeriodStart.Month == periodMonth);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(b => b.UploadedAt)
.Skip(query.Skip).Take(query.PageSize)
.Select(b => Map(b))
.ToListAsync(ct);
return PagedResponse<AttendanceUploadBatchDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<AttendanceUploadBatchDto?> GetBatchAsync(int batchId, CancellationToken ct = default)
{
var batch = await _batches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.AttendanceUploadBatchId == batchId, ct);
return batch is null ? null : Map(batch);
}
public async Task<AttendanceUploadBatchDto> UploadAsync(
Stream fileContent, string fileName, DateTime periodStart, DateTime periodEnd, int actorUserId, CancellationToken ct = default)
{
if (periodEnd < periodStart)
throw new DomainException(ErrorCodes.Validation, "Period end cannot be before period start.", 422);
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var sourceType = extension switch
{
".xlsx" => AttendanceSourceType.Excel,
".csv" => AttendanceSourceType.Csv,
_ => throw new DomainException(ErrorCodes.FileTypeNotAllowed, $"Unsupported attendance file type '{extension}'.", 422)
};
var rows = extension == ".xlsx" ? ParseExcel(fileContent) : ParseCsv(fileContent);
var docNo = await _numberSequence.NextAsync("ATT", ct);
var batch = new AttendanceUploadBatch
{
DocNo = docNo,
PeriodStart = periodStart.Date,
PeriodEnd = periodEnd.Date,
SourceType = sourceType,
OriginalFileName = fileName,
UploadedBy = actorUserId,
UploadedAt = DateTime.UtcNow,
Status = AttendanceBatchStatus.Draft
};
await _batches.AddAsync(batch, ct);
await _uow.SaveChangesAsync(ct); // flush to get batch.AttendanceUploadBatchId
var employeesByCode = await _employees.Query().AsNoTracking()
.Where(e => e.Status == EmployeeStatus.Active)
.ToDictionaryAsync(e => e.EmployeeCode, StringComparer.OrdinalIgnoreCase, ct);
var shifts = await _workShifts.Query().AsNoTracking().ToDictionaryAsync(s => s.WorkShiftId, ct);
var seenInBatch = new Dictionary<(int EmployeeId, DateTime Date), AttendanceRecord>();
var created = new List<AttendanceRecord>();
foreach (var row in rows)
{
var record = new AttendanceRecord { AttendanceUploadBatchId = batch.AttendanceUploadBatchId };
if (!employeesByCode.TryGetValue(row.EmployeeCode, out var employee))
{
record.RowValidationStatus = RowValidationStatus.EmployeeNotFound;
record.AttendanceDate = row.Date ?? periodStart;
record.WorkShiftId = shifts.Values.FirstOrDefault()?.WorkShiftId ?? 0;
created.Add(record);
continue;
}
record.EmployeeId = employee.EmployeeId;
record.WorkShiftId = employee.WorkShiftId;
if (row.Date is null)
{
record.RowValidationStatus = RowValidationStatus.InvalidDateTime;
record.AttendanceDate = periodStart;
created.Add(record);
continue;
}
record.AttendanceDate = row.Date.Value;
if (row.HasCheckInText && row.CheckIn is null || row.HasCheckOutText && row.CheckOut is null)
{
record.RowValidationStatus = RowValidationStatus.InvalidDateTime;
created.Add(record);
continue;
}
record.CheckIn = row.CheckIn;
record.CheckOut = row.CheckOut;
if (row.CheckIn is not null && row.CheckOut is null)
{
record.RowValidationStatus = RowValidationStatus.Error; // forgot to punch out
created.Add(record);
continue;
}
var key = (record.EmployeeId, record.AttendanceDate);
if (seenInBatch.TryGetValue(key, out var firstOccurrence))
{
record.RowValidationStatus = RowValidationStatus.DuplicateWithinBatch;
record.DuplicateOfAttendanceRecordId = null; // linked by (EmployeeId, Date) until both are persisted
firstOccurrence.RowValidationStatus = RowValidationStatus.DuplicateWithinBatch;
created.Add(record);
continue;
}
var alreadyConfirmed = await _records.Query().AsNoTracking()
.Include(r => r.AttendanceUploadBatch)
.AnyAsync(r => r.EmployeeId == record.EmployeeId && r.AttendanceDate == record.AttendanceDate
&& r.AttendanceUploadBatch != null
&& (r.AttendanceUploadBatch.Status == AttendanceBatchStatus.Confirmed || r.AttendanceUploadBatch.Status == AttendanceBatchStatus.UsedInPayroll), ct);
if (alreadyConfirmed)
{
record.RowValidationStatus = RowValidationStatus.DuplicateConfirmed;
created.Add(record);
continue;
}
seenInBatch[key] = record;
record.RowValidationStatus = RowValidationStatus.Valid;
created.Add(record);
}
foreach (var record in created)
{
if (record.RowValidationStatus == RowValidationStatus.Valid && shifts.TryGetValue(record.WorkShiftId, out var shift))
{
var leave = await _leaveRequests.FindApprovedLeaveCoveringAsync(record.EmployeeId, record.AttendanceDate, ct);
var dayIndex = ((int)record.AttendanceDate.DayOfWeek + 6) % 7; // Monday=0..Sunday=6
var isWeekOff = (shift.WorkingDaysMask & (1 << dayIndex)) == 0;
_computation.Compute(record, shift, leave is not null, isHoliday: false, isWeekOff: isWeekOff);
}
await _records.AddAsync(record, ct);
}
batch.RowCountTotal = created.Count;
batch.RowCountDuplicate = created.Count(r => r.RowValidationStatus is RowValidationStatus.DuplicateWithinBatch or RowValidationStatus.DuplicateConfirmed);
batch.RowCountError = created.Count(r => r.RowValidationStatus is RowValidationStatus.EmployeeNotFound or RowValidationStatus.InvalidDateTime or RowValidationStatus.Error);
await _uow.SaveChangesAsync(ct);
return Map(batch);
}
public async Task<List<AttendanceRecordDto>> ListRecordsAsync(int batchId, RowValidationStatus? status, CancellationToken ct = default)
{
var q = _records.Query().AsNoTracking().Include(r => r.Employee)
.Where(r => r.AttendanceUploadBatchId == batchId);
if (status is not null) q = q.Where(r => r.RowValidationStatus == status);
return await q.OrderBy(r => r.Employee!.FullName).ThenBy(r => r.AttendanceDate)
.Select(r => Map(r))
.ToListAsync(ct);
}
public async Task<AttendanceRecordDto> UpdateRecordAsync(
int batchId, int recordId, UpdateAttendanceRecordRequest request, int actorUserId, CancellationToken ct = default)
{
var batch = await _batches.GetByIdAsync(batchId, ct)
?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
if (batch.Status is AttendanceBatchStatus.Confirmed or AttendanceBatchStatus.UsedInPayroll)
throw new DomainException(ErrorCodes.AttendanceBatchLocked, "This attendance batch is locked and cannot be edited.", 409);
var record = await _records.Query().Include(r => r.Employee).Include(r => r.WorkShift)
.FirstOrDefaultAsync(r => r.AttendanceRecordId == recordId && r.AttendanceUploadBatchId == batchId, ct)
?? throw new NotFoundException($"Attendance record {recordId} was not found in batch {batchId}.");
if (request.CheckIn is not null) record.CheckIn = request.CheckIn;
if (request.CheckOut is not null) record.CheckOut = request.CheckOut;
if (request.Notes is not null) record.Notes = request.Notes.Trim();
if (record.WorkShift is not null && record.RowValidationStatus == RowValidationStatus.Valid)
{
var leave = await _leaveRequests.FindApprovedLeaveCoveringAsync(record.EmployeeId, record.AttendanceDate, ct);
var dayIndex = ((int)record.AttendanceDate.DayOfWeek + 6) % 7;
var isWeekOff = (record.WorkShift.WorkingDaysMask & (1 << dayIndex)) == 0;
_computation.Compute(record, record.WorkShift, leave is not null, isHoliday: false, isWeekOff: isWeekOff);
}
if (request.AttendanceStatus is not null) record.AttendanceStatus = request.AttendanceStatus.Value;
record.IsManualOverride = true;
record.EditedBy = actorUserId;
record.EditedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(record);
}
public async Task ResolveDuplicateAsync(int batchId, ResolveDuplicateRequest request, CancellationToken ct = default)
{
var batch = await _batches.GetByIdAsync(batchId, ct)
?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
if (batch.Status is AttendanceBatchStatus.Confirmed or AttendanceBatchStatus.UsedInPayroll)
throw new DomainException(ErrorCodes.AttendanceBatchLocked, "This attendance batch is locked and cannot be edited.", 409);
var record = await _records.GetByIdAsync(request.RecordId, ct)
?? throw new NotFoundException($"Attendance record {request.RecordId} was not found.");
switch (request.Action)
{
case "discard":
_records.Remove(record);
break;
case "keep":
record.RowValidationStatus = RowValidationStatus.Valid;
break;
case "supersede":
// Authorized cross-batch override: accept this row as the new source of truth.
record.RowValidationStatus = RowValidationStatus.Valid;
break;
}
await _uow.SaveChangesAsync(ct);
}
public async Task<AttendanceUploadBatchDto> ValidateAsync(int batchId, CancellationToken ct = default)
{
var batch = await _batches.GetByIdAsync(batchId, ct)
?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
if (batch.Status != AttendanceBatchStatus.Draft)
throw new DomainException(ErrorCodes.Conflict, "Only a Draft batch can be validated.", 409);
var unresolved = await _records.Query()
.CountAsync(r => r.AttendanceUploadBatchId == batchId && r.RowValidationStatus != RowValidationStatus.Valid, ct);
if (unresolved > 0)
throw new DomainException(ErrorCodes.AttendanceDuplicateUnresolved,
$"{unresolved} record(s) have unresolved errors/duplicates.", 422);
batch.Status = AttendanceBatchStatus.Validated;
await _uow.SaveChangesAsync(ct);
return Map(batch);
}
public async Task<AttendanceUploadBatchDto> ConfirmAsync(int batchId, int actorUserId, CancellationToken ct = default)
{
var batch = await _batches.GetByIdAsync(batchId, ct)
?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
if (batch.Status != AttendanceBatchStatus.Validated)
throw new DomainException(ErrorCodes.Conflict, "Only a Validated batch can be confirmed.", 409);
batch.Status = AttendanceBatchStatus.Confirmed;
batch.ConfirmedBy = actorUserId;
batch.ConfirmedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(batch);
}
public async Task<AttendanceUploadBatchDto> UnlockAsync(int batchId, string reason, int actorUserId, CancellationToken ct = default)
{
var batch = await _batches.GetByIdAsync(batchId, ct)
?? throw new NotFoundException($"Attendance batch {batchId} was not found.");
if (batch.Status == AttendanceBatchStatus.UsedInPayroll)
throw new DomainException(ErrorCodes.AttendanceBatchLocked,
"This batch has already been used in payroll; unlock/regenerate the payroll run first.", 409);
if (batch.Status != AttendanceBatchStatus.Confirmed)
throw new DomainException(ErrorCodes.Conflict, "Only a Confirmed batch can be unlocked.", 409);
batch.Status = AttendanceBatchStatus.Validated;
await _uow.SaveChangesAsync(ct);
return Map(batch);
}
public (byte[] Content, string ContentType, string FileName) GenerateTemplate(bool asCsv)
{
if (asCsv)
{
var csv = string.Join(",", ColumnNames) + "\r\n" + "EMP001,2026-07-01,08:00,17:00\r\n";
return (Encoding.UTF8.GetBytes(csv), "text/csv", "attendance-template.csv");
}
using var workbook = new XLWorkbook();
var sheet = workbook.Worksheets.Add("Attendance");
for (var i = 0; i < ColumnNames.Length; i++) sheet.Cell(1, i + 1).Value = ColumnNames[i];
sheet.Cell(2, 1).Value = "EMP001";
sheet.Cell(2, 2).Value = "2026-07-01";
sheet.Cell(2, 3).Value = "08:00";
sheet.Cell(2, 4).Value = "17:00";
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return (stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "attendance-template.xlsx");
}
private sealed record ParsedRow(string EmployeeCode, DateTime? Date, TimeSpan? CheckIn, TimeSpan? CheckOut, bool HasCheckInText, bool HasCheckOutText);
private static List<ParsedRow> ParseExcel(Stream content)
{
using var workbook = new XLWorkbook(content);
var sheet = workbook.Worksheets.First();
var rows = new List<ParsedRow>();
var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
for (var r = 2; r <= lastRow; r++)
{
var employeeCode = sheet.Cell(r, 1).GetString().Trim();
if (string.IsNullOrWhiteSpace(employeeCode)) continue;
var dateText = sheet.Cell(r, 2).GetString().Trim();
var checkInText = sheet.Cell(r, 3).GetString().Trim();
var checkOutText = sheet.Cell(r, 4).GetString().Trim();
rows.Add(new ParsedRow(
employeeCode,
DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d.Date : null,
TimeSpan.TryParse(checkInText, CultureInfo.InvariantCulture, out var ci) ? ci : null,
TimeSpan.TryParse(checkOutText, CultureInfo.InvariantCulture, out var co) ? co : null,
!string.IsNullOrWhiteSpace(checkInText), !string.IsNullOrWhiteSpace(checkOutText)));
}
return rows;
}
private static List<ParsedRow> ParseCsv(Stream content)
{
using var reader = new StreamReader(content);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null });
csv.Read();
csv.ReadHeader();
var rows = new List<ParsedRow>();
while (csv.Read())
{
var employeeCode = csv.GetField("Employee Code")?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(employeeCode)) continue;
var dateText = csv.GetField("Date")?.Trim() ?? string.Empty;
var checkInText = csv.GetField("Check In")?.Trim() ?? string.Empty;
var checkOutText = csv.GetField("Check Out")?.Trim() ?? string.Empty;
rows.Add(new ParsedRow(
employeeCode,
DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d.Date : null,
TimeSpan.TryParse(checkInText, CultureInfo.InvariantCulture, out var ci) ? ci : null,
TimeSpan.TryParse(checkOutText, CultureInfo.InvariantCulture, out var co) ? co : null,
!string.IsNullOrWhiteSpace(checkInText), !string.IsNullOrWhiteSpace(checkOutText)));
}
return rows;
}
private static AttendanceUploadBatchDto Map(AttendanceUploadBatch b) => new(
b.AttendanceUploadBatchId, b.DocNo, b.PeriodStart, b.PeriodEnd, b.SourceType, b.OriginalFileName,
b.UploadedBy, b.UploadedAt, b.Status, b.ConfirmedBy, b.ConfirmedAt, b.RowCountTotal, b.RowCountDuplicate, b.RowCountError);
private static AttendanceRecordDto Map(AttendanceRecord r) => new(
r.AttendanceRecordId, r.AttendanceUploadBatchId, r.EmployeeId, r.Employee?.EmployeeCode, r.Employee?.FullName,
r.AttendanceDate, r.CheckIn, r.CheckOut, r.WorkingMinutes, r.LateMinutes, r.EarlyLeaveMinutes, r.OvertimeMinutes,
r.AttendanceStatus, r.RowValidationStatus, r.DuplicateOfAttendanceRecordId, r.Notes);
}
@@ -0,0 +1,107 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>Branch master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2).</summary>
public sealed class BranchService : IBranchService
{
private readonly IRepository<Branch> _branches;
private readonly IUnitOfWork _uow;
public BranchService(IRepository<Branch> branches, IUnitOfWork uow)
{
_branches = branches;
_uow = uow;
}
public async Task<PagedResponse<BranchDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _branches.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%") || EF.Functions.ILike(b.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(b => b.Status == status);
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))
.ToListAsync(ct);
return PagedResponse<BranchDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<BranchDto>?> GetAsync(int branchId, CancellationToken ct = default)
{
var branch = await _branches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BranchId == branchId, ct);
return branch is null ? null : new ETagged<BranchDto>(Map(branch), branch.RowVersion);
}
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,
Name = request.Name.Trim(),
Address = request.Address?.Trim(),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _branches.AddAsync(branch, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<BranchDto>(Map(branch), branch.RowVersion);
}
public async Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found.");
if (branch.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The branch was modified by another request.", 412);
branch.Name = request.Name.Trim();
branch.Address = request.Address?.Trim();
branch.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The branch was modified by another request.", 412);
}
return new ETagged<BranchDto>(Map(branch), branch.RowVersion);
}
public async Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default)
{
var branch = await _branches.GetByIdAsync(branchId, ct)
?? throw new NotFoundException($"Branch {branchId} was not found.");
branch.Status = status;
branch.UpdatedAt = DateTime.UtcNow;
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);
}
@@ -0,0 +1,149 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>
/// Department master service (FR-HR-MD-01) — unlimited self-nesting for a real org
/// chart (unlike the two-level-capped Category); <see cref="EnsureNoCycleAsync"/> is
/// the service-level guard since there is no DB-level constraint for this
/// (docs/12-BACKEND-HRM.md A.1/C.1).
/// </summary>
public sealed class DepartmentService : IDepartmentService
{
private readonly IRepository<Department> _departments;
private readonly IUnitOfWork _uow;
public DepartmentService(IRepository<Department> departments, IUnitOfWork uow)
{
_departments = departments;
_uow = uow;
}
public async Task<PagedResponse<DepartmentDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _departments.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(d => EF.Functions.ILike(d.Name, $"%{term}%") || EF.Functions.ILike(d.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(d => d.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(d => d.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(d => Map(d))
.ToListAsync(ct);
return PagedResponse<DepartmentDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<DepartmentDto>?> GetAsync(int departmentId, CancellationToken ct = default)
{
var dept = await _departments.Query().AsNoTracking().FirstOrDefaultAsync(d => d.DepartmentId == departmentId, ct);
return dept is null ? null : new ETagged<DepartmentDto>(Map(dept), dept.RowVersion);
}
public async Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _departments.Query().AnyAsync(d => d.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A department with code '{code}' already exists.");
if (request.ParentDepartmentId is not null
&& !await _departments.Query().AnyAsync(d => d.DepartmentId == request.ParentDepartmentId, ct))
throw new DomainException(ErrorCodes.Validation, $"Parent department {request.ParentDepartmentId} was not found.", 422);
var dept = new Department
{
Code = code,
Name = request.Name.Trim(),
ParentDepartmentId = request.ParentDepartmentId,
HeadEmployeeId = request.HeadEmployeeId,
BranchId = request.BranchId,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _departments.AddAsync(dept, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<DepartmentDto>(Map(dept), dept.RowVersion);
}
public async Task<ETagged<DepartmentDto>> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var dept = await _departments.GetByIdAsync(departmentId, ct)
?? throw new NotFoundException($"Department {departmentId} was not found.");
if (dept.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The department was modified by another request.", 412);
if (request.ParentDepartmentId is not null)
{
if (request.ParentDepartmentId == departmentId)
throw new DomainException(ErrorCodes.DepartmentCycleDetected, "A department cannot be its own parent.", 422);
if (!await _departments.Query().AnyAsync(d => d.DepartmentId == request.ParentDepartmentId, ct))
throw new DomainException(ErrorCodes.Validation, $"Parent department {request.ParentDepartmentId} was not found.", 422);
await EnsureNoCycleAsync(departmentId, request.ParentDepartmentId.Value, ct);
}
dept.Name = request.Name.Trim();
dept.ParentDepartmentId = request.ParentDepartmentId;
dept.HeadEmployeeId = request.HeadEmployeeId;
dept.BranchId = request.BranchId;
dept.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The department was modified by another request.", 412);
}
return new ETagged<DepartmentDto>(Map(dept), dept.RowVersion);
}
public async Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default)
{
var dept = await _departments.GetByIdAsync(departmentId, ct)
?? throw new NotFoundException($"Department {departmentId} was not found.");
dept.Status = status;
dept.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
/// <summary>Walks up from <paramref name="newParentId"/>; throws if it ever reaches <paramref name="departmentId"/>.</summary>
private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct)
{
var currentId = (int?)newParentId;
var guard = 0;
while (currentId is not null && guard++ < 1000)
{
if (currentId == departmentId)
throw new DomainException(ErrorCodes.DepartmentCycleDetected, "Setting this parent would create a department cycle.", 422);
currentId = await _departments.Query().AsNoTracking()
.Where(d => d.DepartmentId == currentId)
.Select(d => d.ParentDepartmentId)
.FirstOrDefaultAsync(ct);
}
}
private static DepartmentDto Map(Department d) => new(
d.DepartmentId, d.Code, d.Name, d.ParentDepartmentId, d.HeadEmployeeId, d.BranchId, d.Status, d.CreatedAt, d.UpdatedAt);
}
@@ -0,0 +1,105 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>Designation (job title) master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2).</summary>
public sealed class DesignationService : IDesignationService
{
private readonly IRepository<Designation> _designations;
private readonly IUnitOfWork _uow;
public DesignationService(IRepository<Designation> designations, IUnitOfWork uow)
{
_designations = designations;
_uow = uow;
}
public async Task<PagedResponse<DesignationDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _designations.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(d => EF.Functions.ILike(d.Name, $"%{term}%") || EF.Functions.ILike(d.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(d => d.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(d => d.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(d => new DesignationDto(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<DesignationDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<DesignationDto>?> GetAsync(int designationId, CancellationToken ct = default)
{
var designation = await _designations.Query().AsNoTracking().FirstOrDefaultAsync(d => d.DesignationId == designationId, ct);
return designation is null ? null : new ETagged<DesignationDto>(Map(designation), designation.RowVersion);
}
public async Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _designations.Query().AnyAsync(d => d.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A designation with code '{code}' already exists.");
var designation = new Designation
{
Code = code,
Name = request.Name.Trim(),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _designations.AddAsync(designation, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<DesignationDto>(Map(designation), designation.RowVersion);
}
public async Task<ETagged<DesignationDto>> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var designation = await _designations.GetByIdAsync(designationId, ct)
?? throw new NotFoundException($"Designation {designationId} was not found.");
if (designation.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The designation was modified by another request.", 412);
designation.Name = request.Name.Trim();
designation.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The designation was modified by another request.", 412);
}
return new ETagged<DesignationDto>(Map(designation), designation.RowVersion);
}
public async Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default)
{
var designation = await _designations.GetByIdAsync(designationId, ct)
?? throw new NotFoundException($"Designation {designationId} was not found.");
designation.Status = status;
designation.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt);
}
@@ -0,0 +1,129 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace ERPCore.Services.Hrm;
/// <summary>
/// Uploaded staff document service (FR-HR-DOC-02..04). Extension allowlist + declared
/// content-type cross-check + size cap are enforced here, server-authoritative
/// (docs/12-BACKEND-HRM.md §B.5, 02-SECURITY C.8) — magic-byte sniffing / antivirus
/// scanning are an explicitly deferred accepted risk, not silently skipped.
/// </summary>
public sealed class EmployeeDocumentService : Services.Interfaces.IEmployeeDocumentService
{
private static readonly Dictionary<string, string> AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
[".pdf"] = "application/pdf",
[".jpg"] = "image/jpeg",
[".jpeg"] = "image/jpeg",
[".png"] = "image/png",
[".docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
};
private readonly IRepository<EmployeeDocument> _documents;
private readonly IRepository<Employee> _employees;
private readonly IRepository<HrDocumentType> _documentTypes;
private readonly IFileStorageService _storage;
private readonly IUnitOfWork _uow;
private readonly long _maxSizeBytes;
public EmployeeDocumentService(
IRepository<EmployeeDocument> documents, IRepository<Employee> employees, IRepository<HrDocumentType> documentTypes,
IFileStorageService storage, IUnitOfWork uow, IConfiguration configuration)
{
_documents = documents;
_employees = employees;
_documentTypes = documentTypes;
_storage = storage;
_uow = uow;
_maxSizeBytes = configuration.GetValue<long?>("FileStorage:MaxSizeBytes") ?? 10 * 1024 * 1024;
}
public async Task<List<EmployeeDocumentDto>> ListAsync(int employeeId, CancellationToken ct = default)
{
return await _documents.Query().AsNoTracking()
.Include(d => d.HrDocumentType)
.Where(d => d.EmployeeId == employeeId)
.Select(d => Map(d))
.ToListAsync(ct);
}
public async Task<EmployeeDocumentDto> UploadAsync(
int employeeId, UploadEmployeeDocumentRequest request, Stream fileContent, string fileName, string contentType,
int actorUserId, CancellationToken ct = default)
{
if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct))
throw new NotFoundException($"Employee {employeeId} was not found.");
var docType = await _documentTypes.GetByIdAsync(request.HrDocumentTypeId, ct)
?? throw new NotFoundException($"Document type {request.HrDocumentTypeId} was not found.");
var extension = Path.GetExtension(fileName);
if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType)
|| !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase))
throw new DomainException(ErrorCodes.FileTypeNotAllowed,
$"File type '{extension}'/'{contentType}' is not allowed.", 422);
if (fileContent.Length > _maxSizeBytes)
throw new DomainException(ErrorCodes.FileTooLarge,
$"File exceeds the maximum allowed size of {_maxSizeBytes} bytes.", 413);
var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(fileContent, fileName, contentType, ct);
var document = new EmployeeDocument
{
EmployeeId = employeeId,
HrDocumentTypeId = docType.HrDocumentTypeId,
OriginalFileName = fileName,
StoredFileName = storedFileName,
RelativePath = relativePath,
ContentType = contentType,
SizeBytes = sizeBytes,
IssueDate = request.IssueDate,
ExpiryDate = request.ExpiryDate,
Notes = request.Notes?.Trim(),
UploadedBy = actorUserId,
UploadedAt = DateTime.UtcNow,
Status = EmployeeDocumentStatus.Active
};
await _documents.AddAsync(document, ct);
await _uow.SaveChangesAsync(ct);
document.HrDocumentType = docType;
return Map(document);
}
public async Task<(Stream Content, string FileName, string ContentType)> DownloadAsync(
int employeeId, int documentId, CancellationToken ct = default)
{
var document = await _documents.Query().AsNoTracking()
.FirstOrDefaultAsync(d => d.EmployeeDocumentId == documentId && d.EmployeeId == employeeId, ct)
?? throw new NotFoundException($"Document {documentId} was not found for employee {employeeId}.");
var stream = await _storage.OpenReadAsync(document.RelativePath, ct);
return (stream, document.OriginalFileName, document.ContentType);
}
public async Task SetStatusAsync(int employeeId, int documentId, EmployeeDocumentStatus status, CancellationToken ct = default)
{
var document = await _documents.Query()
.FirstOrDefaultAsync(d => d.EmployeeDocumentId == documentId && d.EmployeeId == employeeId, ct)
?? throw new NotFoundException($"Document {documentId} was not found for employee {employeeId}.");
document.Status = status;
await _uow.SaveChangesAsync(ct);
}
private static EmployeeDocumentDto Map(EmployeeDocument d) => new(
d.EmployeeDocumentId, d.EmployeeId, d.HrDocumentTypeId, d.HrDocumentType?.Name,
d.OriginalFileName, d.ContentType, d.SizeBytes, d.IssueDate, d.ExpiryDate, d.Notes,
d.UploadedBy, d.UploadedAt, d.Status);
}
@@ -0,0 +1,114 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>
/// Loan/Advance service (FR-HR-PAY-03). Creating a loan generates its full
/// installment schedule up front; installments flip Pending→Deducted only when
/// their consuming PayrollRun reaches Locked (docs/12-BACKEND-HRM.md A.4), handled
/// by <see cref="Services.Hrm.PayrollRunService"/>, not here.
/// </summary>
public sealed class EmployeeLoanService : IEmployeeLoanService
{
private readonly IRepository<EmployeeLoan> _loans;
private readonly IRepository<Employee> _employees;
private readonly INumberSequenceService _numberSequence;
private readonly IUnitOfWork _uow;
public EmployeeLoanService(
IRepository<EmployeeLoan> loans, IRepository<Employee> employees, INumberSequenceService numberSequence, IUnitOfWork uow)
{
_loans = loans;
_employees = employees;
_numberSequence = numberSequence;
_uow = uow;
}
public async Task<List<EmployeeLoanDto>> ListAsync(int employeeId, CancellationToken ct = default)
{
var rows = await _loans.Query().AsNoTracking()
.Include(l => l.Installments)
.Where(l => l.EmployeeId == employeeId)
.OrderByDescending(l => l.CreatedAt)
.ToListAsync(ct);
return rows.Select(Map).ToList();
}
public async Task<EmployeeLoanDto?> GetAsync(int employeeId, int loanId, CancellationToken ct = default)
{
var loan = await _loans.Query().AsNoTracking()
.Include(l => l.Installments)
.FirstOrDefaultAsync(l => l.EmployeeLoanId == loanId && l.EmployeeId == employeeId, ct);
return loan is null ? null : Map(loan);
}
public async Task<EmployeeLoanDto> CreateAsync(int employeeId, CreateEmployeeLoanRequest request, int actorUserId, CancellationToken ct = default)
{
if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct))
throw new NotFoundException($"Employee {employeeId} was not found.");
var docNo = await _numberSequence.NextAsync("LOAN", ct);
var loan = new EmployeeLoan
{
DocNo = docNo,
EmployeeId = employeeId,
LoanKind = request.LoanKind,
PrincipalAmount = request.PrincipalAmount,
InterestRate = request.InterestRate,
InstallmentAmount = request.InstallmentAmount,
NumberOfInstallments = request.NumberOfInstallments,
StartYear = request.StartYear,
StartMonth = request.StartMonth,
OutstandingBalance = request.PrincipalAmount,
Status = LoanStatus.Active,
ApprovedBy = actorUserId,
ApprovedAt = DateTime.UtcNow,
CreatedBy = actorUserId,
CreatedAt = DateTime.UtcNow
};
var year = request.StartYear;
var month = request.StartMonth;
for (var i = 1; i <= request.NumberOfInstallments; i++)
{
loan.Installments.Add(new LoanInstallment
{
InstallmentNumber = i,
DueYear = year,
DueMonth = month,
ScheduledAmount = request.InstallmentAmount,
Status = LoanInstallmentStatus.Pending
});
month++;
if (month > 12) { month = 1; year++; }
}
await _loans.AddAsync(loan, ct);
await _uow.SaveChangesAsync(ct);
return Map(loan);
}
public async Task<List<LoanInstallment>> GetDueInstallmentsAsync(int employeeId, int periodYear, int periodMonth, CancellationToken ct = default)
{
return await _loans.Query()
.Where(l => l.EmployeeId == employeeId && l.Status == LoanStatus.Active)
.SelectMany(l => l.Installments)
.Where(i => i.DueYear == periodYear && i.DueMonth == periodMonth && i.Status == LoanInstallmentStatus.Pending)
.ToListAsync(ct);
}
private static EmployeeLoanDto Map(EmployeeLoan l) => new(
l.EmployeeLoanId, l.DocNo, l.EmployeeId, l.LoanKind, l.PrincipalAmount, l.InterestRate, l.InstallmentAmount,
l.NumberOfInstallments, l.StartYear, l.StartMonth, l.OutstandingBalance, l.Status,
l.Installments.OrderBy(i => i.InstallmentNumber).Select(i => new LoanInstallmentDto(
i.LoanInstallmentId, i.InstallmentNumber, i.DueYear, i.DueMonth, i.ScheduledAmount, i.PaidAmount, i.PayrollRunId, i.Status)).ToList(),
l.CreatedAt);
}
@@ -0,0 +1,104 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>
/// Effective-dated salary structure service (FR-HR-PAY-02) — creating a new
/// structure supersedes the previous open-ended one, preserving history for audits
/// (docs/12-BACKEND-HRM.md §13) rather than overwriting it.
/// </summary>
public sealed class EmployeeSalaryStructureService : IEmployeeSalaryStructureService
{
private readonly IRepository<EmployeeSalaryStructure> _structures;
private readonly IRepository<Employee> _employees;
private readonly IRepository<SalaryComponent> _components;
private readonly IUnitOfWork _uow;
public EmployeeSalaryStructureService(
IRepository<EmployeeSalaryStructure> structures, IRepository<Employee> employees,
IRepository<SalaryComponent> components, IUnitOfWork uow)
{
_structures = structures;
_employees = employees;
_components = components;
_uow = uow;
}
public async Task<List<EmployeeSalaryStructureDto>> ListHistoryAsync(int employeeId, CancellationToken ct = default)
{
var rows = await _structures.Query().AsNoTracking()
.Include(s => s.Lines).ThenInclude(l => l.SalaryComponent)
.Where(s => s.EmployeeId == employeeId)
.OrderByDescending(s => s.EffectiveFrom)
.ToListAsync(ct);
return rows.Select(Map).ToList();
}
public async Task<EmployeeSalaryStructureDto?> GetCurrentAsync(int employeeId, CancellationToken ct = default)
{
var current = await _structures.Query().AsNoTracking()
.Include(s => s.Lines).ThenInclude(l => l.SalaryComponent)
.Where(s => s.EmployeeId == employeeId && s.EffectiveTo == null)
.FirstOrDefaultAsync(ct);
return current is null ? null : Map(current);
}
public async Task<EmployeeSalaryStructureDto> CreateAsync(
int employeeId, CreateSalaryStructureRequest request, int actorUserId, CancellationToken ct = default)
{
if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct))
throw new NotFoundException($"Employee {employeeId} was not found.");
var current = await _structures.Query()
.FirstOrDefaultAsync(s => s.EmployeeId == employeeId && s.EffectiveTo == null, ct);
if (current is not null)
{
if (request.EffectiveFrom.Date <= current.EffectiveFrom.Date)
throw new DomainException(ErrorCodes.SalaryStructureOverlap,
"The new effective date must be after the current structure's effective date.", 409);
current.EffectiveTo = request.EffectiveFrom.Date.AddDays(-1);
current.Status = SalaryStructureStatus.Superseded;
}
var componentIds = request.Lines.Select(l => l.SalaryComponentId).ToList();
var validComponentCount = await _components.Query().CountAsync(c => componentIds.Contains(c.SalaryComponentId), ct);
if (validComponentCount != componentIds.Distinct().Count())
throw new DomainException(ErrorCodes.Validation, "One or more salary components were not found.", 422);
var structure = new EmployeeSalaryStructure
{
EmployeeId = employeeId,
EffectiveFrom = request.EffectiveFrom.Date,
BasicSalary = request.BasicSalary,
Status = SalaryStructureStatus.Active,
ApprovedBy = actorUserId,
ApprovedAt = DateTime.UtcNow,
CreatedBy = actorUserId,
CreatedAt = DateTime.UtcNow,
Lines = request.Lines.Select(l => new EmployeeSalaryStructureLine
{
SalaryComponentId = l.SalaryComponentId,
Amount = l.Amount
}).ToList()
};
await _structures.AddAsync(structure, ct);
await _uow.SaveChangesAsync(ct);
return await GetCurrentAsync(employeeId, ct) ?? Map(structure);
}
private static EmployeeSalaryStructureDto Map(EmployeeSalaryStructure s) => new(
s.EmployeeSalaryStructureId, s.EmployeeId, s.EffectiveFrom, s.EffectiveTo, s.BasicSalary, s.Currency, s.Status,
s.Lines.Select(l => new EmployeeSalaryStructureLineDto(l.SalaryComponentId, l.SalaryComponent?.Name, l.Amount)).ToList(),
s.CreatedAt);
}
@@ -0,0 +1,244 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>
/// Employee (staff) service (FR-HR-MD-02/03, docs/13-BACKEND-HRM-API.md §3). Distinct
/// from <see cref="IUserManagementService"/> (system login accounts) — the two are
/// linked only via the optional, explicit <see cref="Employee.UserId"/> (docs/12-BACKEND-HRM.md A.5).
/// </summary>
public sealed class EmployeeService : IEmployeeService
{
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IRepository<EmployeeBankDetail> _bankDetails;
private readonly IUnitOfWork _uow;
public EmployeeService(
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails, IUnitOfWork uow)
{
_employees = employees;
_users = users;
_bankDetails = bankDetails;
_uow = uow;
}
public async Task<PagedResponse<EmployeeListItemDto>> ListAsync(
PageQuery query, EmployeeStatus? status, int? departmentId, int? designationId, int? branchId, CancellationToken ct = default)
{
var q = _employees.Query().AsNoTracking()
.Include(e => e.Department).Include(e => e.Designation)
.Include(e => e.EmploymentType).Include(e => e.Branch)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(e => EF.Functions.ILike(e.FullName, $"%{term}%") || EF.Functions.ILike(e.EmployeeCode, $"%{term}%"));
}
if (status is not null) q = q.Where(e => e.Status == status);
if (departmentId is not null) q = q.Where(e => e.DepartmentId == departmentId);
if (designationId is not null) q = q.Where(e => e.DesignationId == designationId);
if (branchId is not null) q = q.Where(e => e.BranchId == branchId);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(e => e.FullName)
.Skip(query.Skip).Take(query.PageSize)
.Select(e => new EmployeeListItemDto(
e.EmployeeId, e.EmployeeCode, e.FullName, e.Email,
e.DepartmentId, e.Department!.Name, e.DesignationId, e.Designation!.Name,
e.EmploymentTypeId, e.EmploymentType!.Name, e.BranchId, e.Branch != null ? e.Branch.Name : null,
e.Status, e.UserId != null, e.HireDate))
.ToListAsync(ct);
return PagedResponse<EmployeeListItemDto>.Create(rows, query.Page, query.PageSize, total);
}
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);
}
public async Task<ETagged<EmployeeDetailDto>> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default)
{
var code = request.EmployeeCode.Trim();
if (await _employees.Query().AnyAsync(e => e.EmployeeCode.ToLower() == code.ToLower(), ct))
throw new DomainException(ErrorCodes.EmployeeCodeDuplicate, $"An employee with code '{code}' already exists.", 400);
int? linkedUserId = null;
if (request.LinkUserId is not null)
{
var user = await _users.GetByIdAsync(request.LinkUserId.Value, ct)
?? throw new NotFoundException($"User {request.LinkUserId} was not found.");
if (await _employees.Query().AnyAsync(e => e.UserId == user.UserId, ct))
throw new DomainException(ErrorCodes.UserAlreadyLinked, $"User {user.UserId} already backs a different employee.", 409);
linkedUserId = user.UserId;
}
var employee = new Employee
{
EmployeeCode = code,
FullName = request.FullName.Trim(),
Nic = request.Nic?.Trim(),
DateOfBirth = request.DateOfBirth,
Gender = request.Gender,
Nationality = request.Nationality?.Trim(),
Email = request.Email?.Trim(),
PersonalMobile = request.PersonalMobile?.Trim(),
AddressLine1 = request.AddressLine1?.Trim(),
AddressLine2 = request.AddressLine2?.Trim(),
City = request.City?.Trim(),
PostalCode = request.PostalCode?.Trim(),
Country = request.Country?.Trim(),
EmergencyContactName = request.EmergencyContactName?.Trim(),
EmergencyContactRelationship = request.EmergencyContactRelationship?.Trim(),
EmergencyContactPhone = request.EmergencyContactPhone?.Trim(),
HireDate = request.HireDate,
DepartmentId = request.DepartmentId,
DesignationId = request.DesignationId,
EmploymentTypeId = request.EmploymentTypeId,
BranchId = request.BranchId,
WorkShiftId = request.WorkShiftId,
ReportingManagerId = request.ReportingManagerId,
EpfNumber = request.EpfNumber?.Trim(),
EtfNumber = request.EtfNumber?.Trim(),
TaxIdentificationNumber = request.TaxIdentificationNumber?.Trim(),
UserId = linkedUserId,
Status = EmployeeStatus.Active,
CreatedBy = actorUserId,
CreatedAt = DateTime.UtcNow
};
await _employees.AddAsync(employee, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<EmployeeDetailDto>(Map(employee), employee.RowVersion);
}
public async Task<ETagged<EmployeeDetailDto>> UpdateAsync(
int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
if (employee.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412);
employee.FullName = request.FullName.Trim();
employee.Nic = request.Nic?.Trim();
employee.DateOfBirth = request.DateOfBirth;
employee.Gender = request.Gender;
employee.Nationality = request.Nationality?.Trim();
employee.Email = request.Email?.Trim();
employee.PersonalMobile = request.PersonalMobile?.Trim();
employee.AddressLine1 = request.AddressLine1?.Trim();
employee.AddressLine2 = request.AddressLine2?.Trim();
employee.City = request.City?.Trim();
employee.PostalCode = request.PostalCode?.Trim();
employee.Country = request.Country?.Trim();
employee.EmergencyContactName = request.EmergencyContactName?.Trim();
employee.EmergencyContactRelationship = request.EmergencyContactRelationship?.Trim();
employee.EmergencyContactPhone = request.EmergencyContactPhone?.Trim();
employee.ConfirmationDate = request.ConfirmationDate;
employee.LastWorkingDate = request.LastWorkingDate;
employee.DepartmentId = request.DepartmentId;
employee.DesignationId = request.DesignationId;
employee.EmploymentTypeId = request.EmploymentTypeId;
employee.BranchId = request.BranchId;
employee.WorkShiftId = request.WorkShiftId;
employee.ReportingManagerId = request.ReportingManagerId;
employee.EpfNumber = request.EpfNumber?.Trim();
employee.EtfNumber = request.EtfNumber?.Trim();
employee.TaxIdentificationNumber = request.TaxIdentificationNumber?.Trim();
employee.UpdatedBy = actorUserId;
employee.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412);
}
return new ETagged<EmployeeDetailDto>(Map(employee), employee.RowVersion);
}
public async Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
employee.Status = status;
employee.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
{
return await _bankDetails.Query().AsNoTracking()
.Where(b => b.EmployeeId == employeeId)
.Select(b => new EmployeeBankDetailDto(
b.EmployeeBankDetailId, b.BankName, b.BranchName, b.AccountNumber, b.AccountHolderName,
b.SwiftCode, b.IsPrimary, b.Status))
.ToListAsync(ct);
}
public async Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(
int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default)
{
if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct))
throw new NotFoundException($"Employee {employeeId} was not found.");
if (request.Items.Count(i => i.IsPrimary) > 1)
throw new DomainException(ErrorCodes.Validation, "Only one bank detail row may be marked primary.", 422);
var existing = await _bankDetails.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct);
foreach (var row in existing) _bankDetails.Remove(row);
var result = new List<EmployeeBankDetail>();
foreach (var item in request.Items)
{
var detail = new EmployeeBankDetail
{
EmployeeId = employeeId,
BankName = item.BankName.Trim(),
BranchName = item.BranchName.Trim(),
AccountNumber = item.AccountNumber.Trim(),
AccountHolderName = item.AccountHolderName.Trim(),
SwiftCode = item.SwiftCode?.Trim(),
IsPrimary = item.IsPrimary,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
result.Add(detail);
await _bankDetails.AddAsync(detail, ct);
}
await _uow.SaveChangesAsync(ct);
return result.Select(b => new EmployeeBankDetailDto(
b.EmployeeBankDetailId, b.BankName, b.BranchName, b.AccountNumber, b.AccountHolderName,
b.SwiftCode, b.IsPrimary, b.Status)).ToList();
}
private static EmployeeDetailDto Map(Employee e) => new(
e.EmployeeId, e.EmployeeCode, e.FullName, e.Nic, e.DateOfBirth, e.Gender, e.Nationality, e.ProfilePhotoPath,
e.Email, e.PersonalMobile, e.AddressLine1, e.AddressLine2, e.City, e.PostalCode, e.Country,
e.EmergencyContactName, e.EmergencyContactRelationship, e.EmergencyContactPhone,
e.HireDate, e.ConfirmationDate, e.LastWorkingDate,
e.DepartmentId, e.DesignationId, e.EmploymentTypeId, e.BranchId, e.WorkShiftId,
e.ReportingManagerId, e.EpfNumber, e.EtfNumber, e.TaxIdentificationNumber,
e.UserId, e.Status, e.CreatedAt, e.UpdatedAt);
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <inheritdoc cref="IEmployeeUserLinkService"/>
public sealed class EmployeeUserLinkService : IEmployeeUserLinkService
{
private readonly IRepository<Employee> _employees;
private readonly IRepository<User> _users;
private readonly IUnitOfWork _uow;
public EmployeeUserLinkService(IRepository<Employee> employees, IRepository<User> users, IUnitOfWork uow)
{
_employees = employees;
_users = users;
_uow = uow;
}
public async Task<EmployeeMatchDto?> FindStaffCandidateByEmailAsync(string email, CancellationToken ct = default)
{
var term = email.Trim();
var employee = await _employees.Query().AsNoTracking()
.Where(e => e.UserId == null && e.Email != null && EF.Functions.ILike(e.Email, term))
.Select(e => new EmployeeMatchDto(e.EmployeeId, e.EmployeeCode, e.FullName, e.Email!))
.FirstOrDefaultAsync(ct);
return employee;
}
public async Task<UserMatchDto?> FindUserCandidateByEmailAsync(string email, CancellationToken ct = default)
{
var term = email.Trim();
var linkedUserIds = _employees.Query().Where(e => e.UserId != null).Select(e => e.UserId!.Value);
var user = await _users.Query().AsNoTracking()
.Where(u => u.Email != null && EF.Functions.ILike(u.Email, term) && !linkedUserIds.Contains(u.UserId))
.Select(u => new UserMatchDto(u.UserId, u.Username, u.DisplayName, u.Email!))
.FirstOrDefaultAsync(ct);
return user;
}
public async Task LinkAsync(int employeeId, int userId, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
if (employee.UserId is not null)
throw new DomainException(ErrorCodes.EmployeeAlreadyLinked, $"Employee {employeeId} already has a linked user.", 409);
var user = await _users.GetByIdAsync(userId, ct)
?? throw new NotFoundException($"User {userId} was not found.");
if (await _employees.Query().AnyAsync(e => e.UserId == userId, ct))
throw new DomainException(ErrorCodes.UserAlreadyLinked, $"User {userId} already backs a different employee.", 409);
employee.UserId = user.UserId;
employee.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
public async Task UnlinkAsync(int employeeId, CancellationToken ct = default)
{
var employee = await _employees.GetByIdAsync(employeeId, ct)
?? throw new NotFoundException($"Employee {employeeId} was not found.");
employee.UserId = null;
employee.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,105 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>EmploymentType master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2).</summary>
public sealed class EmploymentTypeService : IEmploymentTypeService
{
private readonly IRepository<EmploymentType> _employmentTypes;
private readonly IUnitOfWork _uow;
public EmploymentTypeService(IRepository<EmploymentType> employmentTypes, IUnitOfWork uow)
{
_employmentTypes = employmentTypes;
_uow = uow;
}
public async Task<PagedResponse<EmploymentTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _employmentTypes.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(e => EF.Functions.ILike(e.Name, $"%{term}%") || EF.Functions.ILike(e.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(e => e.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(e => e.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(e => new EmploymentTypeDto(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<EmploymentTypeDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<EmploymentTypeDto>?> GetAsync(int employmentTypeId, CancellationToken ct = default)
{
var et = await _employmentTypes.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmploymentTypeId == employmentTypeId, ct);
return et is null ? null : new ETagged<EmploymentTypeDto>(Map(et), et.RowVersion);
}
public async Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _employmentTypes.Query().AnyAsync(e => e.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"An employment type with code '{code}' already exists.");
var et = new EmploymentType
{
Code = code,
Name = request.Name.Trim(),
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _employmentTypes.AddAsync(et, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<EmploymentTypeDto>(Map(et), et.RowVersion);
}
public async Task<ETagged<EmploymentTypeDto>> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct)
?? throw new NotFoundException($"Employment type {employmentTypeId} was not found.");
if (et.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employment type was modified by another request.", 412);
et.Name = request.Name.Trim();
et.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employment type was modified by another request.", 412);
}
return new ETagged<EmploymentTypeDto>(Map(et), et.RowVersion);
}
public async Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default)
{
var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct)
?? throw new NotFoundException($"Employment type {employmentTypeId} was not found.");
et.Status = status;
et.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt);
}
@@ -0,0 +1,113 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>Staff document-type catalog service (FR-HR-DOC-01, docs/13-BACKEND-HRM-API.md §2).</summary>
public sealed class HrDocumentTypeService : IHrDocumentTypeService
{
private readonly IRepository<HrDocumentType> _types;
private readonly IUnitOfWork _uow;
public HrDocumentTypeService(IRepository<HrDocumentType> types, IUnitOfWork uow)
{
_types = types;
_uow = uow;
}
public async Task<PagedResponse<HrDocumentTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _types.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%") || EF.Functions.ILike(t.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(t => t.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => Map(t))
.ToListAsync(ct);
return PagedResponse<HrDocumentTypeDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<HrDocumentTypeDto>?> GetAsync(int hrDocumentTypeId, CancellationToken ct = default)
{
var type = await _types.Query().AsNoTracking().FirstOrDefaultAsync(t => t.HrDocumentTypeId == hrDocumentTypeId, ct);
return type is null ? null : new ETagged<HrDocumentTypeDto>(Map(type), type.RowVersion);
}
public async Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _types.Query().AnyAsync(t => t.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A document type with code '{code}' already exists.");
var type = new HrDocumentType
{
Code = code,
Name = request.Name.Trim(),
Category = request.Category,
RequiredAtOnboarding = request.RequiredAtOnboarding,
ExpiryTracked = request.ExpiryTracked,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _types.AddAsync(type, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<HrDocumentTypeDto>(Map(type), type.RowVersion);
}
public async Task<ETagged<HrDocumentTypeDto>> UpdateAsync(
int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(hrDocumentTypeId, ct)
?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found.");
if (type.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The document type was modified by another request.", 412);
type.Name = request.Name.Trim();
type.Category = request.Category;
type.RequiredAtOnboarding = request.RequiredAtOnboarding;
type.ExpiryTracked = request.ExpiryTracked;
type.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The document type was modified by another request.", 412);
}
return new ETagged<HrDocumentTypeDto>(Map(type), type.RowVersion);
}
public async Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(hrDocumentTypeId, ct)
?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found.");
type.Status = status;
type.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static HrDocumentTypeDto Map(HrDocumentType t) => new(
t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt);
}
@@ -0,0 +1,133 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Hrm;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <inheritdoc cref="IHrReportService"/>
public sealed class HrReportService : IHrReportService
{
private readonly IRepository<AttendanceRecord> _attendance;
private readonly IRepository<PayrollLine> _payrollLines;
private readonly IRepository<EmployeeSalaryStructure> _salaryStructures;
private readonly IRepository<LeaveBalance> _leaveBalances;
private readonly IRepository<EmployeeDocument> _documents;
public HrReportService(
IRepository<AttendanceRecord> attendance, IRepository<PayrollLine> payrollLines,
IRepository<EmployeeSalaryStructure> salaryStructures, IRepository<LeaveBalance> leaveBalances,
IRepository<EmployeeDocument> documents)
{
_attendance = attendance;
_payrollLines = payrollLines;
_salaryStructures = salaryStructures;
_leaveBalances = leaveBalances;
_documents = documents;
}
public async Task<List<AttendanceSummaryRowDto>> AttendanceSummaryAsync(
int periodYear, int periodMonth, int? departmentId, CancellationToken ct = default)
{
var periodStart = new DateTime(periodYear, periodMonth, 1);
var periodEnd = periodStart.AddMonths(1).AddDays(-1);
var q = _attendance.Query().AsNoTracking()
.Include(r => r.Employee).ThenInclude(e => e!.Department)
.Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd);
if (departmentId is not null) q = q.Where(r => r.Employee!.DepartmentId == departmentId);
var rows = await q.ToListAsync(ct);
return rows.GroupBy(r => r.EmployeeId)
.Select(g => new AttendanceSummaryRowDto(
g.Key, g.First().Employee!.EmployeeCode, g.First().Employee!.FullName, g.First().Employee!.Department?.Name,
g.Count(r => r.AttendanceStatus == AttendanceStatus.Present),
g.Count(r => r.AttendanceStatus == AttendanceStatus.Absent),
g.Count(r => r.AttendanceStatus == AttendanceStatus.OnLeave),
g.Count(r => r.AttendanceStatus == AttendanceStatus.HalfDay),
g.Sum(r => r.OvertimeMinutes),
g.Sum(r => r.LateMinutes)))
.OrderBy(r => r.EmployeeName)
.ToList();
}
public async Task<List<OvertimeReportRowDto>> OvertimeReportAsync(int periodYear, int periodMonth, CancellationToken ct = default)
{
var periodStart = new DateTime(periodYear, periodMonth, 1);
var periodEnd = periodStart.AddMonths(1).AddDays(-1);
return await _attendance.Query().AsNoTracking()
.Include(r => r.Employee)
.Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd && r.OvertimeMinutes > 0)
.OrderByDescending(r => r.OvertimeMinutes)
.Select(r => new OvertimeReportRowDto(r.EmployeeId, r.Employee!.EmployeeCode, r.Employee!.FullName, r.AttendanceDate, r.OvertimeMinutes))
.ToListAsync(ct);
}
public async Task<List<LateArrivalReportRowDto>> LateArrivalReportAsync(int periodYear, int periodMonth, CancellationToken ct = default)
{
var periodStart = new DateTime(periodYear, periodMonth, 1);
var periodEnd = periodStart.AddMonths(1).AddDays(-1);
return await _attendance.Query().AsNoTracking()
.Include(r => r.Employee)
.Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd && r.LateMinutes > 0)
.OrderByDescending(r => r.LateMinutes)
.Select(r => new LateArrivalReportRowDto(r.EmployeeId, r.Employee!.EmployeeCode, r.Employee!.FullName, r.AttendanceDate, r.LateMinutes))
.ToListAsync(ct);
}
public async Task<List<PayrollRegisterRowDto>> PayrollRegisterAsync(int payrollRunId, CancellationToken ct = default)
{
return await _payrollLines.Query().AsNoTracking()
.Include(l => l.Employee)
.Where(l => l.PayrollRunId == payrollRunId)
.OrderBy(l => l.Employee!.FullName)
.Select(l => new PayrollRegisterRowDto(
l.PayrollLineId, l.EmployeeId, l.Employee!.EmployeeCode, l.Employee!.FullName,
l.GrossSalary, l.GrossSalary - l.NetSalary, l.NetSalary))
.ToListAsync(ct);
}
public async Task<List<SalaryHistoryRowDto>> SalaryHistoryAsync(int employeeId, CancellationToken ct = default)
{
return await _salaryStructures.Query().AsNoTracking()
.Where(s => s.EmployeeId == employeeId)
.OrderByDescending(s => s.EffectiveFrom)
.Select(s => new SalaryHistoryRowDto(s.EmployeeSalaryStructureId, s.EffectiveFrom, s.EffectiveTo, s.BasicSalary, s.Status.ToString()))
.ToListAsync(ct);
}
public async Task<List<LeaveBalanceReportRowDto>> LeaveBalanceReportAsync(int year, CancellationToken ct = default)
{
return await _leaveBalances.Query().AsNoTracking()
.Include(b => b.Employee)
.Include(b => b.LeaveType)
.Where(b => b.Year == year)
.OrderBy(b => b.Employee!.FullName)
.Select(b => new LeaveBalanceReportRowDto(
b.EmployeeId, b.Employee!.EmployeeCode, b.Employee!.FullName, b.LeaveType!.Name,
b.EntitledDays, b.TakenDays, b.EntitledDays + b.CarriedForwardDays + b.AdjustmentDays - b.TakenDays))
.ToListAsync(ct);
}
public async Task<List<DocumentExpiryReportRowDto>> DocumentExpiryReportAsync(int withinDays, CancellationToken ct = default)
{
var cutoff = DateTime.UtcNow.Date.AddDays(withinDays);
var today = DateTime.UtcNow.Date;
var rows = await _documents.Query().AsNoTracking()
.Include(d => d.Employee)
.Include(d => d.HrDocumentType)
.Where(d => d.ExpiryDate != null && d.ExpiryDate <= cutoff)
.OrderBy(d => d.ExpiryDate)
.ToListAsync(ct);
return rows.Select(d => new DocumentExpiryReportRowDto(
d.EmployeeDocumentId, d.EmployeeId, d.Employee!.EmployeeCode, d.Employee!.FullName,
d.HrDocumentType!.Name, d.ExpiryDate!.Value, (d.ExpiryDate.Value.Date - today).Days)).ToList();
}
}
@@ -0,0 +1,95 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>Leave balance service (FR-HR-LV-03, docs/13-BACKEND-HRM-API.md §5).</summary>
public sealed class LeaveBalanceService : ILeaveBalanceService
{
private readonly IRepository<LeaveBalance> _balances;
private readonly IRepository<LeaveType> _leaveTypes;
private readonly IRepository<Employee> _employees;
private readonly IUnitOfWork _uow;
public LeaveBalanceService(
IRepository<LeaveBalance> balances, IRepository<LeaveType> leaveTypes, IRepository<Employee> employees, IUnitOfWork uow)
{
_balances = balances;
_leaveTypes = leaveTypes;
_employees = employees;
_uow = uow;
}
public async Task<List<LeaveBalanceDto>> ListAsync(int employeeId, int? year, CancellationToken ct = default)
{
var effectiveYear = year ?? DateTime.UtcNow.Year;
var rows = await _balances.Query().AsNoTracking()
.Include(b => b.LeaveType)
.Where(b => b.EmployeeId == employeeId && b.Year == effectiveYear)
.ToListAsync(ct);
return rows.Select(Map).ToList();
}
public async Task<List<LeaveBalanceDto>> ApplyAdjustmentsAsync(int employeeId, UpdateLeaveBalancesRequest request, CancellationToken ct = default)
{
if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct))
throw new NotFoundException($"Employee {employeeId} was not found.");
var result = new List<LeaveBalance>();
foreach (var item in request.Items)
{
var balance = await GetOrCreateAsync(employeeId, item.LeaveTypeId, request.Year, ct);
balance.AdjustmentDays = item.AdjustmentDays;
balance.UpdatedAt = DateTime.UtcNow;
result.Add(balance);
}
await _uow.SaveChangesAsync(ct);
return result.Select(Map).ToList();
}
public async Task IncrementTakenDaysAsync(int employeeId, int leaveTypeId, int year, decimal days, CancellationToken ct = default)
{
var balance = await GetOrCreateAsync(employeeId, leaveTypeId, year, ct);
balance.TakenDays += days;
balance.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private async Task<LeaveBalance> GetOrCreateAsync(int employeeId, int leaveTypeId, int year, CancellationToken ct)
{
var leaveType = await _leaveTypes.GetByIdAsync(leaveTypeId, ct)
?? throw new NotFoundException($"Leave type {leaveTypeId} was not found.");
var balance = await _balances.Query()
.FirstOrDefaultAsync(b => b.EmployeeId == employeeId && b.LeaveTypeId == leaveTypeId && b.Year == year, ct);
if (balance is not null)
{
balance.LeaveType = leaveType;
return balance;
}
balance = new LeaveBalance
{
EmployeeId = employeeId,
LeaveTypeId = leaveTypeId,
Year = year,
EntitledDays = leaveType.AccrualPerYear,
LeaveType = leaveType
};
await _balances.AddAsync(balance, ct);
return balance;
}
private static LeaveBalanceDto Map(LeaveBalance b) => new(
b.LeaveBalanceId, b.EmployeeId, b.LeaveTypeId, b.LeaveType?.Name, b.Year,
b.EntitledDays, b.TakenDays, b.CarriedForwardDays, b.AdjustmentDays,
b.EntitledDays + b.CarriedForwardDays + b.AdjustmentDays - b.TakenDays);
}
@@ -0,0 +1,168 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>
/// Leave request service (FR-HR-LV-02). Approving a request increments the
/// employee's <see cref="LeaveBalance.TakenDays"/> (docs/12-BACKEND-HRM.md §6) —
/// this is also the event Attendance's OnLeave classification reads back via
/// <see cref="FindApprovedLeaveCoveringAsync"/>.
/// </summary>
public sealed class LeaveRequestService : ILeaveRequestService
{
private readonly IRepository<LeaveRequest> _requests;
private readonly IRepository<Employee> _employees;
private readonly IRepository<LeaveType> _leaveTypes;
private readonly ILeaveBalanceService _balances;
private readonly INumberSequenceService _numberSequence;
private readonly IUnitOfWork _uow;
public LeaveRequestService(
IRepository<LeaveRequest> requests, IRepository<Employee> employees, IRepository<LeaveType> leaveTypes,
ILeaveBalanceService balances, INumberSequenceService numberSequence, IUnitOfWork uow)
{
_requests = requests;
_employees = employees;
_leaveTypes = leaveTypes;
_balances = balances;
_numberSequence = numberSequence;
_uow = uow;
}
public async Task<PagedResponse<LeaveRequestDto>> ListAsync(
PageQuery query, int? employeeId, LeaveRequestStatus? status, CancellationToken ct = default)
{
var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable();
if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId);
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.CreatedAt)
.Skip(query.Skip).Take(query.PageSize)
.Select(r => Map(r))
.ToListAsync(ct);
return PagedResponse<LeaveRequestDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<LeaveRequestDto?> GetAsync(int leaveRequestId, CancellationToken ct = default)
{
var request = await _requests.Query().AsNoTracking()
.Include(r => r.Employee).Include(r => r.LeaveType)
.FirstOrDefaultAsync(r => r.LeaveRequestId == leaveRequestId, ct);
return request is null ? null : Map(request);
}
public async Task<LeaveRequestDto> CreateAsync(CreateLeaveRequestRequest request, int actorUserId, CancellationToken ct = default)
{
if (!await _employees.Query().AnyAsync(e => e.EmployeeId == request.EmployeeId, ct))
throw new NotFoundException($"Employee {request.EmployeeId} was not found.");
if (!await _leaveTypes.Query().AnyAsync(t => t.LeaveTypeId == request.LeaveTypeId, ct))
throw new NotFoundException($"Leave type {request.LeaveTypeId} was not found.");
if (request.EndDate < request.StartDate)
throw new DomainException(ErrorCodes.Validation, "End date cannot be before start date.", 422);
var docNo = await _numberSequence.NextAsync("LV", ct);
// Simplification: calendar-day count (inclusive), not business-day aware —
// flagged for a future improvement, not silently assumed correct for payroll.
var daysCount = (decimal)(request.EndDate.Date - request.StartDate.Date).Days + 1;
var leaveRequest = new LeaveRequest
{
DocNo = docNo,
EmployeeId = request.EmployeeId,
LeaveTypeId = request.LeaveTypeId,
StartDate = request.StartDate.Date,
EndDate = request.EndDate.Date,
DaysCount = daysCount,
Reason = request.Reason?.Trim(),
Status = LeaveRequestStatus.Draft,
CreatedBy = actorUserId,
CreatedAt = DateTime.UtcNow
};
await _requests.AddAsync(leaveRequest, ct);
await _uow.SaveChangesAsync(ct);
return Map(leaveRequest);
}
public async Task<LeaveRequestDto> SubmitAsync(int leaveRequestId, CancellationToken ct = default)
{
var request = await GetTrackedAsync(leaveRequestId, ct);
if (request.Status != LeaveRequestStatus.Draft)
throw new DomainException(ErrorCodes.Conflict, "Only a Draft leave request can be submitted.", 409);
request.Status = LeaveRequestStatus.Submitted;
await _uow.SaveChangesAsync(ct);
return Map(request);
}
public async Task<LeaveRequestDto> ApproveAsync(int leaveRequestId, int actorUserId, CancellationToken ct = default)
{
var request = await GetTrackedAsync(leaveRequestId, ct);
if (request.Status != LeaveRequestStatus.Submitted)
throw new DomainException(ErrorCodes.Conflict, "Only a Submitted leave request can be approved.", 409);
request.Status = LeaveRequestStatus.Approved;
request.ApprovedBy = actorUserId;
request.ApprovedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
await _balances.IncrementTakenDaysAsync(request.EmployeeId, request.LeaveTypeId, request.StartDate.Year, request.DaysCount, ct);
return Map(request);
}
public async Task<LeaveRequestDto> RejectAsync(int leaveRequestId, string reason, int actorUserId, CancellationToken ct = default)
{
var request = await GetTrackedAsync(leaveRequestId, ct);
if (request.Status != LeaveRequestStatus.Submitted)
throw new DomainException(ErrorCodes.Conflict, "Only a Submitted leave request can be rejected.", 409);
request.Status = LeaveRequestStatus.Rejected;
request.ApprovedBy = actorUserId;
request.ApprovedAt = DateTime.UtcNow;
request.RejectionReason = reason.Trim();
await _uow.SaveChangesAsync(ct);
return Map(request);
}
public async Task<LeaveRequestDto> CancelAsync(int leaveRequestId, CancellationToken ct = default)
{
var request = await GetTrackedAsync(leaveRequestId, ct);
if (request.Status is not (LeaveRequestStatus.Draft or LeaveRequestStatus.Submitted))
throw new DomainException(ErrorCodes.Conflict, "Only a Draft or Submitted leave request can be cancelled.", 409);
request.Status = LeaveRequestStatus.Cancelled;
await _uow.SaveChangesAsync(ct);
return Map(request);
}
public async Task<LeaveRequest?> FindApprovedLeaveCoveringAsync(int employeeId, DateTime date, CancellationToken ct = default)
{
var day = date.Date;
return await _requests.Query().AsNoTracking()
.Include(r => r.LeaveType)
.FirstOrDefaultAsync(r => r.EmployeeId == employeeId && r.Status == LeaveRequestStatus.Approved
&& r.StartDate <= day && r.EndDate >= day, ct);
}
private async Task<LeaveRequest> GetTrackedAsync(int leaveRequestId, CancellationToken ct)
=> await _requests.GetByIdAsync(leaveRequestId, ct)
?? throw new NotFoundException($"Leave request {leaveRequestId} was not found.");
private static LeaveRequestDto Map(LeaveRequest r) => new(
r.LeaveRequestId, r.DocNo, r.EmployeeId, r.Employee?.FullName, r.LeaveTypeId, r.LeaveType?.Name,
r.StartDate, r.EndDate, r.DaysCount, r.Reason, r.Status, r.ApprovedBy, r.ApprovedAt, r.RejectionReason, r.CreatedAt);
}
@@ -0,0 +1,119 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>Leave type master service (FR-HR-LV-01, docs/13-BACKEND-HRM-API.md §5).</summary>
public sealed class LeaveTypeService : ILeaveTypeService
{
private readonly IRepository<LeaveType> _types;
private readonly IUnitOfWork _uow;
public LeaveTypeService(IRepository<LeaveType> types, IUnitOfWork uow)
{
_types = types;
_uow = uow;
}
public async Task<PagedResponse<LeaveTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _types.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%") || EF.Functions.ILike(t.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(t => t.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(t => t.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(t => Map(t))
.ToListAsync(ct);
return PagedResponse<LeaveTypeDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<LeaveTypeDto>?> GetAsync(int leaveTypeId, CancellationToken ct = default)
{
var type = await _types.Query().AsNoTracking().FirstOrDefaultAsync(t => t.LeaveTypeId == leaveTypeId, ct);
return type is null ? null : new ETagged<LeaveTypeDto>(Map(type), type.RowVersion);
}
public async Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _types.Query().AnyAsync(t => t.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A leave type with code '{code}' already exists.");
var type = new LeaveType
{
Code = code,
Name = request.Name.Trim(),
IsPaid = request.IsPaid,
CountsAsNoPay = request.CountsAsNoPay,
AccrualPerYear = request.AccrualPerYear,
CarryForwardAllowed = request.CarryForwardAllowed,
MaxCarryForwardDays = request.MaxCarryForwardDays,
RequiresApproval = request.RequiresApproval,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _types.AddAsync(type, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<LeaveTypeDto>(Map(type), type.RowVersion);
}
public async Task<ETagged<LeaveTypeDto>> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(leaveTypeId, ct)
?? throw new NotFoundException($"Leave type {leaveTypeId} was not found.");
if (type.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The leave type was modified by another request.", 412);
type.Name = request.Name.Trim();
type.IsPaid = request.IsPaid;
type.CountsAsNoPay = request.CountsAsNoPay;
type.AccrualPerYear = request.AccrualPerYear;
type.CarryForwardAllowed = request.CarryForwardAllowed;
type.MaxCarryForwardDays = request.MaxCarryForwardDays;
type.RequiresApproval = request.RequiresApproval;
type.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The leave type was modified by another request.", 412);
}
return new ETagged<LeaveTypeDto>(Map(type), type.RowVersion);
}
public async Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default)
{
var type = await _types.GetByIdAsync(leaveTypeId, ct)
?? throw new NotFoundException($"Leave type {leaveTypeId} was not found.");
type.Status = status;
type.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static LeaveTypeDto Map(LeaveType t) => new(
t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear,
t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt);
}
@@ -0,0 +1,152 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <inheritdoc cref="IPayrollCalculationService"/>
public sealed class PayrollCalculationService : IPayrollCalculationService
{
private readonly IRepository<EmployeeSalaryStructure> _structures;
private readonly IRepository<AttendanceRecord> _attendance;
private readonly IRepository<WorkShift> _workShifts;
private readonly IEmployeeLoanService _loans;
private readonly IPayrollStatutorySettingService _statutory;
private readonly ITaxSlabService _taxSlabs;
public PayrollCalculationService(
IRepository<EmployeeSalaryStructure> structures, IRepository<AttendanceRecord> attendance,
IRepository<WorkShift> workShifts, IEmployeeLoanService loans,
IPayrollStatutorySettingService statutory, ITaxSlabService taxSlabs)
{
_structures = structures;
_attendance = attendance;
_workShifts = workShifts;
_loans = loans;
_statutory = statutory;
_taxSlabs = taxSlabs;
}
public async Task<PayrollLine> CalculateAsync(Employee employee, int periodYear, int periodMonth, CancellationToken ct = default)
{
var periodStart = new DateTime(periodYear, periodMonth, 1);
var periodEnd = periodStart.AddMonths(1).AddDays(-1);
var structure = await _structures.Query().AsNoTracking()
.Include(s => s.Lines).ThenInclude(l => l.SalaryComponent)
.Where(s => s.EmployeeId == employee.EmployeeId && s.EffectiveFrom <= periodEnd && (s.EffectiveTo == null || s.EffectiveTo >= periodStart))
.OrderByDescending(s => s.EffectiveFrom)
.FirstOrDefaultAsync(ct)
?? throw new NotFoundException($"Employee {employee.EmployeeId} has no salary structure effective for {periodYear}-{periodMonth:00}.");
var shift = await _workShifts.GetByIdAsync(employee.WorkShiftId, ct)
?? throw new NotFoundException($"Work shift {employee.WorkShiftId} was not found.");
var records = await _attendance.Query().AsNoTracking()
.Where(r => r.EmployeeId == employee.EmployeeId && r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd)
.ToListAsync(ct);
var presentDays = records.Count(r => r.AttendanceStatus is AttendanceStatus.Present or AttendanceStatus.HalfDay);
var absentDays = records.Count(r => r.AttendanceStatus == AttendanceStatus.Absent);
// Simplification: all OnLeave days are currently treated as paid — AttendanceRecord doesn't
// carry which LeaveType covered it, so unpaid-leave No-Pay isn't distinguished here yet (docs §8).
var leaveDays = records.Count(r => r.AttendanceStatus == AttendanceStatus.OnLeave);
var otMinutesTotal = records.Sum(r => r.OvertimeMinutes);
var lateMinutesTotal = records.Sum(r => r.LateMinutes);
var daysInMonth = DateTime.DaysInMonth(periodYear, periodMonth);
var dailyRate = structure.BasicSalary / daysInMonth;
var perMinuteRate = shift.StandardWorkingMinutes > 0 ? dailyRate / shift.StandardWorkingMinutes : 0m;
var earningLines = structure.Lines.Where(l => l.SalaryComponent!.ComponentType == SalaryComponentType.Earning).ToList();
var deductionLines = structure.Lines.Where(l => l.SalaryComponent!.ComponentType == SalaryComponentType.Deduction).ToList();
var totalAllowances = earningLines.Sum(l => l.Amount);
var otMultiplier = shift.OtMultiplier;
var overtimeAmount = Math.Round(perMinuteRate * otMultiplier * otMinutesTotal, 2);
var grossSalary = structure.BasicSalary + totalAllowances + overtimeAmount;
var lateDeduction = Math.Round(perMinuteRate * lateMinutesTotal, 2);
var noPayAmount = Math.Round(dailyRate * absentDays, 2);
var otherDeductionsAmount = deductionLines.Sum(l => l.Amount);
var dueInstallments = await _loans.GetDueInstallmentsAsync(employee.EmployeeId, periodYear, periodMonth, ct);
var loanDeduction = dueInstallments.Sum(i => i.ScheduledAmount);
var statutory = await _statutory.GetEffectiveAsync(periodStart, ct);
var epfEtfBase = structure.BasicSalary + earningLines.Where(l => l.SalaryComponent!.IsEpfEtfApplicable).Sum(l => l.Amount);
var epfEmployeeAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EpfEmployeeRate, 2);
var epfEmployerAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EpfEmployerRate, 2);
var etfEmployerAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EtfEmployerRate, 2);
var taxableIncome = structure.BasicSalary + earningLines.Where(l => l.SalaryComponent!.IsTaxable).Sum(l => l.Amount) + overtimeAmount;
var slabs = await _taxSlabs.GetEffectiveSlabsAsync(periodStart, ct);
var taxAmount = Math.Round(ComputeMarginalTax(taxableIncome, slabs), 2);
var netSalary = grossSalary - lateDeduction - noPayAmount - loanDeduction - epfEmployeeAmount - taxAmount - otherDeductionsAmount;
var line = new PayrollLine
{
EmployeeId = employee.EmployeeId,
BasicSalary = structure.BasicSalary,
TotalAllowances = totalAllowances,
OvertimeAmount = overtimeAmount,
GrossSalary = grossSalary,
LateDeductionAmount = lateDeduction,
NoPayAmount = noPayAmount,
LoanDeductionAmount = loanDeduction,
EpfEmployeeAmount = epfEmployeeAmount,
EpfEmployerAmount = epfEmployerAmount,
EtfEmployerAmount = etfEmployerAmount,
TaxAmount = taxAmount,
OtherDeductionsAmount = otherDeductionsAmount,
NetSalary = netSalary,
WorkingDays = presentDays + absentDays + leaveDays,
PresentDays = presentDays,
AbsentDays = absentDays,
LeaveDays = leaveDays,
OtMinutesTotal = otMinutesTotal,
LateMinutesTotal = lateMinutesTotal
};
var sort = 0;
line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, Label = "Basic Salary", Amount = structure.BasicSalary, SortOrder = sort++ });
foreach (var l in earningLines)
line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, SalaryComponentId = l.SalaryComponentId, Label = l.SalaryComponent!.Name, Amount = l.Amount, SortOrder = sort++ });
if (overtimeAmount > 0)
line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, Label = "Overtime", Amount = overtimeAmount, SortOrder = sort++ });
if (lateDeduction > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Late Deduction", Amount = lateDeduction, SortOrder = sort++ });
if (noPayAmount > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "No Pay", Amount = noPayAmount, SortOrder = sort++ });
if (loanDeduction > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Loan", Amount = loanDeduction, SortOrder = sort++ });
foreach (var l in deductionLines)
line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, SalaryComponentId = l.SalaryComponentId, Label = l.SalaryComponent!.Name, Amount = l.Amount, SortOrder = sort++ });
line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "EPF (Employee)", Amount = epfEmployeeAmount, SortOrder = sort++ });
line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Tax", Amount = taxAmount, SortOrder = sort++ });
line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.EmployerContribution, Label = "EPF (Employer)", Amount = epfEmployerAmount, SortOrder = sort++ });
line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.EmployerContribution, Label = "ETF (Company Contribution)", Amount = etfEmployerAmount, SortOrder = sort++ });
return line;
}
/// <summary>Standard ascending marginal-slab computation over taxable income.</summary>
private static decimal ComputeMarginalTax(decimal taxableIncome, List<TaxSlab> slabs)
{
if (taxableIncome <= 0 || slabs.Count == 0) return 0m;
var tax = 0m;
foreach (var slab in slabs.OrderBy(s => s.LowerBound))
{
if (taxableIncome <= slab.LowerBound) continue;
var upper = slab.UpperBound ?? taxableIncome;
var taxableInBand = Math.Min(taxableIncome, upper) - slab.LowerBound;
if (taxableInBand <= 0) continue;
tax += taxableInBand * slab.Rate;
}
return tax;
}
}
@@ -0,0 +1,283 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>
/// Payroll run orchestration (FR-HR-PAY-05/06). Maps the business's 5-step flow onto
/// 3 stored states (docs/12-BACKEND-HRM.md A.4): Generate→Draft, Review is a human
/// action, Approve→Approved, Lock→Locked (the point loan installments and attendance
/// batches are stamped consumed — deliberately deferred from Generate so a
/// discarded/regenerated Draft never prematurely consumes them), Generate Payslips is
/// an action gated on Locked.
/// </summary>
public sealed class PayrollRunService : IPayrollRunService
{
private readonly IRepository<PayrollRun> _runs;
private readonly IRepository<Employee> _employees;
private readonly IRepository<AttendanceUploadBatch> _attendanceBatches;
private readonly IRepository<LoanInstallment> _installments;
private readonly IRepository<EmployeeLoan> _loans;
private readonly IRepository<Payslip> _payslips;
private readonly IPayrollCalculationService _calculation;
private readonly IEmployeeLoanService _loanService;
private readonly INumberSequenceService _numberSequence;
private readonly IUnitOfWork _uow;
public PayrollRunService(
IRepository<PayrollRun> runs, IRepository<Employee> employees, IRepository<AttendanceUploadBatch> attendanceBatches,
IRepository<LoanInstallment> installments, IRepository<EmployeeLoan> loans, IRepository<Payslip> payslips,
IPayrollCalculationService calculation, IEmployeeLoanService loanService, INumberSequenceService numberSequence, IUnitOfWork uow)
{
_runs = runs;
_employees = employees;
_attendanceBatches = attendanceBatches;
_installments = installments;
_loans = loans;
_payslips = payslips;
_calculation = calculation;
_loanService = loanService;
_numberSequence = numberSequence;
_uow = uow;
}
public async Task<PagedResponse<PayrollRunDto>> ListAsync(
PageQuery query, int? periodYear, int? periodMonth, PayrollRunStatus? status, CancellationToken ct = default)
{
var q = _runs.Query().AsNoTracking().Include(r => r.Lines).AsQueryable();
if (periodYear is not null) q = q.Where(r => r.PeriodYear == periodYear);
if (periodMonth is not null) q = q.Where(r => r.PeriodMonth == periodMonth);
if (status is not null) q = q.Where(r => r.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(r => r.GeneratedAt)
.Skip(query.Skip).Take(query.PageSize)
.ToListAsync(ct);
return PagedResponse<PayrollRunDto>.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total);
}
public async Task<PayrollRunDto?> GetAsync(int payrollRunId, CancellationToken ct = default)
{
var run = await _runs.Query().AsNoTracking().Include(r => r.Lines)
.FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct);
return run is null ? null : Map(run);
}
public async Task<List<PayrollLineDto>> ListLinesAsync(int payrollRunId, CancellationToken ct = default)
{
var lines = await _runs.Query().AsNoTracking()
.Where(r => r.PayrollRunId == payrollRunId)
.SelectMany(r => r.Lines)
.Include(l => l.Employee)
.OrderBy(l => l.Employee!.FullName)
.ToListAsync(ct);
return lines.Select(MapLine).ToList();
}
public async Task<PayrollLineDetailDto?> GetLineAsync(int payrollRunId, int lineId, CancellationToken ct = default)
{
var line = await _runs.Query().AsNoTracking()
.Where(r => r.PayrollRunId == payrollRunId)
.SelectMany(r => r.Lines)
.Include(l => l.Employee)
.Include(l => l.Components).ThenInclude(c => c.SalaryComponent)
.FirstOrDefaultAsync(l => l.PayrollLineId == lineId, ct);
return line is null ? null : MapLineDetail(line);
}
public async Task<PayrollRunDto> GenerateAsync(GeneratePayrollRunRequest request, int actorUserId, CancellationToken ct = default)
{
var unconfirmed = await _attendanceBatches.Query().AnyAsync(b =>
b.PeriodStart.Year == request.PeriodYear && b.PeriodStart.Month == request.PeriodMonth &&
(b.Status == AttendanceBatchStatus.Draft || b.Status == AttendanceBatchStatus.Validated), ct);
if (unconfirmed)
throw new DomainException(ErrorCodes.AttendanceNotConfirmed,
"One or more attendance batches for this period are not yet Confirmed.", 422);
var employeesQuery = _employees.Query().Where(e => e.Status == EmployeeStatus.Active);
if (request.BranchId is not null) employeesQuery = employeesQuery.Where(e => e.BranchId == request.BranchId);
var employees = await employeesQuery.ToListAsync(ct);
var docNo = await _numberSequence.NextAsync("PAY", ct);
var run = new PayrollRun
{
DocNo = docNo,
PeriodYear = request.PeriodYear,
PeriodMonth = request.PeriodMonth,
BranchId = request.BranchId,
Status = PayrollRunStatus.Draft,
GeneratedBy = actorUserId,
GeneratedAt = DateTime.UtcNow
};
foreach (var employee in employees)
{
try
{
var line = await _calculation.CalculateAsync(employee, request.PeriodYear, request.PeriodMonth, ct);
run.Lines.Add(line);
}
catch (NotFoundException)
{
// No effective salary structure for this employee this period — skip rather than fail the whole run.
}
}
await _runs.AddAsync(run, ct);
await _uow.SaveChangesAsync(ct);
return Map(run);
}
public async Task<PayrollRunDto> ApproveAsync(int payrollRunId, int actorUserId, CancellationToken ct = default)
{
var run = await GetTrackedAsync(payrollRunId, ct);
if (run.Status != PayrollRunStatus.Draft)
throw new DomainException(ErrorCodes.Conflict, "Only a Draft payroll run can be approved.", 409);
run.Status = PayrollRunStatus.Approved;
run.ApprovedBy = actorUserId;
run.ApprovedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
return Map(run);
}
public async Task<PayrollRunDto> LockAsync(int payrollRunId, int actorUserId, CancellationToken ct = default)
{
var run = await GetTrackedAsync(payrollRunId, ct);
if (run.Status != PayrollRunStatus.Approved)
throw new DomainException(ErrorCodes.Conflict, "Only an Approved payroll run can be locked.", 409);
await _uow.ExecuteInTransactionAsync(async innerCt =>
{
// Stamp due loan installments as Deducted, decrementing outstanding balance.
var employeeIds = run.Lines.Select(l => l.EmployeeId).ToList();
var loans = await _loans.Query().Include(l => l.Installments)
.Where(l => employeeIds.Contains(l.EmployeeId) && l.Status == LoanStatus.Active)
.ToListAsync(innerCt);
foreach (var loan in loans)
{
foreach (var installment in loan.Installments.Where(i =>
i.DueYear == run.PeriodYear && i.DueMonth == run.PeriodMonth && i.Status == LoanInstallmentStatus.Pending))
{
installment.Status = LoanInstallmentStatus.Deducted;
installment.PaidAmount = installment.ScheduledAmount;
installment.PayrollRunId = run.PayrollRunId;
loan.OutstandingBalance = Math.Max(0, loan.OutstandingBalance - installment.ScheduledAmount);
if (loan.OutstandingBalance == 0) loan.Status = LoanStatus.Closed;
}
}
// Flip consumed attendance batches Confirmed -> UsedInPayroll.
var batches = await _attendanceBatches.Query()
.Where(b => b.PeriodStart.Year == run.PeriodYear && b.PeriodStart.Month == run.PeriodMonth && b.Status == AttendanceBatchStatus.Confirmed)
.ToListAsync(innerCt);
foreach (var batch in batches) batch.Status = AttendanceBatchStatus.UsedInPayroll;
run.Status = PayrollRunStatus.Locked;
run.LockedBy = actorUserId;
run.LockedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(innerCt);
}, ct);
return Map(run);
}
public async Task<PayrollRunDto> UnlockAsync(int payrollRunId, string reason, int actorUserId, CancellationToken ct = default)
{
var run = await GetTrackedAsync(payrollRunId, ct);
if (run.Status != PayrollRunStatus.Locked)
throw new DomainException(ErrorCodes.PayrollPeriodLocked, "Only a Locked payroll run can be unlocked.", 409);
await _uow.ExecuteInTransactionAsync(async innerCt =>
{
var installments = await _installments.Query()
.Where(i => i.PayrollRunId == run.PayrollRunId)
.Include(i => i.EmployeeLoan)
.ToListAsync(innerCt);
foreach (var installment in installments)
{
installment.Status = LoanInstallmentStatus.Pending;
installment.PaidAmount = null;
installment.PayrollRunId = null;
if (installment.EmployeeLoan is not null)
{
installment.EmployeeLoan.OutstandingBalance += installment.ScheduledAmount;
installment.EmployeeLoan.Status = LoanStatus.Active;
}
}
var batches = await _attendanceBatches.Query()
.Where(b => b.PeriodStart.Year == run.PeriodYear && b.PeriodStart.Month == run.PeriodMonth && b.Status == AttendanceBatchStatus.UsedInPayroll)
.ToListAsync(innerCt);
foreach (var batch in batches) batch.Status = AttendanceBatchStatus.Confirmed;
run.Status = PayrollRunStatus.Approved;
run.UnlockedBy = actorUserId;
run.UnlockedAt = DateTime.UtcNow;
run.UnlockReason = reason.Trim();
await _uow.SaveChangesAsync(innerCt);
}, ct);
return Map(run);
}
public async Task<List<PayslipDto>> GeneratePayslipsAsync(int payrollRunId, CancellationToken ct = default)
{
var run = await _runs.Query().Include(r => r.Lines).FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct)
?? throw new NotFoundException($"Payroll run {payrollRunId} was not found.");
if (run.Status != PayrollRunStatus.Locked)
throw new DomainException(ErrorCodes.Conflict, "Payslips can only be generated for a Locked payroll run.", 409);
var existingLineIds = await _payslips.Query()
.Where(p => run.Lines.Select(l => l.PayrollLineId).Contains(p.PayrollLineId))
.Select(p => p.PayrollLineId)
.ToListAsync(ct);
var created = new List<Payslip>();
foreach (var line in run.Lines.Where(l => !existingLineIds.Contains(l.PayrollLineId)))
{
var payslip = new Payslip { PayrollLineId = line.PayrollLineId, GeneratedAt = DateTime.UtcNow };
created.Add(payslip);
await _payslips.AddAsync(payslip, ct);
}
await _uow.SaveChangesAsync(ct);
var all = await _payslips.Query().AsNoTracking()
.Where(p => run.Lines.Select(l => l.PayrollLineId).Contains(p.PayrollLineId))
.ToListAsync(ct);
return all.Select(p => new PayslipDto(p.PayslipId, p.PayrollLineId, p.GeneratedAt, p.ReleasedAt, p.ReleasedBy)).ToList();
}
private async Task<PayrollRun> GetTrackedAsync(int payrollRunId, CancellationToken ct)
=> await _runs.Query().Include(r => r.Lines).FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct)
?? throw new NotFoundException($"Payroll run {payrollRunId} was not found.");
private static PayrollRunDto Map(PayrollRun r) => new(
r.PayrollRunId, r.DocNo, r.PeriodYear, r.PeriodMonth, r.BranchId, r.Status,
r.GeneratedBy, r.GeneratedAt, r.ApprovedBy, r.ApprovedAt, r.LockedBy, r.LockedAt,
r.UnlockedBy, r.UnlockedAt, r.UnlockReason,
r.Lines.Sum(l => l.GrossSalary), r.Lines.Sum(l => l.NetSalary), r.Lines.Count);
private static PayrollLineDto MapLine(PayrollLine l) => new(
l.PayrollLineId, l.PayrollRunId, l.EmployeeId, l.Employee?.EmployeeCode, l.Employee?.FullName,
l.BasicSalary, l.TotalAllowances, l.OvertimeAmount, l.GrossSalary,
l.LateDeductionAmount, l.NoPayAmount, l.LoanDeductionAmount,
l.EpfEmployeeAmount, l.EpfEmployerAmount, l.EtfEmployerAmount, l.TaxAmount, l.OtherDeductionsAmount, l.NetSalary,
l.WorkingDays, l.PresentDays, l.AbsentDays, l.LeaveDays, l.OtMinutesTotal, l.LateMinutesTotal);
private static PayrollLineDetailDto MapLineDetail(PayrollLine l) => new(
MapLine(l),
l.Components.OrderBy(c => c.SortOrder).Select(c => new PayrollLineComponentDto(
c.ComponentCategory, c.SalaryComponentId, c.Label, c.Amount, c.SortOrder)).ToList());
}
@@ -0,0 +1,63 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>Effective-dated EPF/ETF settings (FR-HR-PAY-04, docs/13-BACKEND-HRM-API.md §6).</summary>
public sealed class PayrollStatutorySettingService : IPayrollStatutorySettingService
{
private readonly IRepository<PayrollStatutorySetting> _settings;
private readonly IUnitOfWork _uow;
public PayrollStatutorySettingService(IRepository<PayrollStatutorySetting> settings, IUnitOfWork uow)
{
_settings = settings;
_uow = uow;
}
public async Task<List<PayrollStatutorySettingDto>> ListAsync(CancellationToken ct = default)
{
var rows = await _settings.Query().AsNoTracking().OrderByDescending(s => s.EffectiveFrom).ToListAsync(ct);
return rows.Select(Map).ToList();
}
public async Task<PayrollStatutorySettingDto> CreateAsync(UpsertPayrollStatutorySettingRequest request, int actorUserId, CancellationToken ct = default)
{
var previous = await _settings.Query()
.Where(s => s.EffectiveTo == null)
.OrderByDescending(s => s.EffectiveFrom)
.FirstOrDefaultAsync(ct);
if (previous is not null) previous.EffectiveTo = request.EffectiveFrom.Date.AddDays(-1);
var setting = new PayrollStatutorySetting
{
EpfEmployeeRate = request.EpfEmployeeRate,
EpfEmployerRate = request.EpfEmployerRate,
EtfEmployerRate = request.EtfEmployerRate,
OtMultiplierDefault = request.OtMultiplierDefault,
EffectiveFrom = request.EffectiveFrom.Date,
CreatedBy = actorUserId,
CreatedAt = DateTime.UtcNow
};
await _settings.AddAsync(setting, ct);
await _uow.SaveChangesAsync(ct);
return Map(setting);
}
public async Task<PayrollStatutorySetting?> GetEffectiveAsync(DateTime asOf, CancellationToken ct = default)
{
return await _settings.Query().AsNoTracking()
.Where(s => s.EffectiveFrom <= asOf && (s.EffectiveTo == null || s.EffectiveTo >= asOf))
.OrderByDescending(s => s.EffectiveFrom)
.FirstOrDefaultAsync(ct);
}
private static PayrollStatutorySettingDto Map(PayrollStatutorySetting s) => new(
s.PayrollStatutorySettingId, s.EpfEmployeeRate, s.EpfEmployerRate, s.EtfEmployerRate, s.OtMultiplierDefault, s.EffectiveFrom, s.EffectiveTo);
}
@@ -0,0 +1,74 @@
using System.Net;
using System.Text;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Hrm;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <inheritdoc cref="IPayslipService"/>
public sealed class PayslipService : IPayslipService
{
private readonly IRepository<Payslip> _payslips;
public PayslipService(IRepository<Payslip> payslips) => _payslips = payslips;
public async Task<PayslipDto?> GetAsync(int payslipId, CancellationToken ct = default)
{
var payslip = await _payslips.Query().AsNoTracking().FirstOrDefaultAsync(p => p.PayslipId == payslipId, ct);
return payslip is null ? null : new PayslipDto(payslip.PayslipId, payslip.PayrollLineId, payslip.GeneratedAt, payslip.ReleasedAt, payslip.ReleasedBy);
}
public async Task<string?> RenderHtmlAsync(int payslipId, CancellationToken ct = default)
{
var payslip = await _payslips.Query().AsNoTracking()
.Include(p => p.PayrollLine!).ThenInclude(l => l.Employee)
.Include(p => p.PayrollLine!).ThenInclude(l => l.Components)
.Include(p => p.PayrollLine!).ThenInclude(l => l.PayrollRun)
.FirstOrDefaultAsync(p => p.PayslipId == payslipId, ct);
if (payslip?.PayrollLine is null) return null;
var line = payslip.PayrollLine;
var employee = line.Employee;
var run = line.PayrollRun;
var rows = new StringBuilder();
foreach (var group in line.Components.GroupBy(c => c.ComponentCategory))
{
var title = group.Key switch
{
PayrollLineComponentCategory.Earning => "Earnings",
PayrollLineComponentCategory.Deduction => "Deductions",
_ => "Employer Contributions (informational, not deducted)"
};
rows.Append($"<tr><td colspan='2' style='font-weight:bold;padding-top:12px;'>{WebUtility.HtmlEncode(title)}</td></tr>");
foreach (var c in group.OrderBy(c => c.SortOrder))
rows.Append($"<tr><td>{WebUtility.HtmlEncode(c.Label)}</td><td style='text-align:right;'>{c.Amount:N2}</td></tr>");
}
const string style = "body{font-family:Arial,sans-serif;font-size:14px;color:#111;}"
+ "table{width:100%;border-collapse:collapse;} td{padding:4px 8px;}"
+ ".totals td{font-weight:bold;border-top:2px solid #333;}"
+ "h2{margin-bottom:0;} .sub{color:#555;margin-top:2px;}";
var html = new StringBuilder();
html.Append("<!doctype html><html><head><meta charset=\"utf-8\"><title>Payslip</title><style>")
.Append(style).Append("</style></head><body>")
.Append("<h2>Payslip</h2>")
.Append("<div class=\"sub\">Employee: ").Append(WebUtility.HtmlEncode(employee?.FullName ?? string.Empty))
.Append(" (").Append(WebUtility.HtmlEncode(employee?.EmployeeCode ?? string.Empty)).Append(")</div>")
.Append("<div class=\"sub\">Period: ").Append(run?.PeriodMonth.ToString("00")).Append('/').Append(run?.PeriodYear)
.Append(" &middot; Run ").Append(WebUtility.HtmlEncode(run?.DocNo ?? string.Empty)).Append("</div>")
.Append("<table>").Append(rows)
.Append("<tr class=\"totals\"><td>Gross Salary</td><td style=\"text-align:right;\">").Append(line.GrossSalary.ToString("N2")).Append("</td></tr>")
.Append("<tr class=\"totals\"><td>Net Salary</td><td style=\"text-align:right;\">").Append(line.NetSalary.ToString("N2")).Append("</td></tr>")
.Append("</table>")
.Append("<p class=\"sub\">Generated ").Append(payslip.GeneratedAt.ToString("yyyy-MM-dd HH:mm")).Append(" UTC</p>")
.Append("</body></html>");
return html.ToString();
}
}
@@ -0,0 +1,112 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>SalaryComponent master service (FR-HR-PAY-01, docs/13-BACKEND-HRM-API.md §6).</summary>
public sealed class SalaryComponentService : ISalaryComponentService
{
private readonly IRepository<SalaryComponent> _components;
private readonly IUnitOfWork _uow;
public SalaryComponentService(IRepository<SalaryComponent> components, IUnitOfWork uow)
{
_components = components;
_uow = uow;
}
public async Task<PagedResponse<SalaryComponentDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _components.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%") || EF.Functions.ILike(c.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(c => c.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => Map(c))
.ToListAsync(ct);
return PagedResponse<SalaryComponentDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<SalaryComponentDto>?> GetAsync(int salaryComponentId, CancellationToken ct = default)
{
var component = await _components.Query().AsNoTracking().FirstOrDefaultAsync(c => c.SalaryComponentId == salaryComponentId, ct);
return component is null ? null : new ETagged<SalaryComponentDto>(Map(component), component.RowVersion);
}
public async Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _components.Query().AnyAsync(c => c.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A salary component with code '{code}' already exists.");
var component = new SalaryComponent
{
Code = code,
Name = request.Name.Trim(),
ComponentType = request.ComponentType,
IsTaxable = request.IsTaxable,
IsEpfEtfApplicable = request.IsEpfEtfApplicable,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _components.AddAsync(component, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<SalaryComponentDto>(Map(component), component.RowVersion);
}
public async Task<ETagged<SalaryComponentDto>> UpdateAsync(
int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var component = await _components.GetByIdAsync(salaryComponentId, ct)
?? throw new NotFoundException($"Salary component {salaryComponentId} was not found.");
if (component.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The salary component was modified by another request.", 412);
component.Name = request.Name.Trim();
component.IsTaxable = request.IsTaxable;
component.IsEpfEtfApplicable = request.IsEpfEtfApplicable;
component.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The salary component was modified by another request.", 412);
}
return new ETagged<SalaryComponentDto>(Map(component), component.RowVersion);
}
public async Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default)
{
var component = await _components.GetByIdAsync(salaryComponentId, ct)
?? throw new NotFoundException($"Salary component {salaryComponentId} was not found.");
component.Status = status;
component.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static SalaryComponentDto Map(SalaryComponent c) => new(
c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt);
}
@@ -0,0 +1,67 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>Configurable marginal tax slabs (FR-HR-PAY-04, docs/13-BACKEND-HRM-API.md §6).</summary>
public sealed class TaxSlabService : ITaxSlabService
{
private readonly IRepository<TaxSlab> _slabs;
private readonly IUnitOfWork _uow;
public TaxSlabService(IRepository<TaxSlab> slabs, IUnitOfWork uow)
{
_slabs = slabs;
_uow = uow;
}
public async Task<List<TaxSlabDto>> ListAsync(CancellationToken ct = default)
{
var rows = await _slabs.Query().AsNoTracking()
.OrderByDescending(s => s.EffectiveFrom).ThenBy(s => s.LowerBound)
.ToListAsync(ct);
return rows.Select(Map).ToList();
}
public async Task<TaxSlabDto> CreateAsync(CreateTaxSlabRequest request, CancellationToken ct = default)
{
if (request.UpperBound is not null && request.UpperBound <= request.LowerBound)
throw new DomainException(ErrorCodes.TaxSlabGapInvalid, "Upper bound must be greater than the lower bound.", 422);
var overlapping = await _slabs.Query().AnyAsync(s =>
s.EffectiveFrom.Date == request.EffectiveFrom.Date &&
s.LowerBound < (request.UpperBound ?? decimal.MaxValue) &&
(s.UpperBound ?? decimal.MaxValue) > request.LowerBound, ct);
if (overlapping)
throw new DomainException(ErrorCodes.TaxSlabGapInvalid, "This slab overlaps an existing slab for the same effective date.", 422);
var slab = new TaxSlab
{
EffectiveFrom = request.EffectiveFrom.Date,
LowerBound = request.LowerBound,
UpperBound = request.UpperBound,
Rate = request.Rate,
CreatedAt = DateTime.UtcNow
};
await _slabs.AddAsync(slab, ct);
await _uow.SaveChangesAsync(ct);
return Map(slab);
}
public async Task<List<TaxSlab>> GetEffectiveSlabsAsync(DateTime asOf, CancellationToken ct = default)
{
return await _slabs.Query().AsNoTracking()
.Where(s => s.EffectiveFrom <= asOf && (s.EffectiveTo == null || s.EffectiveTo >= asOf))
.OrderBy(s => s.LowerBound)
.ToListAsync(ct);
}
private static TaxSlabDto Map(TaxSlab s) => new(s.TaxSlabId, s.EffectiveFrom, s.EffectiveTo, s.LowerBound, s.UpperBound, s.Rate);
}
@@ -0,0 +1,127 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services.Hrm;
/// <summary>
/// WorkShift master service (FR-HR-MD-01) — the attendance baseline Late/Early/OT
/// figures are computed against (docs/12-BACKEND-HRM.md A.3).
/// </summary>
public sealed class WorkShiftService : IWorkShiftService
{
private readonly IRepository<WorkShift> _shifts;
private readonly IUnitOfWork _uow;
public WorkShiftService(IRepository<WorkShift> shifts, IUnitOfWork uow)
{
_shifts = shifts;
_uow = uow;
}
public async Task<PagedResponse<WorkShiftDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _shifts.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(w => EF.Functions.ILike(w.Name, $"%{term}%") || EF.Functions.ILike(w.Code, $"%{term}%"));
}
if (status is not null) q = q.Where(w => w.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(w => w.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(w => Map(w))
.ToListAsync(ct);
return PagedResponse<WorkShiftDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<WorkShiftDto>?> GetAsync(int workShiftId, CancellationToken ct = default)
{
var shift = await _shifts.Query().AsNoTracking().FirstOrDefaultAsync(w => w.WorkShiftId == workShiftId, ct);
return shift is null ? null : new ETagged<WorkShiftDto>(Map(shift), shift.RowVersion);
}
public async Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default)
{
var code = request.Code.Trim();
if (await _shifts.Query().AnyAsync(w => w.Code.ToLower() == code.ToLower(), ct))
throw new ConflictException($"A work shift with code '{code}' already exists.");
var shift = new WorkShift
{
Code = code,
Name = request.Name.Trim(),
StartTime = request.StartTime,
EndTime = request.EndTime,
IsOvernight = request.IsOvernight,
GraceMinutes = request.GraceMinutes,
BreakMinutes = request.BreakMinutes,
StandardWorkingMinutes = request.StandardWorkingMinutes,
OtMultiplier = request.OtMultiplier,
WorkingDaysMask = request.WorkingDaysMask,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _shifts.AddAsync(shift, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<WorkShiftDto>(Map(shift), shift.RowVersion);
}
public async Task<ETagged<WorkShiftDto>> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var shift = await _shifts.GetByIdAsync(workShiftId, ct)
?? throw new NotFoundException($"Work shift {workShiftId} was not found.");
if (shift.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The work shift was modified by another request.", 412);
shift.Name = request.Name.Trim();
shift.StartTime = request.StartTime;
shift.EndTime = request.EndTime;
shift.IsOvernight = request.IsOvernight;
shift.GraceMinutes = request.GraceMinutes;
shift.BreakMinutes = request.BreakMinutes;
shift.StandardWorkingMinutes = request.StandardWorkingMinutes;
shift.OtMultiplier = request.OtMultiplier;
shift.WorkingDaysMask = request.WorkingDaysMask;
shift.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The work shift was modified by another request.", 412);
}
return new ETagged<WorkShiftDto>(Map(shift), shift.RowVersion);
}
public async Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default)
{
var shift = await _shifts.GetByIdAsync(workShiftId, ct)
?? throw new NotFoundException($"Work shift {workShiftId} was not found.");
shift.Status = status;
shift.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static WorkShiftDto Map(WorkShift w) => new(
w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight,
w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask,
w.Status, w.CreatedAt, w.UpdatedAt);
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Pure attendance-figure computation against a <see cref="WorkShift"/> baseline
/// (docs/12-BACKEND-HRM.md A.3) — the direct analog of
/// <see cref="Services.Stock.IFifoCostingService"/>: invoked from
/// <see cref="IAttendanceUploadService"/>, never from a controller.
/// </summary>
public interface IAttendanceComputationService
{
/// <summary>
/// Computes WorkingMinutes/LateMinutes/EarlyLeaveMinutes/OvertimeMinutes and the
/// derived AttendanceStatus for one record's CheckIn/CheckOut against its shift,
/// mutating the record in place.
/// </summary>
void Compute(AttendanceRecord record, WorkShift shift, bool hasApprovedLeave, bool isHoliday, bool isWeekOff);
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Attendance upload/validate/confirm pipeline (FR-HR-ATT, docs/13-BACKEND-HRM-API.md §4).</summary>
public interface IAttendanceUploadService
{
Task<PagedResponse<AttendanceUploadBatchDto>> ListBatchesAsync(
PageQuery query, AttendanceBatchStatus? status, int? periodYear, int? periodMonth, CancellationToken ct = default);
Task<AttendanceUploadBatchDto?> GetBatchAsync(int batchId, CancellationToken ct = default);
Task<AttendanceUploadBatchDto> UploadAsync(
Stream fileContent, string fileName, DateTime periodStart, DateTime periodEnd, int actorUserId, CancellationToken ct = default);
Task<List<AttendanceRecordDto>> ListRecordsAsync(int batchId, RowValidationStatus? status, CancellationToken ct = default);
Task<AttendanceRecordDto> UpdateRecordAsync(int batchId, int recordId, UpdateAttendanceRecordRequest request, int actorUserId, CancellationToken ct = default);
Task ResolveDuplicateAsync(int batchId, ResolveDuplicateRequest request, CancellationToken ct = default);
Task<AttendanceUploadBatchDto> ValidateAsync(int batchId, CancellationToken ct = default);
Task<AttendanceUploadBatchDto> ConfirmAsync(int batchId, int actorUserId, CancellationToken ct = default);
Task<AttendanceUploadBatchDto> UnlockAsync(int batchId, string reason, int actorUserId, CancellationToken ct = default);
/// <summary>Generates the upload template in the same column shape the parser expects.</summary>
(byte[] Content, string ContentType, string FileName) GenerateTemplate(bool asCsv);
}
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Branch master business logic (docs/13-BACKEND-HRM-API.md §2).</summary>
public interface IBranchService
{
Task<PagedResponse<BranchDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<BranchDto>?> GetAsync(int branchId, CancellationToken ct = default);
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);
}
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Department master business logic, incl. self-nesting cycle guard (docs/13-BACKEND-HRM-API.md §2).</summary>
public interface IDepartmentService
{
Task<PagedResponse<DepartmentDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<DepartmentDto>?> GetAsync(int departmentId, CancellationToken ct = default);
Task<ETagged<DepartmentDto>> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default);
Task<ETagged<DepartmentDto>> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
public interface IDesignationService
{
Task<PagedResponse<DesignationDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<DesignationDto>?> GetAsync(int designationId, CancellationToken ct = default);
Task<ETagged<DesignationDto>> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default);
Task<ETagged<DesignationDto>> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Uploaded staff document ("Doc") business logic (docs/13-BACKEND-HRM-API.md §3).</summary>
public interface IEmployeeDocumentService
{
Task<List<EmployeeDocumentDto>> ListAsync(int employeeId, CancellationToken ct = default);
Task<EmployeeDocumentDto> UploadAsync(
int employeeId, UploadEmployeeDocumentRequest request, Stream fileContent, string fileName, string contentType,
int actorUserId, CancellationToken ct = default);
Task<(Stream Content, string FileName, string ContentType)> DownloadAsync(int employeeId, int documentId, CancellationToken ct = default);
Task SetStatusAsync(int employeeId, int documentId, EmployeeDocumentStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,14 @@
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Loan/Advance business logic (FR-HR-PAY-03, docs/13-BACKEND-HRM-API.md §6).</summary>
public interface IEmployeeLoanService
{
Task<List<EmployeeLoanDto>> ListAsync(int employeeId, CancellationToken ct = default);
Task<EmployeeLoanDto?> GetAsync(int employeeId, int loanId, CancellationToken ct = default);
Task<EmployeeLoanDto> CreateAsync(int employeeId, CreateEmployeeLoanRequest request, int actorUserId, CancellationToken ct = default);
/// <summary>Due, not-yet-deducted installments for an employee in a given period — consumed by PayrollCalculationService.</summary>
Task<List<Domain.Entities.LoanInstallment>> GetDueInstallmentsAsync(int employeeId, int periodYear, int periodMonth, CancellationToken ct = default);
}
@@ -0,0 +1,11 @@
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Effective-dated salary structure business logic (FR-HR-PAY-02, docs/13-BACKEND-HRM-API.md §6).</summary>
public interface IEmployeeSalaryStructureService
{
Task<List<EmployeeSalaryStructureDto>> ListHistoryAsync(int employeeId, CancellationToken ct = default);
Task<EmployeeSalaryStructureDto?> GetCurrentAsync(int employeeId, CancellationToken ct = default);
Task<EmployeeSalaryStructureDto> CreateAsync(int employeeId, CreateSalaryStructureRequest request, int actorUserId, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Employee (staff) business logic (docs/13-BACKEND-HRM-API.md §3).</summary>
public interface IEmployeeService
{
Task<PagedResponse<EmployeeListItemDto>> ListAsync(
PageQuery query, EmployeeStatus? status, int? departmentId, int? designationId, int? branchId, CancellationToken ct = default);
Task<ETagged<EmployeeDetailDto>?> GetAsync(int employeeId, CancellationToken ct = default);
Task<ETagged<EmployeeDetailDto>> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default);
Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default);
Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,22 @@
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Bidirectional Employee&lt;-&gt;User soft-match/link logic (docs/12-BACKEND-HRM.md A.5,
/// Part B.3.2). Lookups are advisory only; linking is always an explicit, human-confirmed
/// action — never automatic, even on an exact email match.
/// </summary>
public interface IEmployeeUserLinkService
{
/// <summary>Given an email (typically entered on a Create-User form), find an unlinked Staff record match.</summary>
Task<EmployeeMatchDto?> FindStaffCandidateByEmailAsync(string email, CancellationToken ct = default);
/// <summary>Given an email (typically entered on a Create-Employee form), find an unlinked User account match.</summary>
Task<UserMatchDto?> FindUserCandidateByEmailAsync(string email, CancellationToken ct = default);
/// <summary>Links an existing Employee to an existing User. Throws EMPLOYEE_ALREADY_LINKED/USER_ALREADY_LINKED if either side is already linked to someone else.</summary>
Task LinkAsync(int employeeId, int userId, CancellationToken ct = default);
Task UnlinkAsync(int employeeId, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
public interface IEmploymentTypeService
{
Task<PagedResponse<EmploymentTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<EmploymentTypeDto>?> GetAsync(int employmentTypeId, CancellationToken ct = default);
Task<ETagged<EmploymentTypeDto>> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default);
Task<ETagged<EmploymentTypeDto>> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Staff document-type catalog ("DocType") business logic (docs/13-BACKEND-HRM-API.md §2).</summary>
public interface IHrDocumentTypeService
{
Task<PagedResponse<HrDocumentTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<HrDocumentTypeDto>?> GetAsync(int hrDocumentTypeId, CancellationToken ct = default);
Task<ETagged<HrDocumentTypeDto>> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default);
Task<ETagged<HrDocumentTypeDto>> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,19 @@
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Read-only HRM aggregation reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6) — no
/// new entities, queries over Attendance/Payroll/Leave/Document data that already
/// exists, mirroring how StockController answers on-hand/ledger queries today.
/// </summary>
public interface IHrReportService
{
Task<List<AttendanceSummaryRowDto>> AttendanceSummaryAsync(int periodYear, int periodMonth, int? departmentId, CancellationToken ct = default);
Task<List<OvertimeReportRowDto>> OvertimeReportAsync(int periodYear, int periodMonth, CancellationToken ct = default);
Task<List<LateArrivalReportRowDto>> LateArrivalReportAsync(int periodYear, int periodMonth, CancellationToken ct = default);
Task<List<PayrollRegisterRowDto>> PayrollRegisterAsync(int payrollRunId, CancellationToken ct = default);
Task<List<SalaryHistoryRowDto>> SalaryHistoryAsync(int employeeId, CancellationToken ct = default);
Task<List<LeaveBalanceReportRowDto>> LeaveBalanceReportAsync(int year, CancellationToken ct = default);
Task<List<DocumentExpiryReportRowDto>> DocumentExpiryReportAsync(int withinDays, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
public interface ILeaveBalanceService
{
Task<List<LeaveBalanceDto>> ListAsync(int employeeId, int? year, CancellationToken ct = default);
Task<List<LeaveBalanceDto>> ApplyAdjustmentsAsync(int employeeId, UpdateLeaveBalancesRequest request, CancellationToken ct = default);
/// <summary>Increments TakenDays for an approved leave request; called by ILeaveRequestService.ApproveAsync.</summary>
Task IncrementTakenDaysAsync(int employeeId, int leaveTypeId, int year, decimal days, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Domain.Enums;
namespace ERPCore.Services.Interfaces;
/// <summary>Leave request business logic (FR-HR-LV-02, docs/13-BACKEND-HRM-API.md §5).</summary>
public interface ILeaveRequestService
{
Task<PagedResponse<LeaveRequestDto>> ListAsync(PageQuery query, int? employeeId, LeaveRequestStatus? status, CancellationToken ct = default);
Task<LeaveRequestDto?> GetAsync(int leaveRequestId, CancellationToken ct = default);
Task<LeaveRequestDto> CreateAsync(CreateLeaveRequestRequest request, int actorUserId, CancellationToken ct = default);
Task<LeaveRequestDto> SubmitAsync(int leaveRequestId, CancellationToken ct = default);
Task<LeaveRequestDto> ApproveAsync(int leaveRequestId, int actorUserId, CancellationToken ct = default);
Task<LeaveRequestDto> RejectAsync(int leaveRequestId, string reason, int actorUserId, CancellationToken ct = default);
Task<LeaveRequestDto> CancelAsync(int leaveRequestId, CancellationToken ct = default);
/// <summary>True if the employee has an Approved leave request covering the given date (used by Attendance's OnLeave classification).</summary>
Task<Domain.Entities.LeaveRequest?> FindApprovedLeaveCoveringAsync(int employeeId, DateTime date, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
public interface ILeaveTypeService
{
Task<PagedResponse<LeaveTypeDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<LeaveTypeDto>?> GetAsync(int leaveTypeId, CancellationToken ct = default);
Task<ETagged<LeaveTypeDto>> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default);
Task<ETagged<LeaveTypeDto>> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,14 @@
using ERPCore.Domain.Entities;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Payroll calculation domain service (FR-HR-PAY-05) — the payroll analog of
/// <see cref="Services.Stock.IFifoCostingService"/>. Computes one <see cref="PayrollLine"/>
/// (with its <see cref="PayrollLineComponent"/> breakdown) per employee per period,
/// per the formula in docs/12-BACKEND-HRM.md B.4. Never invoked from a controller directly.
/// </summary>
public interface IPayrollCalculationService
{
Task<PayrollLine> CalculateAsync(Employee employee, int periodYear, int periodMonth, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Payroll run approval workflow (FR-HR-PAY-05/06, docs/13-BACKEND-HRM-API.md §6).</summary>
public interface IPayrollRunService
{
Task<PagedResponse<PayrollRunDto>> ListAsync(PageQuery query, int? periodYear, int? periodMonth, PayrollRunStatus? status, CancellationToken ct = default);
Task<PayrollRunDto?> GetAsync(int payrollRunId, CancellationToken ct = default);
Task<List<PayrollLineDto>> ListLinesAsync(int payrollRunId, CancellationToken ct = default);
Task<PayrollLineDetailDto?> GetLineAsync(int payrollRunId, int lineId, CancellationToken ct = default);
Task<PayrollRunDto> GenerateAsync(GeneratePayrollRunRequest request, int actorUserId, CancellationToken ct = default);
Task<PayrollRunDto> ApproveAsync(int payrollRunId, int actorUserId, CancellationToken ct = default);
Task<PayrollRunDto> LockAsync(int payrollRunId, int actorUserId, CancellationToken ct = default);
Task<PayrollRunDto> UnlockAsync(int payrollRunId, string reason, int actorUserId, CancellationToken ct = default);
Task<List<PayslipDto>> GeneratePayslipsAsync(int payrollRunId, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
public interface IPayrollStatutorySettingService
{
Task<List<PayrollStatutorySettingDto>> ListAsync(CancellationToken ct = default);
Task<PayrollStatutorySettingDto> CreateAsync(UpsertPayrollStatutorySettingRequest request, int actorUserId, CancellationToken ct = default);
Task<Domain.Entities.PayrollStatutorySetting?> GetEffectiveAsync(DateTime asOf, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
/// <summary>Payslip retrieval + HTML print view (FR-HR-PAY-07, docs/13-BACKEND-HRM-API.md §6). No PDF dependency in this phase.</summary>
public interface IPayslipService
{
Task<PayslipDto?> GetAsync(int payslipId, CancellationToken ct = default);
Task<string?> RenderHtmlAsync(int payslipId, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
public interface ISalaryComponentService
{
Task<PagedResponse<SalaryComponentDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<SalaryComponentDto>?> GetAsync(int salaryComponentId, CancellationToken ct = default);
Task<ETagged<SalaryComponentDto>> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default);
Task<ETagged<SalaryComponentDto>> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default);
}
@@ -0,0 +1,10 @@
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
public interface ITaxSlabService
{
Task<List<TaxSlabDto>> ListAsync(CancellationToken ct = default);
Task<TaxSlabDto> CreateAsync(CreateTaxSlabRequest request, CancellationToken ct = default);
Task<List<Domain.Entities.TaxSlab>> GetEffectiveSlabsAsync(DateTime asOf, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
namespace ERPCore.Services.Interfaces;
public interface IWorkShiftService
{
Task<PagedResponse<WorkShiftDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<WorkShiftDto>?> GetAsync(int workShiftId, CancellationToken ct = default);
Task<ETagged<WorkShiftDto>> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default);
Task<ETagged<WorkShiftDto>> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default);
}
@@ -18,15 +18,18 @@ public sealed class UserManagementService : IUserManagementService
private readonly IRepository<Role> _roles;
private readonly IAuthUserService _authUsers;
private readonly IAuthHexClient _authHex;
private readonly IEmployeeUserLinkService _links;
private readonly IUnitOfWork _uow;
public UserManagementService(
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex, IUnitOfWork uow)
IRepository<User> users, IRepository<Role> roles, IAuthUserService authUsers, IAuthHexClient authHex,
IEmployeeUserLinkService links, IUnitOfWork uow)
{
_users = users;
_roles = roles;
_authUsers = authUsers;
_authHex = authHex;
_links = links;
_uow = uow;
}
@@ -82,12 +85,15 @@ public sealed class UserManagementService : IUserManagementService
}, ct);
// Mirror into the local shadow User row immediately, rather than waiting
// for ShadowUserClaimsTransformation's next-login JIT provisioning.
// for ShadowUserClaimsTransformation's next-login JIT provisioning. Email is
// persisted here too — it is the field the Employee<->User cross-link
// (docs/12-BACKEND-HRM.md A.5) matches on.
var user = new User
{
AuthUserId = authUserId,
Username = username,
DisplayName = request.FullName.Trim(),
Email = request.Email.Trim(),
RoleId = role.RoleId,
Status = EntityStatus.Active
};
@@ -95,6 +101,10 @@ public sealed class UserManagementService : IUserManagementService
await _users.AddAsync(user, ct);
await _uow.SaveChangesAsync(ct);
// 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);
user.Role = role;
return Map(user);
}
@@ -120,5 +130,5 @@ public sealed class UserManagementService : IUserManagementService
}
private static ManagedUserDto Map(User u) => new(
u.UserId, u.Username, u.DisplayName, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name);
u.UserId, u.Username, u.DisplayName, u.Email, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name);
}