develop full initial module
This commit is contained in:
@@ -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(" · 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<->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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user