This commit is contained in:
Dhananjaya99
2026-07-24 14:33:55 +05:30
188 changed files with 13894 additions and 49 deletions
@@ -0,0 +1,99 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase
{
private readonly IAttendanceUploadService _attendance;
private readonly ICurrentUser _currentUser;
public AttendanceBatchesController(IAttendanceUploadService attendance, ICurrentUser currentUser)
{
_attendance = attendance;
_currentUser = currentUser;
}
[HttpGet("template.xlsx")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult DownloadTemplate([FromQuery] string? format)
{
var (content, contentType, fileName) = _attendance.GenerateTemplate(string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase));
return File(content, contentType, fileName);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AttendanceUploadBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AttendanceUploadBatchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] AttendanceBatchStatus? status,
[FromQuery] int? periodYear, [FromQuery] int? periodMonth, CancellationToken ct)
=> Ok(await _attendance.ListBatchesAsync(query, status, periodYear, periodMonth, ct));
[HttpGet("{batchId:int}")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AttendanceUploadBatchDto>> GetById(int batchId, CancellationToken ct)
{
var result = await _attendance.GetBatchAsync(batchId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Upload(
[FromForm] UploadAttendanceBatchMetadata metadata, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _attendance.UploadAsync(stream, file.FileName, metadata.PeriodStart, metadata.PeriodEnd, _currentUser.AuditUserId, ct);
return Created($"/api/v1/attendance-batches/{result.AttendanceUploadBatchId}", result);
}
[HttpGet("{batchId:int}/records")]
[ProducesResponseType(typeof(List<AttendanceRecordDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceRecordDto>>> ListRecords(
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceRecordDto>> UpdateRecord(
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResolveDuplicate(int batchId, [FromBody] ResolveDuplicateRequest request, CancellationToken ct)
{
await _attendance.ResolveDuplicateAsync(batchId, request, ct);
return NoContent();
}
[HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct));
[HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct)
=> Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct));
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Branch master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/branches")]
public sealed class BranchesController : ApiControllerBase
{
private readonly IBranchService _branches;
public BranchesController(IBranchService branches) => _branches = branches;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BranchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BranchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _branches.ListAsync(query, status, ct));
[HttpGet("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BranchDto>> GetById(int branchId, CancellationToken ct)
{
var result = await _branches.GetAsync(branchId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BranchDto>> Create([FromBody] CreateBranchRequest request, CancellationToken ct)
{
var result = await _branches.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
}
[HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchDto>> Update(int branchId, [FromBody] UpdateBranchRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _branches.UpdateAsync(branchId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,65 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase
{
private readonly IDepartmentService _departments;
public DepartmentsController(IDepartmentService departments) => _departments = departments;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DepartmentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DepartmentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _departments.ListAsync(query, status, ct));
[HttpGet("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DepartmentDto>> GetById(int departmentId, CancellationToken ct)
{
var result = await _departments.GetAsync(departmentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DepartmentDto>> Create([FromBody] CreateDepartmentRequest request, CancellationToken ct)
{
var result = await _departments.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
}
[HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<DepartmentDto>> Update(int departmentId, [FromBody] UpdateDepartmentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _departments.UpdateAsync(departmentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase
{
private readonly IDesignationService _designations;
public DesignationsController(IDesignationService designations) => _designations = designations;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DesignationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DesignationDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _designations.ListAsync(query, status, ct));
[HttpGet("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DesignationDto>> GetById(int designationId, CancellationToken ct)
{
var result = await _designations.GetAsync(designationId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DesignationDto>> Create([FromBody] CreateDesignationRequest request, CancellationToken ct)
{
var result = await _designations.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
}
[HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<DesignationDto>> Update(int designationId, [FromBody] UpdateDesignationRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _designations.UpdateAsync(designationId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,217 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary>
[Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase
{
private readonly IEmployeeService _employees;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeDocumentService _documents;
private readonly ILeaveBalanceService _leaveBalances;
private readonly IEmployeeSalaryStructureService _salaryStructures;
private readonly IEmployeeLoanService _loans;
private readonly ICurrentUser _currentUser;
public EmployeesController(
IEmployeeService employees, IEmployeeUserLinkService links, IEmployeeDocumentService documents,
ILeaveBalanceService leaveBalances, IEmployeeSalaryStructureService salaryStructures,
IEmployeeLoanService loans, ICurrentUser currentUser)
{
_employees = employees;
_links = links;
_documents = documents;
_leaveBalances = leaveBalances;
_salaryStructures = salaryStructures;
_loans = loans;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmployeeListItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmployeeListItemDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EmployeeStatus? status,
[FromQuery] int? departmentId, [FromQuery] int? designationId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeDetailDto>> GetById(int employeeId, CancellationToken ct)
{
var result = await _employees.GetAsync(employeeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeDetailDto>> Create([FromBody] CreateEmployeeRequest request, CancellationToken ct)
{
var result = await _employees.CreateAsync(request, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
}
[HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmployeeDetailDto>> Update(int employeeId, [FromBody] UpdateEmployeeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employees.UpdateAsync(employeeId, request, expected, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employeeId, [FromBody] UpdateEmployeeStatusRequest request, CancellationToken ct)
{
await _employees.SetStatusAsync(employeeId, request.Status, ct);
return NoContent();
}
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> LinkUser(int employeeId, [FromBody] LinkUserRequest request, CancellationToken ct)
{
await _links.LinkAsync(employeeId, request.UserId, ct);
return NoContent();
}
[HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UnlinkUser(int employeeId, CancellationToken ct)
{
await _links.UnlinkAsync(employeeId, ct);
return NoContent();
}
[HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ReplaceBankDetails(
int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct)
=> Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<LeaveBalanceDto>>> UpdateLeaveBalances(
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeSalaryStructureDto>> CreateSalaryStructure(
int employeeId, [FromBody] CreateSalaryStructureRequest request, CancellationToken ct)
{
var result = await _salaryStructures.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
}
[HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct));
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> GetLoan(int employeeId, int loanId, CancellationToken ct)
{
var result = await _loans.GetAsync(employeeId, loanId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> CreateLoan(int employeeId, [FromBody] CreateEmployeeLoanRequest request, CancellationToken ct)
{
var result = await _loans.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
}
[HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct));
[HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<EmployeeDocumentDto>> UploadDocument(
int employeeId, [FromForm] UploadEmployeeDocumentRequest request, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _documents.UploadAsync(
employeeId, request, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
}
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadDocument(int employeeId, int documentId, CancellationToken ct)
{
var (content, fileName, contentType) = await _documents.DownloadAsync(employeeId, documentId, ct);
return File(content, contentType, fileName);
}
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetDocumentStatus(
int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct)
{
await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase
{
private readonly IEmploymentTypeService _employmentTypes;
public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmploymentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmploymentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _employmentTypes.ListAsync(query, status, ct));
[HttpGet("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmploymentTypeDto>> GetById(int employmentTypeId, CancellationToken ct)
{
var result = await _employmentTypes.GetAsync(employmentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmploymentTypeDto>> Create([FromBody] CreateEmploymentTypeRequest request, CancellationToken ct)
{
var result = await _employmentTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
}
[HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmploymentTypeDto>> Update(int employmentTypeId, [FromBody] UpdateEmploymentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employmentTypes.UpdateAsync(employmentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase
{
private readonly IHrDocumentTypeService _types;
public HrDocumentTypesController(IHrDocumentTypeService types) => _types = types;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<HrDocumentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<HrDocumentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _types.ListAsync(query, status, ct));
[HttpGet("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<HrDocumentTypeDto>> GetById(int hrDocumentTypeId, CancellationToken ct)
{
var result = await _types.GetAsync(hrDocumentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<HrDocumentTypeDto>> Create([FromBody] CreateHrDocumentTypeRequest request, CancellationToken ct)
{
var result = await _types.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
}
[HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<HrDocumentTypeDto>> Update(int hrDocumentTypeId, [FromBody] UpdateHrDocumentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _types.UpdateAsync(hrDocumentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,52 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase
{
private readonly IHrReportService _reports;
public HrReportsController(IHrReportService reports) => _reports = reports;
[HttpGet("attendance-summary")]
[ProducesResponseType(typeof(List<AttendanceSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceSummaryRowDto>>> AttendanceSummary(
[FromQuery] int periodYear, [FromQuery] int periodMonth, [FromQuery] int? departmentId, CancellationToken ct)
=> Ok(await _reports.AttendanceSummaryAsync(periodYear, periodMonth, departmentId, ct));
[HttpGet("overtime")]
[ProducesResponseType(typeof(List<OvertimeReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<OvertimeReportRowDto>>> Overtime(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct));
[HttpGet("late-arrivals")]
[ProducesResponseType(typeof(List<LateArrivalReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LateArrivalReportRowDto>>> LateArrivals(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
=> Ok(await _reports.SalaryHistoryAsync(employeeId, ct));
[HttpGet("leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceReportRowDto>>> LeaveBalances([FromQuery] int year, CancellationToken ct)
=> Ok(await _reports.LeaveBalanceReportAsync(year, ct));
[HttpGet("document-expiry")]
[ProducesResponseType(typeof(List<DocumentExpiryReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<DocumentExpiryReportRowDto>>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct)
=> Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct));
}
@@ -0,0 +1,71 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase
{
private readonly ILeaveRequestService _requests;
private readonly ICurrentUser _currentUser;
public LeaveRequestsController(ILeaveRequestService requests, ICurrentUser currentUser)
{
_requests = requests;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveRequestDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveRequestDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? employeeId, [FromQuery] LeaveRequestStatus? status, CancellationToken ct)
=> Ok(await _requests.ListAsync(query, employeeId, status, ct));
[HttpGet("{leaveRequestId:int}")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveRequestDto>> GetById(int leaveRequestId, CancellationToken ct)
{
var result = await _requests.GetAsync(leaveRequestId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<LeaveRequestDto>> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct)
{
var result = await _requests.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
}
[HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Cancel(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.CancelAsync(leaveRequestId, ct));
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase
{
private readonly ILeaveTypeService _leaveTypes;
public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _leaveTypes.ListAsync(query, status, ct));
[HttpGet("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveTypeDto>> GetById(int leaveTypeId, CancellationToken ct)
{
var result = await _leaveTypes.GetAsync(leaveTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveTypeDto>> Create([FromBody] CreateLeaveTypeRequest request, CancellationToken ct)
{
var result = await _leaveTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
}
[HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<LeaveTypeDto>> Update(int leaveTypeId, [FromBody] UpdateLeaveTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _leaveTypes.UpdateAsync(leaveTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,85 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase
{
private readonly IPayrollRunService _payrollRuns;
private readonly ICurrentUser _currentUser;
public PayrollRunsController(IPayrollRunService payrollRuns, ICurrentUser currentUser)
{
_payrollRuns = payrollRuns;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PayrollRunDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PayrollRunDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? periodYear, [FromQuery] int? periodMonth,
[FromQuery] PayrollRunStatus? status, CancellationToken ct)
=> Ok(await _payrollRuns.ListAsync(query, periodYear, periodMonth, status, ct));
[HttpGet("{payrollRunId:int}")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollRunDto>> GetById(int payrollRunId, CancellationToken ct)
{
var result = await _payrollRuns.GetAsync(payrollRunId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payrollRunId:int}/lines")]
[ProducesResponseType(typeof(List<PayrollLineDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollLineDto>>> ListLines(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ListLinesAsync(payrollRunId, ct));
[HttpGet("{payrollRunId:int}/lines/{lineId:int}")]
[ProducesResponseType(typeof(PayrollLineDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollLineDetailDto>> GetLine(int payrollRunId, int lineId, CancellationToken ct)
{
var result = await _payrollRuns.GetLineAsync(payrollRunId, lineId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PayrollRunDto>> Generate([FromBody] GeneratePayrollRunRequest request, CancellationToken ct)
{
var result = await _payrollRuns.GenerateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
}
[HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<List<PayslipDto>>> GeneratePayslips(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct));
}
@@ -0,0 +1,33 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase
{
private readonly IPayrollStatutorySettingService _settings;
private readonly ICurrentUser _currentUser;
public PayrollStatutorySettingsController(IPayrollStatutorySettingService settings, ICurrentUser currentUser)
{
_settings = settings;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<PayrollStatutorySettingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
{
var result = await _settings.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-statutory-settings/{result.PayrollStatutorySettingId}", result);
}
}
@@ -0,0 +1,32 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase
{
private readonly IPayslipService _payslips;
public PayslipsController(IPayslipService payslips) => _payslips = payslips;
[HttpGet("{payslipId:int}")]
[ProducesResponseType(typeof(PayslipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayslipDto>> GetById(int payslipId, CancellationToken ct)
{
var result = await _payslips.GetAsync(payslipId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payslipId:int}/view")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> View(int payslipId, CancellationToken ct)
{
var html = await _payslips.RenderHtmlAsync(payslipId, ct);
return html is null ? NotFound() : Content(html, "text/html");
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase
{
private readonly ISalaryComponentService _components;
public SalaryComponentsController(ISalaryComponentService components) => _components = components;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalaryComponentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalaryComponentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _components.ListAsync(query, status, ct));
[HttpGet("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalaryComponentDto>> GetById(int salaryComponentId, CancellationToken ct)
{
var result = await _components.GetAsync(salaryComponentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalaryComponentDto>> Create([FromBody] CreateSalaryComponentRequest request, CancellationToken ct)
{
var result = await _components.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
}
[HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalaryComponentDto>> Update(int salaryComponentId, [FromBody] UpdateSalaryComponentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _components.UpdateAsync(salaryComponentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,28 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase
{
private readonly ITaxSlabService _taxSlabs;
public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs;
[HttpGet]
[ProducesResponseType(typeof(List<TaxSlabDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TaxSlabDto>> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct)
{
var result = await _taxSlabs.CreateAsync(request, ct);
return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result);
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
private readonly IWorkShiftService _shifts;
public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WorkShiftDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WorkShiftDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _shifts.ListAsync(query, status, ct));
[HttpGet("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WorkShiftDto>> GetById(int workShiftId, CancellationToken ct)
{
var result = await _shifts.GetAsync(workShiftId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WorkShiftDto>> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct)
{
var result = await _shifts.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<WorkShiftDto>> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
}
+13 -1
View File
@@ -1,4 +1,5 @@
using ERPCore.Dtos.Common; using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users; using ERPCore.Dtos.Users;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -13,8 +14,19 @@ namespace ERPCore.Controllers;
public sealed class UsersController : ApiControllerBase public sealed class UsersController : ApiControllerBase
{ {
private readonly IUserManagementService _users; private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
public UsersController(IUserManagementService users) => _users = users; public UsersController(IUserManagementService users, IEmployeeUserLinkService links)
{
_users = users;
_links = links;
}
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<EmployeeMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct)));
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/day attendance row (FR-HR-ATT). <see cref="WorkShiftId"/> is
/// snapshotted from the employee's shift at ingestion time (docs/12-BACKEND-HRM.md
/// A.3) so a later shift reassignment never retroactively changes historical
/// Late/OT figures. Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceRecord
{
public int AttendanceRecordId { get; set; }
public int? AttendanceUploadBatchId { get; set; }
public AttendanceUploadBatch? AttendanceUploadBatch { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime AttendanceDate { get; set; }
public TimeSpan? CheckIn { get; set; }
public TimeSpan? CheckOut { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int WorkingMinutes { get; set; }
public int LateMinutes { get; set; }
public int EarlyLeaveMinutes { get; set; }
public int OvertimeMinutes { get; set; }
public AttendanceStatus AttendanceStatus { get; set; }
public RowValidationStatus RowValidationStatus { get; set; } = RowValidationStatus.Valid;
public int? DuplicateOfAttendanceRecordId { get; set; }
public string? Notes { get; set; }
public bool IsManualOverride { get; set; }
public int? EditedBy { get; set; }
public DateTime? EditedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Attendance upload batch (FR-HR-ATT) — the transactional document driving the
/// exact status flow Draft→Validated→Confirmed→UsedInPayroll. Scoped to exactly
/// one payroll period, numbered via <see cref="NumberSequence"/> (docType "ATT").
/// Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceUploadBatch
{
public int AttendanceUploadBatchId { get; set; }
public string DocNo { get; set; } = string.Empty;
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
public AttendanceSourceType SourceType { get; set; }
public string? OriginalFileName { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public AttendanceBatchStatus Status { get; set; } = AttendanceBatchStatus.Draft;
public int? ConfirmedBy { get; set; }
public DateTime? ConfirmedAt { get; set; }
public int RowCountTotal { get; set; }
public int RowCountDuplicate { get; set; }
public int RowCountError { get; set; }
public uint RowVersion { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>.
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Branch
{
public int BranchId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Department master (FR-HR-MD-01) — unlimited self-nesting for a real org chart
/// (unlike the two-level-capped <see cref="Category"/>); cycle prevention is a
/// service-level check on write, not a DB constraint. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Department
{
public int DepartmentId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int? ParentDepartmentId { get; set; }
public Department? ParentDepartment { get; set; }
public int? HeadEmployeeId { get; set; }
public Employee? HeadEmployee { get; set; }
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Job title master (FR-HR-MD-01), standalone — not FK'd to Department, since a
/// title like "Accountant" can exist in multiple departments. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Designation
{
public int DesignationId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,76 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
/// account): not every employee has a login, and not every login belongs to an
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
/// year-scoping is the wrong shape for an identifier that must never look "reset".
/// </summary>
public class Employee
{
public int EmployeeId { get; set; }
public string EmployeeCode { get; set; } = string.Empty;
// Identity
public string FullName { get; set; } = string.Empty;
public string? Nic { get; set; }
public DateTime? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
public string? Nationality { get; set; }
public string? ProfilePhotoPath { get; set; }
// Contact
/// <summary>The field used for the bidirectional Employee&lt;-&gt;User email cross-check.</summary>
public string? Email { get; set; }
public string? PersonalMobile { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? PostalCode { get; set; }
public string? Country { get; set; }
// Emergency contact
public string? EmergencyContactName { get; set; }
public string? EmergencyContactRelationship { get; set; }
public string? EmergencyContactPhone { get; set; }
// Employment
public DateTime HireDate { get; set; }
public DateTime? ConfirmationDate { get; set; }
public DateTime? LastWorkingDate { get; set; }
public int DepartmentId { get; set; }
public Department? Department { get; set; }
public int DesignationId { get; set; }
public Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; }
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int? ReportingManagerId { get; set; }
public Employee? ReportingManager { get; set; }
// Statutory (Sri Lanka)
public string? EpfNumber { get; set; }
public string? EtfNumber { get; set; }
public string? TaxIdentificationNumber { get; set; }
/// <summary>Optional login account link (unique — one User backs at most one Employee).</summary>
public int? UserId { get; set; }
public User? User { get; set; }
public EmployeeStatus Status { get; set; } = EmployeeStatus.Active;
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public int? UpdatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Employee bank account (FR-HR-MD-03), one-to-many — a future split-payment
/// improvement is possible since this isn't a 1:1 scalar set. Exactly one row per
/// employee is <see cref="IsPrimary"/>; payroll disbursement targets it.
/// Model: docs/12-BACKEND-HRM.md Part C.2.
/// </summary>
public class EmployeeBankDetail
{
public int EmployeeBankDetailId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public string BankName { get; set; } = string.Empty;
public string BranchName { get; set; } = string.Empty;
public string AccountNumber { get; set; } = string.Empty;
public string AccountHolderName { get; set; } = string.Empty;
public string? SwiftCode { get; set; }
public bool IsPrimary { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Uploaded staff document (FR-HR-DOC-02..04) — the user's "Doc". <see cref="StoredFileName"/>/
/// <see cref="RelativePath"/> are server-generated (never the client's filename), so the
/// file is only ever reachable through <see cref="Services.Interfaces.IFileStorageService"/>,
/// never a guessable static path. Archived, not deleted, so the audit trail of what was
/// once on file is retained. Model: docs/12-BACKEND-HRM.md Part C.3.
/// </summary>
public class EmployeeDocument
{
public int EmployeeDocumentId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int HrDocumentTypeId { get; set; }
public HrDocumentType? HrDocumentType { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public DateTime? IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public string? Notes { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public int? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
public EmployeeDocumentStatus Status { get; set; } = EmployeeDocumentStatus.Active;
public uint RowVersion { get; set; }
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Loan/Advance (FR-HR-PAY-03) — <see cref="LoanKind"/> discriminates, structurally
/// identical otherwise. <see cref="OutstandingBalance"/> is denormalized (parallel to
/// <c>StockLayer.QtyRemaining</c>). Numbered via <see cref="NumberSequence"/> (docType "LOAN").
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class EmployeeLoan
{
public int EmployeeLoanId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public LoanKind LoanKind { get; set; }
public decimal PrincipalAmount { get; set; }
public decimal InterestRate { get; set; }
public decimal InstallmentAmount { get; set; }
public int NumberOfInstallments { get; set; }
public int StartYear { get; set; }
public int StartMonth { get; set; }
public decimal OutstandingBalance { get; set; }
public LoanStatus Status { get; set; } = LoanStatus.Active;
public int ApprovedBy { get; set; }
public DateTime ApprovedAt { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public List<LoanInstallment> Installments { get; set; } = new();
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Effective-dated salary structure header (FR-HR-PAY-02) — the audit trail a
/// salary revision needs (docs/12-BACKEND-HRM.md §13): exactly one row with
/// <see cref="EffectiveTo"/> null (the current one) per employee at a time.
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class EmployeeSalaryStructure
{
public int EmployeeSalaryStructureId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public decimal BasicSalary { get; set; }
public string Currency { get; set; } = "LKR";
public SalaryStructureStatus Status { get; set; } = SalaryStructureStatus.Active;
public int ApprovedBy { get; set; }
public DateTime ApprovedAt { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public List<EmployeeSalaryStructureLine> Lines { get; set; } = new();
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Entities;
/// <summary>Allowance/other-deduction line on a salary structure. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class EmployeeSalaryStructureLine
{
public int EmployeeSalaryStructureLineId { get; set; }
public int EmployeeSalaryStructureId { get; set; }
public EmployeeSalaryStructure? EmployeeSalaryStructure { get; set; }
public int SalaryComponentId { get; set; }
public SalaryComponent? SalaryComponent { get; set; }
public decimal Amount { get; set; }
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Labor category master (FR-HR-MD-01) — a master, not an enum, mirroring
/// <see cref="Brand"/>: employment categories change with company/labor-law
/// policy without wanting a code deploy. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class EmploymentType
{
public int EmploymentTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,24 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Staff document catalog (FR-HR-DOC-01) — the user's "DocType": a category of
/// document (NIC, contract, certificate...), not the uploaded file itself (see
/// <see cref="EmployeeDocument"/>, the "Doc"). Deactivated, not deleted, when
/// referenced. Model: docs/12-BACKEND-HRM.md Part C.3.
/// </summary>
public class HrDocumentType
{
public int HrDocumentTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public HrDocumentCategory Category { get; set; }
public bool RequiredAtOnboarding { get; set; }
public bool ExpiryTracked { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,24 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/type/year leave entitlement (FR-HR-LV-03). Unique on
/// (EmployeeId, LeaveTypeId, Year). RemainingDays is a computed projection, not
/// stored. Model: docs/12-BACKEND-HRM.md Part C.5.
/// </summary>
public class LeaveBalance
{
public int LeaveBalanceId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int LeaveTypeId { get; set; }
public LeaveType? LeaveType { get; set; }
public int Year { get; set; }
public decimal EntitledDays { get; set; }
public decimal TakenDays { get; set; }
public decimal CarriedForwardDays { get; set; }
public decimal AdjustmentDays { get; set; }
public uint RowVersion { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Leave request (FR-HR-LV-02) — transactional document, numbered via
/// <see cref="NumberSequence"/> (docType "LV"). Model: docs/12-BACKEND-HRM.md Part C.5.
/// </summary>
public class LeaveRequest
{
public int LeaveRequestId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int LeaveTypeId { get; set; }
public LeaveType? LeaveType { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public decimal DaysCount { get; set; }
public string? Reason { get; set; }
public LeaveRequestStatus Status { get; set; } = LeaveRequestStatus.Draft;
public int? ApprovedBy { get; set; }
public DateTime? ApprovedAt { get; set; }
public string? RejectionReason { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>Leave type master (FR-HR-LV-01). Model: docs/12-BACKEND-HRM.md Part C.5.</summary>
public class LeaveType
{
public int LeaveTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsPaid { get; set; } = true;
/// <summary>Feeds Payroll's No-Pay deduction when true (docs/12-BACKEND-HRM.md §6).</summary>
public bool CountsAsNoPay { get; set; }
public decimal AccrualPerYear { get; set; }
public bool CarryForwardAllowed { get; set; }
public int? MaxCarryForwardDays { get; set; }
public bool RequiresApproval { get; set; } = true;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Loan installment ledger row. <see cref="PayrollRunId"/> is stamped only when the
/// consuming <see cref="PayrollRun"/> reaches Locked (docs/12-BACKEND-HRM.md A.4).
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class LoanInstallment
{
public int LoanInstallmentId { get; set; }
public int EmployeeLoanId { get; set; }
public EmployeeLoan? EmployeeLoan { get; set; }
public int InstallmentNumber { get; set; }
public int DueYear { get; set; }
public int DueMonth { get; set; }
public decimal ScheduledAmount { get; set; }
public decimal? PaidAmount { get; set; }
public int? PayrollRunId { get; set; }
public PayrollRun? PayrollRun { get; set; }
public LoanInstallmentStatus Status { get; set; } = LoanInstallmentStatus.Pending;
public uint RowVersion { get; set; }
}
@@ -0,0 +1,41 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-employee payroll summary row (FR-HR-PAY-05). EpfEmployerAmount/EtfEmployerAmount
/// are informational/liability only, never subtracted from NetSalary
/// (docs/12-BACKEND-HRM.md B.4). Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollLine
{
public int PayrollLineId { get; set; }
public int PayrollRunId { get; set; }
public PayrollRun? PayrollRun { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public decimal BasicSalary { get; set; }
public decimal TotalAllowances { get; set; }
public decimal OvertimeAmount { get; set; }
public decimal GrossSalary { get; set; }
public decimal LateDeductionAmount { get; set; }
public decimal NoPayAmount { get; set; }
public decimal LoanDeductionAmount { get; set; }
public decimal EpfEmployeeAmount { get; set; }
public decimal EpfEmployerAmount { get; set; }
public decimal EtfEmployerAmount { get; set; }
public decimal TaxAmount { get; set; }
public decimal OtherDeductionsAmount { get; set; }
public decimal NetSalary { get; set; }
public int WorkingDays { get; set; }
public int PresentDays { get; set; }
public int AbsentDays { get; set; }
public int LeaveDays { get; set; }
public int OtMinutesTotal { get; set; }
public int LateMinutesTotal { get; set; }
public uint RowVersion { get; set; }
public List<PayrollLineComponent> Components { get; set; } = new();
}
@@ -0,0 +1,18 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>The detailed Basic/Transport/Meal/OT/Late/No-Pay/Loan/EPF/ETF/Tax breakdown. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class PayrollLineComponent
{
public int PayrollLineComponentId { get; set; }
public int PayrollLineId { get; set; }
public PayrollLine? PayrollLine { get; set; }
public PayrollLineComponentCategory ComponentCategory { get; set; }
/// <summary>Set only for structure-sourced Allowance/OtherDeduction lines; null for system-computed lines.</summary>
public int? SalaryComponentId { get; set; }
public SalaryComponent? SalaryComponent { get; set; }
public string Label { get; set; } = string.Empty;
public decimal Amount { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Payroll run (FR-HR-PAY-05/06) — the transactional document. Numbered via
/// <see cref="NumberSequence"/> (docType "PAY"). Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollRun
{
public int PayrollRunId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int PeriodYear { get; set; }
public int PeriodMonth { get; set; }
/// <summary>Null = company-wide run.</summary>
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public PayrollRunStatus Status { get; set; } = PayrollRunStatus.Draft;
public int GeneratedBy { get; set; }
public DateTime GeneratedAt { get; set; }
public int? ApprovedBy { get; set; }
public DateTime? ApprovedAt { get; set; }
public int? LockedBy { get; set; }
public DateTime? LockedAt { get; set; }
public int? UnlockedBy { get; set; }
public DateTime? UnlockedAt { get; set; }
public string? UnlockReason { get; set; }
public uint RowVersion { get; set; }
public List<PayrollLine> Lines { get; set; } = new();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Effective-dated EPF/ETF rates (FR-HR-PAY-04) — Sri Lanka defaults (EPF 8%
/// employee / 12% employer, ETF 3% employer-only), configurable since government
/// rates can change. Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollStatutorySetting
{
public int PayrollStatutorySettingId { get; set; }
public decimal EpfEmployeeRate { get; set; } = 0.08m;
public decimal EpfEmployerRate { get; set; } = 0.12m;
public decimal EtfEmployerRate { get; set; } = 0.03m;
public decimal OtMultiplierDefault { get; set; } = 1.5m;
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Thin generation/release marker over a <see cref="PayrollLine"/> — ships as an
/// HTML print view in this phase, per the confirmed decision (no PDF dependency).
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class Payslip
{
public int PayslipId { get; set; }
public int PayrollLineId { get; set; }
public PayrollLine? PayrollLine { get; set; }
public DateTime GeneratedAt { get; set; }
public DateTime? ReleasedAt { get; set; }
public int? ReleasedBy { get; set; }
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>Allowance/ad hoc deduction master (FR-HR-PAY-01). Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class SalaryComponent
{
public int SalaryComponentId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public SalaryComponentType ComponentType { get; set; }
public bool IsTaxable { get; set; }
public bool IsEpfEtfApplicable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,19 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Configurable APIT-style marginal tax slab (FR-HR-PAY-04) — government slabs
/// change with the yearly budget, so this is never hardcoded. <see cref="UpperBound"/>
/// null means "and above". Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class TaxSlab
{
public int TaxSlabId { get; set; }
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
public decimal Rate { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
+8
View File
@@ -23,6 +23,14 @@ public class User
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary> /// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
public Guid? AuthUserId { get; set; } public Guid? AuthUserId { get; set; }
/// <summary>
/// Mirrors the AuthHex identity's email (backfilled at create time by
/// <c>UsersController.Create</c>, best-effort by JIT provisioning otherwise).
/// Used only for the Employee&lt;-&gt;User cross-link soft match (docs/12-BACKEND-HRM.md
/// A.5) — never for authentication, which stays AuthHex's responsibility.
/// </summary>
public string? Email { get; set; }
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary> /// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; } public int? RoleId { get; set; }
public Role? Role { get; set; } public Role? Role { get; set; }
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Attendance baseline (FR-HR-MD-01) — the shift definition Late/Early/OT figures
/// are computed against (docs/12-BACKEND-HRM.md A.3, C.1). <see cref="IsOvernight"/>
/// is explicit rather than inferred from End&lt;Start, since that comparison alone
/// is ambiguous for a shift that starts and ends the same clock time next day.
/// </summary>
public class WorkShift
{
public int WorkShiftId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public TimeSpan StartTime { get; set; }
public TimeSpan EndTime { get; set; }
public bool IsOvernight { get; set; }
public int GraceMinutes { get; set; } = 15;
public int BreakMinutes { get; set; } = 60;
public int StandardWorkingMinutes { get; set; } = 480;
public decimal OtMultiplier { get; set; } = 1.5m;
/// <summary>Bitmask, bit 0 = Monday .. bit 6 = Sunday.</summary>
public int WorkingDaysMask { get; set; } = 0b0111111;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Attendance upload batch lifecycle (FR-HR-ATT, docs/12-BACKEND-HRM.md C.4) —
/// exactly the flow specified by the business: once <see cref="Confirmed"/> it
/// becomes payroll's source of truth; once <see cref="UsedInPayroll"/> it is
/// immutable even to Unlock (a payroll run must be unlocked/regenerated first).
/// </summary>
public enum AttendanceBatchStatus
{
Draft,
Validated,
Confirmed,
UsedInPayroll
}
@@ -0,0 +1,13 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Origin of an attendance batch (docs/12-BACKEND-HRM.md C.4). <see cref="BiometricDevice"/>
/// is a reserved future integration seam (docs §B.7) — no device feed exists yet.
/// </summary>
public enum AttendanceSourceType
{
Excel,
Csv,
Manual,
BiometricDevice
}
@@ -0,0 +1,17 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Per-day attendance classification (docs/12-BACKEND-HRM.md C.4). Lateness/OT are
/// derived facts (LateMinutes/OvertimeMinutes &gt; 0) on an otherwise Present record,
/// not separate statuses. <see cref="OnLeave"/> is derived from an overlapping
/// Approved LeaveRequest with no uploaded punch (§6).
/// </summary>
public enum AttendanceStatus
{
Present,
Absent,
HalfDay,
OnLeave,
Holiday,
WeekOff
}
@@ -0,0 +1,11 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Uploaded staff document status (docs/12-BACKEND-HRM.md C.3). Never hard-deleted —
/// archived instead, mirroring the deactivate-not-delete master convention (FR-MD-08).
/// </summary>
public enum EmployeeDocumentStatus
{
Active,
Archived
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Employee lifecycle status (docs/12-BACKEND-HRM.md C.2). An Employee is never
/// hard-deleted; separation is recorded here instead (with <c>LastWorkingDate</c>
/// set), matching the deactivate-not-delete convention for masters (FR-MD-08)
/// taken one step further since the record must be retained for audit/payroll history.
/// </summary>
public enum EmployeeStatus
{
Active,
Suspended,
Resigned,
Terminated,
Retired
}
+9
View File
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Employee gender (docs/12-BACKEND-HRM.md C.2). Optional field, stored as a string.</summary>
public enum Gender
{
Male,
Female,
Other
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>Staff document catalog category (docs/12-BACKEND-HRM.md C.3). Stored as a string.</summary>
public enum HrDocumentCategory
{
Identity,
Educational,
Contract,
Certification,
Statutory,
Other
}
@@ -0,0 +1,11 @@
namespace ERPCore.Domain.Enums;
/// <summary>Leave request approval lifecycle (FR-HR-LV-02, docs/12-BACKEND-HRM.md C.5).</summary>
public enum LeaveRequestStatus
{
Draft,
Submitted,
Approved,
Rejected,
Cancelled
}
@@ -0,0 +1,13 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// An installment flips Pending→Deducted only when its PayrollRun reaches Locked
/// (docs/12-BACKEND-HRM.md A.4) — never at Generate/Draft, so a discarded/regenerated
/// draft never prematurely consumes it.
/// </summary>
public enum LoanInstallmentStatus
{
Pending,
Deducted,
Skipped
}
+8
View File
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Loan vs Advance discriminator (docs/12-BACKEND-HRM.md C.6) — structurally identical, differ only in intent/labeling.</summary>
public enum LoanKind
{
Loan,
Advance
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum LoanStatus
{
Active,
Closed,
Cancelled
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// EmployerContribution lines (EPF-employer, ETF) are informational/liability only —
/// never subtracted from Net Salary (docs/12-BACKEND-HRM.md B.4).
/// </summary>
public enum PayrollLineComponentCategory
{
Earning,
Deduction,
EmployerContribution
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// PayrollRun approval workflow (FR-HR-PAY-06, docs/12-BACKEND-HRM.md C.6). Maps the
/// business's 5-step flow to 3 stored states: Generate→Draft, Review is a human
/// action (not stored), Approve→Approved, Lock→Locked (the point loan installments
/// and attendance batches are stamped consumed — see A.4), Generate Payslips is an
/// action gated on Locked, not a state.
/// </summary>
public enum PayrollRunStatus
{
Draft,
Approved,
Locked
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>Per-record outcome of the attendance upload validation pipeline (docs/12-BACKEND-HRM.md §B.3.4).</summary>
public enum RowValidationStatus
{
Valid,
DuplicateWithinBatch,
DuplicateConfirmed,
EmployeeNotFound,
InvalidDateTime,
Error
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// SalaryComponent master category (docs/12-BACKEND-HRM.md C.6) — for Allowances and
/// ad hoc Other Deductions only. OT/Late/No-Pay/Loan/EPF/ETF/Tax are system-computed,
/// not user-defined components, to avoid a generic formula engine nobody asked for.
/// </summary>
public enum SalaryComponentType
{
Earning,
Deduction
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Effective-dated salary structure status (docs/12-BACKEND-HRM.md C.6) — exactly one Active (open-ended) row per employee at a time.</summary>
public enum SalaryStructureStatus
{
Active,
Superseded
}
@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
public sealed record AttendanceUploadBatchDto(
int AttendanceUploadBatchId, string DocNo, DateTime PeriodStart, DateTime PeriodEnd,
AttendanceSourceType SourceType, string? OriginalFileName, int UploadedBy, DateTime UploadedAt,
AttendanceBatchStatus Status, int? ConfirmedBy, DateTime? ConfirmedAt,
int RowCountTotal, int RowCountDuplicate, int RowCountError);
public sealed class UploadAttendanceBatchMetadata
{
[Required] public DateTime PeriodStart { get; set; }
[Required] public DateTime PeriodEnd { get; set; }
}
public sealed record AttendanceRecordDto(
int AttendanceRecordId, int? AttendanceUploadBatchId, int EmployeeId, string? EmployeeCode, string? EmployeeName,
DateTime AttendanceDate, TimeSpan? CheckIn, TimeSpan? CheckOut,
int WorkingMinutes, int LateMinutes, int EarlyLeaveMinutes, int OvertimeMinutes,
AttendanceStatus AttendanceStatus, RowValidationStatus RowValidationStatus,
int? DuplicateOfAttendanceRecordId, string? Notes);
public sealed class UpdateAttendanceRecordRequest
{
public TimeSpan? CheckIn { get; set; }
public TimeSpan? CheckOut { get; set; }
public AttendanceStatus? AttendanceStatus { get; set; }
[StringLength(1000)] public string? Notes { get; set; }
}
public sealed class ResolveDuplicateRequest
{
[Required] public int RecordId { get; set; }
/// <summary>"keep" discards the other duplicate row(s); "discard" removes this row; "supersede" (cross-batch-confirmed only) replaces the prior confirmed record.</summary>
[Required, RegularExpression("^(keep|discard|supersede)$")]
public string Action { get; set; } = string.Empty;
}
public sealed class UnlockAttendanceBatchRequest
{
[Required, StringLength(500)] public string Reason { get; set; } = string.Empty;
}
+46
View File
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
public sealed record HrDocumentTypeDto(
int HrDocumentTypeId, string Code, string Name, HrDocumentCategory Category,
bool RequiredAtOnboarding, bool ExpiryTracked, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateHrDocumentTypeRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required, EnumDataType(typeof(HrDocumentCategory))] public HrDocumentCategory Category { get; set; }
public bool RequiredAtOnboarding { get; set; }
public bool ExpiryTracked { get; set; }
}
public sealed class UpdateHrDocumentTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required, EnumDataType(typeof(HrDocumentCategory))] public HrDocumentCategory Category { get; set; }
public bool RequiredAtOnboarding { get; set; }
public bool ExpiryTracked { get; set; }
}
public sealed record EmployeeDocumentDto(
int EmployeeDocumentId, int EmployeeId, int HrDocumentTypeId, string? HrDocumentTypeName,
string OriginalFileName, string ContentType, long SizeBytes,
DateTime? IssueDate, DateTime? ExpiryDate, string? Notes,
int UploadedBy, DateTime UploadedAt, EmployeeDocumentStatus Status);
/// <summary>Metadata accompanying a multipart file upload (the file itself is bound separately).</summary>
public sealed class UploadEmployeeDocumentRequest
{
[Required] public int HrDocumentTypeId { get; set; }
public DateTime? IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
[StringLength(1000)] public string? Notes { get; set; }
}
public sealed class UpdateEmployeeDocumentStatusRequest
{
[Required, EnumDataType(typeof(EmployeeDocumentStatus))] public EmployeeDocumentStatus Status { get; set; }
}
+117
View File
@@ -0,0 +1,117 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName,
int EmploymentTypeId, string? EmploymentTypeName, int? BranchId, string? BranchName,
EmployeeStatus Status, bool HasUserLink, DateTime HireDate);
public sealed record EmployeeDetailDto(
int EmployeeId, string EmployeeCode, string FullName, string? Nic, DateTime? DateOfBirth,
Gender? Gender, string? Nationality, string? ProfilePhotoPath,
string? Email, string? PersonalMobile, string? AddressLine1, string? AddressLine2,
string? City, string? PostalCode, string? Country,
string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone,
DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate,
int DepartmentId, int DesignationId, int EmploymentTypeId, int? BranchId, int WorkShiftId,
int? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber,
int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateEmployeeRequest
{
[Required, StringLength(30)] public string EmployeeCode { get; set; } = string.Empty;
[Required, StringLength(200)] public string FullName { get; set; } = string.Empty;
[StringLength(30)] public string? Nic { get; set; }
public DateTime? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
[StringLength(100)] public string? Nationality { get; set; }
[EmailAddress, StringLength(320)] public string? Email { get; set; }
[StringLength(30)] public string? PersonalMobile { get; set; }
[StringLength(200)] public string? AddressLine1 { get; set; }
[StringLength(200)] public string? AddressLine2 { get; set; }
[StringLength(100)] public string? City { get; set; }
[StringLength(20)] public string? PostalCode { get; set; }
[StringLength(100)] public string? Country { get; set; }
[StringLength(200)] public string? EmergencyContactName { get; set; }
[StringLength(100)] public string? EmergencyContactRelationship { get; set; }
[StringLength(30)] public string? EmergencyContactPhone { get; set; }
[Required] public DateTime HireDate { get; set; }
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
public int? BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
[StringLength(30)] public string? EpfNumber { get; set; }
[StringLength(30)] public string? EtfNumber { get; set; }
[StringLength(30)] public string? TaxIdentificationNumber { get; set; }
/// <summary>Explicit, human-confirmed link to an existing User found via email-lookup — never automatic.</summary>
public int? LinkUserId { get; set; }
}
public sealed class UpdateEmployeeRequest
{
[Required, StringLength(200)] public string FullName { get; set; } = string.Empty;
[StringLength(30)] public string? Nic { get; set; }
public DateTime? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
[StringLength(100)] public string? Nationality { get; set; }
[EmailAddress, StringLength(320)] public string? Email { get; set; }
[StringLength(30)] public string? PersonalMobile { get; set; }
[StringLength(200)] public string? AddressLine1 { get; set; }
[StringLength(200)] public string? AddressLine2 { get; set; }
[StringLength(100)] public string? City { get; set; }
[StringLength(20)] public string? PostalCode { get; set; }
[StringLength(100)] public string? Country { get; set; }
[StringLength(200)] public string? EmergencyContactName { get; set; }
[StringLength(100)] public string? EmergencyContactRelationship { get; set; }
[StringLength(30)] public string? EmergencyContactPhone { get; set; }
public DateTime? ConfirmationDate { get; set; }
public DateTime? LastWorkingDate { get; set; }
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
public int? BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
[StringLength(30)] public string? EpfNumber { get; set; }
[StringLength(30)] public string? EtfNumber { get; set; }
[StringLength(30)] public string? TaxIdentificationNumber { get; set; }
}
public sealed class UpdateEmployeeStatusRequest
{
[Required, EnumDataType(typeof(EmployeeStatus))] public EmployeeStatus Status { get; set; }
}
public sealed record EmployeeBankDetailDto(
int EmployeeBankDetailId, string BankName, string BranchName, string AccountNumber,
string AccountHolderName, string? SwiftCode, bool IsPrimary, EntityStatus Status);
public sealed class UpsertEmployeeBankDetailRequest
{
public int? EmployeeBankDetailId { get; set; }
[Required, StringLength(200)] public string BankName { get; set; } = string.Empty;
[Required, StringLength(200)] public string BranchName { get; set; } = string.Empty;
[Required, StringLength(50)] public string AccountNumber { get; set; } = string.Empty;
[Required, StringLength(200)] public string AccountHolderName { get; set; } = string.Empty;
[StringLength(20)] public string? SwiftCode { get; set; }
public bool IsPrimary { get; set; }
}
public sealed class ReplaceEmployeeBankDetailsRequest
{
[Required] public List<UpsertEmployeeBankDetailRequest> Items { get; set; } = new();
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Hrm;
/// <summary>Advisory match surfaced by the reverse-direction email-lookup (docs/12-BACKEND-HRM.md A.5).</summary>
public sealed record EmployeeMatchDto(int EmployeeId, string EmployeeCode, string FullName, string Email);
public sealed record UserMatchDto(int UserId, string Username, string DisplayName, string Email);
public sealed record EmployeeMatchResponse(EmployeeMatchDto? Match);
public sealed record UserMatchResponse(UserMatchDto? Match);
public sealed class LinkUserRequest
{
[Required] public int UserId { get; set; }
}
+67
View File
@@ -0,0 +1,67 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
public sealed record LeaveTypeDto(
int LeaveTypeId, string Code, string Name, bool IsPaid, bool CountsAsNoPay, decimal AccrualPerYear,
bool CarryForwardAllowed, int? MaxCarryForwardDays, bool RequiresApproval, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateLeaveTypeRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public bool IsPaid { get; set; } = true;
public bool CountsAsNoPay { get; set; }
[Range(0, 365)] public decimal AccrualPerYear { get; set; }
public bool CarryForwardAllowed { get; set; }
public int? MaxCarryForwardDays { get; set; }
public bool RequiresApproval { get; set; } = true;
}
public sealed class UpdateLeaveTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public bool IsPaid { get; set; }
public bool CountsAsNoPay { get; set; }
[Range(0, 365)] public decimal AccrualPerYear { get; set; }
public bool CarryForwardAllowed { get; set; }
public int? MaxCarryForwardDays { get; set; }
public bool RequiresApproval { get; set; }
}
public sealed record LeaveRequestDto(
int LeaveRequestId, string DocNo, int EmployeeId, string? EmployeeName, int LeaveTypeId, string? LeaveTypeName,
DateTime StartDate, DateTime EndDate, decimal DaysCount, string? Reason,
LeaveRequestStatus Status, int? ApprovedBy, DateTime? ApprovedAt, string? RejectionReason, DateTime CreatedAt);
public sealed class CreateLeaveRequestRequest
{
[Required] public int EmployeeId { get; set; }
[Required] public int LeaveTypeId { get; set; }
[Required] public DateTime StartDate { get; set; }
[Required] public DateTime EndDate { get; set; }
[StringLength(1000)] public string? Reason { get; set; }
}
public sealed class RejectLeaveRequestRequest
{
[Required, StringLength(1000)] public string Reason { get; set; } = string.Empty;
}
public sealed record LeaveBalanceDto(
int LeaveBalanceId, int EmployeeId, int LeaveTypeId, string? LeaveTypeName, int Year,
decimal EntitledDays, decimal TakenDays, decimal CarriedForwardDays, decimal AdjustmentDays, decimal RemainingDays);
public sealed class LeaveBalanceAdjustmentItem
{
[Required] public int LeaveTypeId { get; set; }
public decimal AdjustmentDays { get; set; }
}
public sealed class UpdateLeaveBalancesRequest
{
[Required] public int Year { get; set; }
[Required] public List<LeaveBalanceAdjustmentItem> Items { get; set; } = new();
}
+111
View File
@@ -0,0 +1,111 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
// Narrow DTOs for the five HRM org masters — server-controlled fields (status,
// ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1).
public sealed record BranchDto(
int BranchId, string Code, string Name, string? Address, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateBranchRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
}
public sealed class UpdateBranchRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
}
public sealed record DepartmentDto(
int DepartmentId, string Code, string Name, int? ParentDepartmentId, int? HeadEmployeeId,
int? BranchId, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateDepartmentRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public int? ParentDepartmentId { get; set; }
public int? HeadEmployeeId { get; set; }
public int? BranchId { get; set; }
}
public sealed class UpdateDepartmentRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public int? ParentDepartmentId { get; set; }
public int? HeadEmployeeId { get; set; }
public int? BranchId { get; set; }
}
public sealed record DesignationDto(
int DesignationId, string Code, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateDesignationRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateDesignationRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed record EmploymentTypeDto(
int EmploymentTypeId, string Code, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateEmploymentTypeRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateEmploymentTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed record WorkShiftDto(
int WorkShiftId, string Code, string Name, TimeSpan StartTime, TimeSpan EndTime, bool IsOvernight,
int GraceMinutes, int BreakMinutes, int StandardWorkingMinutes, decimal OtMultiplier, int WorkingDaysMask,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateWorkShiftRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required] public TimeSpan StartTime { get; set; }
[Required] public TimeSpan EndTime { get; set; }
public bool IsOvernight { get; set; }
[Range(0, 240)] public int GraceMinutes { get; set; } = 15;
[Range(0, 240)] public int BreakMinutes { get; set; } = 60;
[Range(1, 1440)] public int StandardWorkingMinutes { get; set; } = 480;
[Range(1, 5)] public decimal OtMultiplier { get; set; } = 1.5m;
[Range(0, 127)] public int WorkingDaysMask { get; set; } = 0b0111111;
}
public sealed class UpdateWorkShiftRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required] public TimeSpan StartTime { get; set; }
[Required] public TimeSpan EndTime { get; set; }
public bool IsOvernight { get; set; }
[Range(0, 240)] public int GraceMinutes { get; set; }
[Range(0, 240)] public int BreakMinutes { get; set; }
[Range(1, 1440)] public int StandardWorkingMinutes { get; set; }
[Range(1, 5)] public decimal OtMultiplier { get; set; }
[Range(0, 127)] public int WorkingDaysMask { get; set; }
}
/// <summary>Shared by all five masters' PATCH .../status endpoints.</summary>
public sealed class UpdateHrMasterStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
+130
View File
@@ -0,0 +1,130 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
// --- Salary components ---
public sealed record SalaryComponentDto(
int SalaryComponentId, string Code, string Name, SalaryComponentType ComponentType,
bool IsTaxable, bool IsEpfEtfApplicable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateSalaryComponentRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required, EnumDataType(typeof(SalaryComponentType))] public SalaryComponentType ComponentType { get; set; }
public bool IsTaxable { get; set; }
public bool IsEpfEtfApplicable { get; set; }
}
public sealed class UpdateSalaryComponentRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public bool IsTaxable { get; set; }
public bool IsEpfEtfApplicable { get; set; }
}
// --- Salary structure ---
public sealed record EmployeeSalaryStructureLineDto(int SalaryComponentId, string? SalaryComponentName, decimal Amount);
public sealed record EmployeeSalaryStructureDto(
int EmployeeSalaryStructureId, int EmployeeId, DateTime EffectiveFrom, DateTime? EffectiveTo,
decimal BasicSalary, string Currency, SalaryStructureStatus Status,
List<EmployeeSalaryStructureLineDto> Lines, DateTime CreatedAt);
public sealed class SalaryStructureLineRequest
{
[Required] public int SalaryComponentId { get; set; }
[Range(0, double.MaxValue)] public decimal Amount { get; set; }
}
public sealed class CreateSalaryStructureRequest
{
[Required] public DateTime EffectiveFrom { get; set; }
[Range(0, double.MaxValue)] public decimal BasicSalary { get; set; }
public List<SalaryStructureLineRequest> Lines { get; set; } = new();
}
// --- Loans ---
public sealed record LoanInstallmentDto(
int LoanInstallmentId, int InstallmentNumber, int DueYear, int DueMonth,
decimal ScheduledAmount, decimal? PaidAmount, int? PayrollRunId, LoanInstallmentStatus Status);
public sealed record EmployeeLoanDto(
int EmployeeLoanId, string DocNo, int EmployeeId, LoanKind LoanKind, decimal PrincipalAmount,
decimal InterestRate, decimal InstallmentAmount, int NumberOfInstallments, int StartYear, int StartMonth,
decimal OutstandingBalance, LoanStatus Status, List<LoanInstallmentDto> Installments, DateTime CreatedAt);
public sealed class CreateEmployeeLoanRequest
{
[Required, EnumDataType(typeof(LoanKind))] public LoanKind LoanKind { get; set; }
[Range(0.01, double.MaxValue)] public decimal PrincipalAmount { get; set; }
[Range(0, 1)] public decimal InterestRate { get; set; }
[Range(0.01, double.MaxValue)] public decimal InstallmentAmount { get; set; }
[Range(1, 360)] public int NumberOfInstallments { get; set; }
[Range(2000, 2100)] public int StartYear { get; set; }
[Range(1, 12)] public int StartMonth { get; set; }
}
// --- Statutory settings ---
public sealed record PayrollStatutorySettingDto(
int PayrollStatutorySettingId, decimal EpfEmployeeRate, decimal EpfEmployerRate, decimal EtfEmployerRate,
decimal OtMultiplierDefault, DateTime EffectiveFrom, DateTime? EffectiveTo);
public sealed class UpsertPayrollStatutorySettingRequest
{
[Range(0, 1)] public decimal EpfEmployeeRate { get; set; } = 0.08m;
[Range(0, 1)] public decimal EpfEmployerRate { get; set; } = 0.12m;
[Range(0, 1)] public decimal EtfEmployerRate { get; set; } = 0.03m;
[Range(1, 5)] public decimal OtMultiplierDefault { get; set; } = 1.5m;
[Required] public DateTime EffectiveFrom { get; set; }
}
public sealed record TaxSlabDto(int TaxSlabId, DateTime EffectiveFrom, DateTime? EffectiveTo, decimal LowerBound, decimal? UpperBound, decimal Rate);
public sealed class CreateTaxSlabRequest
{
[Required] public DateTime EffectiveFrom { get; set; }
[Range(0, double.MaxValue)] public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
[Range(0, 1)] public decimal Rate { get; set; }
}
// --- Payroll run ---
public sealed record PayrollLineComponentDto(
PayrollLineComponentCategory ComponentCategory, int? SalaryComponentId, string Label, decimal Amount, int SortOrder);
public sealed record PayrollLineDto(
int PayrollLineId, int PayrollRunId, int EmployeeId, string? EmployeeCode, string? EmployeeName,
decimal BasicSalary, decimal TotalAllowances, decimal OvertimeAmount, decimal GrossSalary,
decimal LateDeductionAmount, decimal NoPayAmount, decimal LoanDeductionAmount,
decimal EpfEmployeeAmount, decimal EpfEmployerAmount, decimal EtfEmployerAmount,
decimal TaxAmount, decimal OtherDeductionsAmount, decimal NetSalary,
int WorkingDays, int PresentDays, int AbsentDays, int LeaveDays, int OtMinutesTotal, int LateMinutesTotal);
public sealed record PayrollLineDetailDto(PayrollLineDto Line, List<PayrollLineComponentDto> Components);
public sealed record PayrollRunDto(
int PayrollRunId, string DocNo, int PeriodYear, int PeriodMonth, int? BranchId, PayrollRunStatus Status,
int GeneratedBy, DateTime GeneratedAt, int? ApprovedBy, DateTime? ApprovedAt,
int? LockedBy, DateTime? LockedAt, int? UnlockedBy, DateTime? UnlockedAt, string? UnlockReason,
decimal TotalGross, decimal TotalNet, int EmployeeCount);
public sealed class GeneratePayrollRunRequest
{
[Range(2000, 2100)] public int PeriodYear { get; set; }
[Range(1, 12)] public int PeriodMonth { get; set; }
public int? BranchId { get; set; }
}
public sealed class UnlockPayrollRunRequest
{
[Required, StringLength(500)] public string Reason { get; set; } = string.Empty;
}
public sealed record PayslipDto(int PayslipId, int PayrollLineId, DateTime GeneratedAt, DateTime? ReleasedAt, int? ReleasedBy);
+26
View File
@@ -0,0 +1,26 @@
namespace ERPCore.Dtos.Hrm;
public sealed record AttendanceSummaryRowDto(
int EmployeeId, string EmployeeCode, string EmployeeName, string? DepartmentName,
int PresentDays, int AbsentDays, int LeaveDays, int HalfDays, int OtMinutesTotal, int LateMinutesTotal);
public sealed record OvertimeReportRowDto(
int EmployeeId, string EmployeeCode, string EmployeeName, DateTime AttendanceDate, int OvertimeMinutes);
public sealed record LateArrivalReportRowDto(
int EmployeeId, string EmployeeCode, string EmployeeName, DateTime AttendanceDate, int LateMinutes);
public sealed record PayrollRegisterRowDto(
int PayrollLineId, int EmployeeId, string EmployeeCode, string EmployeeName,
decimal GrossSalary, decimal TotalDeductions, decimal NetSalary);
public sealed record SalaryHistoryRowDto(
int EmployeeSalaryStructureId, DateTime EffectiveFrom, DateTime? EffectiveTo, decimal BasicSalary, string Status);
public sealed record LeaveBalanceReportRowDto(
int EmployeeId, string EmployeeCode, string EmployeeName, string LeaveTypeName,
decimal EntitledDays, decimal TakenDays, decimal RemainingDays);
public sealed record DocumentExpiryReportRowDto(
int EmployeeDocumentId, int EmployeeId, string EmployeeCode, string EmployeeName,
string DocumentTypeName, DateTime ExpiryDate, int DaysUntilExpiry);
+7 -1
View File
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Users; namespace ERPCore.Dtos.Users;
public sealed record ManagedUserDto( public sealed record ManagedUserDto(
int UserId, string Username, string DisplayName, EntityStatus Status, int UserId, string Username, string DisplayName, string? Email, EntityStatus Status,
int? RoleId, string? RoleCode, string? RoleName); int? RoleId, string? RoleCode, string? RoleName);
/// <summary> /// <summary>
@@ -24,6 +24,12 @@ public sealed class CreateUserRequest
public string? MobileNumber { get; set; } public string? MobileNumber { get; set; }
/// <summary>Left empty to auto-generate (AuthHex emails it to <see cref="Email"/>).</summary> /// <summary>Left empty to auto-generate (AuthHex emails it to <see cref="Email"/>).</summary>
public string? Password { get; set; } public string? Password { get; set; }
/// <summary>
/// Explicit, human-confirmed link to an existing unlinked Employee found via
/// email-lookup — never automatic, even on an exact email match (docs/12-BACKEND-HRM.md A.5).
/// </summary>
public int? LinkEmployeeId { get; set; }
} }
public sealed class UpdateUserRoleRequest public sealed class UpdateUserRoleRequest
+2
View File
@@ -7,6 +7,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,30 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class AttendanceRecordConfiguration : IEntityTypeConfiguration<AttendanceRecord>
{
public void Configure(EntityTypeBuilder<AttendanceRecord> builder)
{
builder.ToTable("hr_attendance_records");
builder.HasKey(r => r.AttendanceRecordId);
builder.Property(r => r.Notes).HasMaxLength(1000);
builder.Property(r => r.AttendanceStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.RowValidationStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(r => r.AttendanceUploadBatch).WithMany()
.HasForeignKey(r => r.AttendanceUploadBatchId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(r => r.Employee).WithMany()
.HasForeignKey(r => r.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.WorkShift).WithMany()
.HasForeignKey(r => r.WorkShiftId).OnDelete(DeleteBehavior.Restrict);
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => new { r.EmployeeId, r.AttendanceDate });
builder.HasIndex(r => r.AttendanceUploadBatchId);
}
}
@@ -0,0 +1,27 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class AttendanceUploadBatchConfiguration : IEntityTypeConfiguration<AttendanceUploadBatch>
{
public void Configure(EntityTypeBuilder<AttendanceUploadBatch> builder)
{
builder.ToTable("hr_attendance_upload_batches");
builder.HasKey(b => b.AttendanceUploadBatchId);
builder.Property(b => b.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(b => b.DocNo).IsUnique();
builder.Property(b => b.OriginalFileName).HasMaxLength(260);
builder.Property(b => b.SourceType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(b => b.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(b => b.UploadedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
builder.HasIndex(b => new { b.PeriodStart, b.PeriodEnd });
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BranchConfiguration : IEntityTypeConfiguration<Branch>
{
public void Configure(EntityTypeBuilder<Branch> builder)
{
builder.ToTable("hr_branches");
builder.HasKey(b => b.BranchId);
builder.Property(b => b.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(b => b.Code).IsUnique();
builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.Property(b => b.Address).HasMaxLength(500);
builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class DepartmentConfiguration : IEntityTypeConfiguration<Department>
{
public void Configure(EntityTypeBuilder<Department> builder)
{
builder.ToTable("hr_departments");
builder.HasKey(d => d.DepartmentId);
builder.Property(d => d.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(d => d.Code).IsUnique();
builder.Property(d => d.Name).IsRequired().HasMaxLength(200);
builder.Property(d => d.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
// Self-nesting (unlimited depth, unlike Category) — cycle prevention is
// service-level, not a DB constraint (docs/12-BACKEND-HRM.md A.1/C.1).
builder.HasOne(d => d.ParentDepartment).WithMany()
.HasForeignKey(d => d.ParentDepartmentId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(d => d.HeadEmployee).WithMany()
.HasForeignKey(d => d.HeadEmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(d => d.Branch).WithMany()
.HasForeignKey(d => d.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
builder.HasIndex(d => d.ParentDepartmentId);
}
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class DesignationConfiguration : IEntityTypeConfiguration<Designation>
{
public void Configure(EntityTypeBuilder<Designation> builder)
{
builder.ToTable("hr_designations");
builder.HasKey(d => d.DesignationId);
builder.Property(d => d.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(d => d.Code).IsUnique();
builder.Property(d => d.Name).IsRequired().HasMaxLength(200);
builder.Property(d => d.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(d => d.CreatedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.Status);
}
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeBankDetailConfiguration : IEntityTypeConfiguration<EmployeeBankDetail>
{
public void Configure(EntityTypeBuilder<EmployeeBankDetail> builder)
{
builder.ToTable("hr_employee_bank_details");
builder.HasKey(b => b.EmployeeBankDetailId);
builder.Property(b => b.BankName).IsRequired().HasMaxLength(200);
builder.Property(b => b.BranchName).IsRequired().HasMaxLength(200);
builder.Property(b => b.AccountNumber).IsRequired().HasMaxLength(50);
builder.Property(b => b.AccountHolderName).IsRequired().HasMaxLength(200);
builder.Property(b => b.SwiftCode).HasMaxLength(20);
builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasOne(b => b.Employee).WithMany()
.HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Cascade);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.EmployeeId);
}
}
@@ -0,0 +1,71 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeConfiguration : IEntityTypeConfiguration<Employee>
{
public void Configure(EntityTypeBuilder<Employee> builder)
{
builder.ToTable("hr_employees");
builder.HasKey(e => e.EmployeeId);
builder.Property(e => e.EmployeeCode).IsRequired().HasMaxLength(30);
builder.HasIndex(e => e.EmployeeCode).IsUnique();
builder.Property(e => e.FullName).IsRequired().HasMaxLength(200);
builder.Property(e => e.Nic).HasMaxLength(30);
builder.Property(e => e.Nationality).HasMaxLength(100);
builder.Property(e => e.ProfilePhotoPath).HasMaxLength(500);
builder.Property(e => e.Gender).HasConversion<string>().HasMaxLength(20);
builder.Property(e => e.Email).HasMaxLength(320);
builder.HasIndex(e => e.Email);
builder.Property(e => e.PersonalMobile).HasMaxLength(30);
builder.Property(e => e.AddressLine1).HasMaxLength(200);
builder.Property(e => e.AddressLine2).HasMaxLength(200);
builder.Property(e => e.City).HasMaxLength(100);
builder.Property(e => e.PostalCode).HasMaxLength(20);
builder.Property(e => e.Country).HasMaxLength(100);
builder.Property(e => e.EmergencyContactName).HasMaxLength(200);
builder.Property(e => e.EmergencyContactRelationship).HasMaxLength(100);
builder.Property(e => e.EmergencyContactPhone).HasMaxLength(30);
builder.Property(e => e.EpfNumber).HasMaxLength(30);
builder.Property(e => e.EtfNumber).HasMaxLength(30);
builder.Property(e => e.TaxIdentificationNumber).HasMaxLength(30);
builder.HasOne(e => e.Department).WithMany()
.HasForeignKey(e => e.DepartmentId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.Designation).WithMany()
.HasForeignKey(e => e.DesignationId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.EmploymentType).WithMany()
.HasForeignKey(e => e.EmploymentTypeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.Branch).WithMany()
.HasForeignKey(e => e.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.WorkShift).WithMany()
.HasForeignKey(e => e.WorkShiftId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(e => e.ReportingManager).WithMany()
.HasForeignKey(e => e.ReportingManagerId).OnDelete(DeleteBehavior.Restrict);
// One User backs at most one Employee (docs/12-BACKEND-HRM.md A.5/C.2).
// Postgres unique indexes allow multiple NULLs natively, so no explicit
// filter is needed (same pattern as User.AuthUserId).
builder.HasOne(e => e.User).WithOne()
.HasForeignKey<Employee>(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => e.UserId).IsUnique();
builder.Property(e => e.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EmployeeStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
builder.HasIndex(e => e.DepartmentId);
}
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeDocumentConfiguration : IEntityTypeConfiguration<EmployeeDocument>
{
public void Configure(EntityTypeBuilder<EmployeeDocument> builder)
{
builder.ToTable("hr_employee_documents");
builder.HasKey(d => d.EmployeeDocumentId);
builder.Property(d => d.OriginalFileName).IsRequired().HasMaxLength(260);
builder.Property(d => d.StoredFileName).IsRequired().HasMaxLength(260);
builder.Property(d => d.RelativePath).IsRequired().HasMaxLength(500);
builder.Property(d => d.ContentType).IsRequired().HasMaxLength(200);
builder.Property(d => d.Notes).HasMaxLength(1000);
builder.Property(d => d.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EmployeeDocumentStatus.Active);
builder.HasOne(d => d.Employee).WithMany()
.HasForeignKey(d => d.EmployeeId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(d => d.HrDocumentType).WithMany()
.HasForeignKey(d => d.HrDocumentTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(d => d.UploadedAt).IsRequired();
builder.Property(d => d.RowVersion).IsRowVersion();
builder.HasIndex(d => d.EmployeeId);
builder.HasIndex(d => d.ExpiryDate);
}
}
@@ -0,0 +1,56 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeLoanConfiguration : IEntityTypeConfiguration<EmployeeLoan>
{
public void Configure(EntityTypeBuilder<EmployeeLoan> builder)
{
builder.ToTable("hr_employee_loans");
builder.HasKey(l => l.EmployeeLoanId);
builder.Property(l => l.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(l => l.DocNo).IsUnique();
builder.Property(l => l.LoanKind).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(l => l.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(l => l.PrincipalAmount).HasPrecision(18, 2);
builder.Property(l => l.InterestRate).HasPrecision(6, 4);
builder.Property(l => l.InstallmentAmount).HasPrecision(18, 2);
builder.Property(l => l.OutstandingBalance).HasPrecision(18, 2);
builder.HasOne(l => l.Employee).WithMany()
.HasForeignKey(l => l.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(l => l.Installments).WithOne(i => i.EmployeeLoan!)
.HasForeignKey(i => i.EmployeeLoanId).OnDelete(DeleteBehavior.Cascade);
builder.Property(l => l.CreatedAt).IsRequired();
builder.Property(l => l.RowVersion).IsRowVersion();
builder.HasIndex(l => l.EmployeeId);
builder.HasIndex(l => l.Status);
}
}
public sealed class LoanInstallmentConfiguration : IEntityTypeConfiguration<LoanInstallment>
{
public void Configure(EntityTypeBuilder<LoanInstallment> builder)
{
builder.ToTable("hr_loan_installments");
builder.HasKey(i => i.LoanInstallmentId);
builder.Property(i => i.ScheduledAmount).HasPrecision(18, 2);
builder.Property(i => i.PaidAmount).HasPrecision(18, 2);
builder.Property(i => i.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(i => i.PayrollRun).WithMany()
.HasForeignKey(i => i.PayrollRunId).OnDelete(DeleteBehavior.Restrict);
builder.Property(i => i.RowVersion).IsRowVersion();
builder.HasIndex(i => new { i.DueYear, i.DueMonth });
}
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmployeeSalaryStructureConfiguration : IEntityTypeConfiguration<EmployeeSalaryStructure>
{
public void Configure(EntityTypeBuilder<EmployeeSalaryStructure> builder)
{
builder.ToTable("hr_employee_salary_structures");
builder.HasKey(s => s.EmployeeSalaryStructureId);
builder.Property(s => s.BasicSalary).HasPrecision(18, 2);
builder.Property(s => s.Currency).IsRequired().HasMaxLength(3);
builder.Property(s => s.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(s => s.Employee).WithMany()
.HasForeignKey(s => s.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(s => s.Lines).WithOne(l => l.EmployeeSalaryStructure!)
.HasForeignKey(l => l.EmployeeSalaryStructureId).OnDelete(DeleteBehavior.Cascade);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => new { s.EmployeeId, s.EffectiveTo });
}
}
public sealed class EmployeeSalaryStructureLineConfiguration : IEntityTypeConfiguration<EmployeeSalaryStructureLine>
{
public void Configure(EntityTypeBuilder<EmployeeSalaryStructureLine> builder)
{
builder.ToTable("hr_employee_salary_structure_lines");
builder.HasKey(l => l.EmployeeSalaryStructureLineId);
builder.Property(l => l.Amount).HasPrecision(18, 2);
builder.HasOne(l => l.SalaryComponent).WithMany()
.HasForeignKey(l => l.SalaryComponentId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class EmploymentTypeConfiguration : IEntityTypeConfiguration<EmploymentType>
{
public void Configure(EntityTypeBuilder<EmploymentType> builder)
{
builder.ToTable("hr_employment_types");
builder.HasKey(e => e.EmploymentTypeId);
builder.Property(e => e.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(e => e.Code).IsUnique();
builder.Property(e => e.Name).IsRequired().HasMaxLength(200);
builder.Property(e => e.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.RowVersion).IsRowVersion();
builder.HasIndex(e => e.Status);
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class HrDocumentTypeConfiguration : IEntityTypeConfiguration<HrDocumentType>
{
public void Configure(EntityTypeBuilder<HrDocumentType> builder)
{
builder.ToTable("hr_document_types");
builder.HasKey(t => t.HrDocumentTypeId);
builder.Property(t => t.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(t => t.Code).IsUnique();
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
builder.Property(t => t.Category).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LeaveBalanceConfiguration : IEntityTypeConfiguration<LeaveBalance>
{
public void Configure(EntityTypeBuilder<LeaveBalance> builder)
{
builder.ToTable("hr_leave_balances");
builder.HasKey(b => b.LeaveBalanceId);
builder.Property(b => b.EntitledDays).HasPrecision(6, 2);
builder.Property(b => b.TakenDays).HasPrecision(6, 2);
builder.Property(b => b.CarriedForwardDays).HasPrecision(6, 2);
builder.Property(b => b.AdjustmentDays).HasPrecision(6, 2);
builder.HasOne(b => b.Employee).WithMany()
.HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(b => b.LeaveType).WithMany()
.HasForeignKey(b => b.LeaveTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => new { b.EmployeeId, b.LeaveTypeId, b.Year }).IsUnique();
}
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LeaveRequestConfiguration : IEntityTypeConfiguration<LeaveRequest>
{
public void Configure(EntityTypeBuilder<LeaveRequest> builder)
{
builder.ToTable("hr_leave_requests");
builder.HasKey(r => r.LeaveRequestId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.DaysCount).HasPrecision(6, 2);
builder.Property(r => r.Reason).HasMaxLength(1000);
builder.Property(r => r.RejectionReason).HasMaxLength(1000);
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(r => r.Employee).WithMany()
.HasForeignKey(r => r.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.LeaveType).WithMany()
.HasForeignKey(r => r.LeaveTypeId).OnDelete(DeleteBehavior.Restrict);
builder.Property(r => r.CreatedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => r.EmployeeId);
builder.HasIndex(r => r.Status);
builder.HasIndex(r => new { r.StartDate, r.EndDate });
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class LeaveTypeConfiguration : IEntityTypeConfiguration<LeaveType>
{
public void Configure(EntityTypeBuilder<LeaveType> builder)
{
builder.ToTable("hr_leave_types");
builder.HasKey(t => t.LeaveTypeId);
builder.Property(t => t.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(t => t.Code).IsUnique();
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
builder.Property(t => t.AccrualPerYear).HasPrecision(6, 2);
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
}
@@ -0,0 +1,52 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PayrollLineConfiguration : IEntityTypeConfiguration<PayrollLine>
{
public void Configure(EntityTypeBuilder<PayrollLine> builder)
{
builder.ToTable("hr_payroll_lines");
builder.HasKey(l => l.PayrollLineId);
foreach (var money in new[]
{
nameof(PayrollLine.BasicSalary), nameof(PayrollLine.TotalAllowances), nameof(PayrollLine.OvertimeAmount),
nameof(PayrollLine.GrossSalary), nameof(PayrollLine.LateDeductionAmount), nameof(PayrollLine.NoPayAmount),
nameof(PayrollLine.LoanDeductionAmount), nameof(PayrollLine.EpfEmployeeAmount), nameof(PayrollLine.EpfEmployerAmount),
nameof(PayrollLine.EtfEmployerAmount), nameof(PayrollLine.TaxAmount), nameof(PayrollLine.OtherDeductionsAmount),
nameof(PayrollLine.NetSalary)
})
{
builder.Property(money).HasColumnType("numeric(18,2)");
}
builder.HasOne(l => l.Employee).WithMany()
.HasForeignKey(l => l.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(l => l.Components).WithOne(c => c.PayrollLine!)
.HasForeignKey(c => c.PayrollLineId).OnDelete(DeleteBehavior.Cascade);
builder.Property(l => l.RowVersion).IsRowVersion();
builder.HasIndex(l => new { l.PayrollRunId, l.EmployeeId }).IsUnique();
}
}
public sealed class PayrollLineComponentConfiguration : IEntityTypeConfiguration<PayrollLineComponent>
{
public void Configure(EntityTypeBuilder<PayrollLineComponent> builder)
{
builder.ToTable("hr_payroll_line_components");
builder.HasKey(c => c.PayrollLineComponentId);
builder.Property(c => c.ComponentCategory).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.Label).IsRequired().HasMaxLength(200);
builder.Property(c => c.Amount).HasPrecision(18, 2);
builder.HasOne(c => c.SalaryComponent).WithMany()
.HasForeignKey(c => c.SalaryComponentId).OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PayrollRunConfiguration : IEntityTypeConfiguration<PayrollRun>
{
public void Configure(EntityTypeBuilder<PayrollRun> builder)
{
builder.ToTable("hr_payroll_runs");
builder.HasKey(r => r.PayrollRunId);
builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(r => r.DocNo).IsUnique();
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.UnlockReason).HasMaxLength(500);
builder.HasOne(r => r.Branch).WithMany()
.HasForeignKey(r => r.BranchId).OnDelete(DeleteBehavior.Restrict);
builder.HasMany(r => r.Lines).WithOne(l => l.PayrollRun!)
.HasForeignKey(l => l.PayrollRunId).OnDelete(DeleteBehavior.Cascade);
builder.Property(r => r.GeneratedAt).IsRequired();
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => new { r.PeriodYear, r.PeriodMonth, r.BranchId });
builder.HasIndex(r => r.Status);
}
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PayrollStatutorySettingConfiguration : IEntityTypeConfiguration<PayrollStatutorySetting>
{
public void Configure(EntityTypeBuilder<PayrollStatutorySetting> builder)
{
builder.ToTable("hr_payroll_statutory_settings");
builder.HasKey(s => s.PayrollStatutorySettingId);
builder.Property(s => s.EpfEmployeeRate).HasPrecision(6, 4);
builder.Property(s => s.EpfEmployerRate).HasPrecision(6, 4);
builder.Property(s => s.EtfEmployerRate).HasPrecision(6, 4);
builder.Property(s => s.OtMultiplierDefault).HasPrecision(6, 2);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => s.EffectiveFrom);
}
}
public sealed class TaxSlabConfiguration : IEntityTypeConfiguration<TaxSlab>
{
public void Configure(EntityTypeBuilder<TaxSlab> builder)
{
builder.ToTable("hr_tax_slabs");
builder.HasKey(s => s.TaxSlabId);
builder.Property(s => s.LowerBound).HasPrecision(18, 2);
builder.Property(s => s.UpperBound).HasPrecision(18, 2);
builder.Property(s => s.Rate).HasPrecision(6, 4);
builder.Property(s => s.CreatedAt).IsRequired();
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasIndex(s => s.EffectiveFrom);
}
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class PayslipConfiguration : IEntityTypeConfiguration<Payslip>
{
public void Configure(EntityTypeBuilder<Payslip> builder)
{
builder.ToTable("hr_payslips");
builder.HasKey(p => p.PayslipId);
builder.HasOne(p => p.PayrollLine).WithOne()
.HasForeignKey<Payslip>(p => p.PayrollLineId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(p => p.PayrollLineId).IsUnique();
builder.Property(p => p.GeneratedAt).IsRequired();
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SalaryComponentConfiguration : IEntityTypeConfiguration<SalaryComponent>
{
public void Configure(EntityTypeBuilder<SalaryComponent> builder)
{
builder.ToTable("hr_salary_components");
builder.HasKey(c => c.SalaryComponentId);
builder.Property(c => c.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(c => c.Code).IsUnique();
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
builder.Property(c => c.ComponentType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(c => c.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(c => c.CreatedAt).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
}
@@ -23,6 +23,12 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id"); builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
builder.HasIndex(u => u.AuthUserId).IsUnique(); builder.HasIndex(u => u.AuthUserId).IsUnique();
// Employee<->User cross-link match field (docs/12-BACKEND-HRM.md A.5). Unique,
// like AuthUserId — Postgres allows multiple NULLs in a unique index natively,
// so employees/users without an email don't collide.
builder.Property(u => u.Email).HasMaxLength(320);
builder.HasIndex(u => u.Email).IsUnique();
// Local shadow Role assignment (nullable — unset until an admin assigns one). // Local shadow Role assignment (nullable — unset until an admin assigns one).
builder.HasOne(u => u.Role).WithMany() builder.HasOne(u => u.Role).WithMany()
.HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict);
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class WorkShiftConfiguration : IEntityTypeConfiguration<WorkShift>
{
public void Configure(EntityTypeBuilder<WorkShift> builder)
{
builder.ToTable("hr_work_shifts");
builder.HasKey(w => w.WorkShiftId);
builder.Property(w => w.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(w => w.Code).IsUnique();
builder.Property(w => w.Name).IsRequired().HasMaxLength(200);
builder.Property(w => w.OtMultiplier).HasPrecision(6, 2);
builder.Property(w => w.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(w => w.CreatedAt).IsRequired();
builder.Property(w => w.RowVersion).IsRowVersion();
builder.HasIndex(w => w.Status);
}
}
@@ -89,6 +89,43 @@ public class ErpDbContext : DbContext
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>(); public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
public DbSet<Branch> Branches => Set<Branch>();
public DbSet<Department> Departments => Set<Department>();
public DbSet<Designation> Designations => Set<Designation>();
public DbSet<EmploymentType> EmploymentTypes => Set<EmploymentType>();
public DbSet<WorkShift> WorkShifts => Set<WorkShift>();
// --- HRM: employee core (docs/12-BACKEND-HRM.md Part C.2) ---
public DbSet<Employee> Employees => Set<Employee>();
public DbSet<EmployeeBankDetail> EmployeeBankDetails => Set<EmployeeBankDetail>();
// --- HRM: staff documents (docs/12-BACKEND-HRM.md Part C.3) ---
public DbSet<HrDocumentType> HrDocumentTypes => Set<HrDocumentType>();
public DbSet<EmployeeDocument> EmployeeDocuments => Set<EmployeeDocument>();
// --- HRM: attendance (docs/12-BACKEND-HRM.md Part C.4) ---
public DbSet<AttendanceUploadBatch> AttendanceUploadBatches => Set<AttendanceUploadBatch>();
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
// --- HRM: leave (docs/12-BACKEND-HRM.md Part C.5) ---
public DbSet<LeaveType> LeaveTypes => Set<LeaveType>();
public DbSet<LeaveRequest> LeaveRequests => Set<LeaveRequest>();
public DbSet<LeaveBalance> LeaveBalances => Set<LeaveBalance>();
// --- HRM: payroll (docs/12-BACKEND-HRM.md Part C.6) ---
public DbSet<SalaryComponent> SalaryComponents => Set<SalaryComponent>();
public DbSet<EmployeeSalaryStructure> EmployeeSalaryStructures => Set<EmployeeSalaryStructure>();
public DbSet<EmployeeSalaryStructureLine> EmployeeSalaryStructureLines => Set<EmployeeSalaryStructureLine>();
public DbSet<EmployeeLoan> EmployeeLoans => Set<EmployeeLoan>();
public DbSet<LoanInstallment> LoanInstallments => Set<LoanInstallment>();
public DbSet<PayrollStatutorySetting> PayrollStatutorySettings => Set<PayrollStatutorySetting>();
public DbSet<TaxSlab> TaxSlabs => Set<TaxSlab>();
public DbSet<PayrollRun> PayrollRuns => Set<PayrollRun>();
public DbSet<PayrollLine> PayrollLines => Set<PayrollLine>();
public DbSet<PayrollLineComponent> PayrollLineComponents => Set<PayrollLineComponent>();
public DbSet<Payslip> Payslips => Set<Payslip>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
@@ -96,6 +133,29 @@ public class ErpDbContext : DbContext
// Pick up every IEntityTypeConfiguration in this assembly // Pick up every IEntityTypeConfiguration in this assembly
// (Infra/Persistence/Configurations/*). // (Infra/Persistence/Configurations/*).
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly); modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly);
// Npgsql requires DateTime values written to `timestamp with time zone` columns
// to have Kind=Utc; dates deserialized from a JSON request body (hire date,
// salary-structure effective date, etc.) come in as Kind=Unspecified and would
// otherwise throw at SaveChanges time. Force Utc kind globally for every
// DateTime/DateTime? property rather than remembering to convert at each HRM
// service call site (docs/12-BACKEND-HRM.md — new in Phase 2; Phase 1 never hit
// this because it only ever persisted server-generated DateTime.UtcNow values).
var utcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<DateTime, DateTime>(
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
var nullableUtcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<DateTime?, DateTime?>(
v => v.HasValue ? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)) : v,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v);
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(DateTime)) property.SetValueConverter(utcConverter);
else if (property.ClrType == typeof(DateTime?)) property.SetValueConverter(nullableUtcConverter);
}
}
} }
// Audit trail (FR-X-02): capture mutations before save (accurate old→new), then // Audit trail (FR-X-02): capture mutations before save (accurate old→new), then
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
namespace ERPCore.Infra.Storage;
/// <summary>
/// File storage abstraction (docs/12-BACKEND-HRM.md A.1, C.10) — the first
/// attachment mechanism in this codebase. <see cref="LocalFileStorageService"/> is
/// the only implementation today; swapping to cloud blob storage later means
/// adding a new implementation + one DI registration change, no controller/service
/// change. Never exposes a path the client can dictate — callers pass only a
/// suggested filename, and get back a server-generated one.
/// </summary>
public interface IFileStorageService
{
/// <summary>Saves the stream under a server-generated name; returns the stored name, its relative path, and size.</summary>
Task<(string StoredFileName, string RelativePath, long SizeBytes)> SaveAsync(
Stream content, string suggestedFileName, string contentType, CancellationToken ct = default);
Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default);
Task DeleteAsync(string relativePath, CancellationToken ct = default);
Task<bool> ExistsAsync(string relativePath, CancellationToken ct = default);
}
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Hosting;
namespace ERPCore.Infra.Storage;
/// <summary>
/// Disk-backed <see cref="IFileStorageService"/>. Writes under a configured root
/// OUTSIDE wwwroot (<c>FileStorage:RootPath</c>, default <c>App_Data/hr-documents</c>
/// relative to the content root) so files are never reachable via a static-file URL —
/// the only way to read one back is through an authenticated controller action that
/// streams via <see cref="OpenReadAsync"/> (docs/12-BACKEND-HRM.md A.1, §4).
/// </summary>
public sealed class LocalFileStorageService : IFileStorageService
{
private readonly string _root;
public LocalFileStorageService(IHostEnvironment env, IConfiguration configuration)
{
var configuredRoot = configuration["FileStorage:RootPath"] ?? "App_Data/hr-documents";
_root = Path.IsPathRooted(configuredRoot) ? configuredRoot : Path.Combine(env.ContentRootPath, configuredRoot);
Directory.CreateDirectory(_root);
}
public async Task<(string StoredFileName, string RelativePath, long SizeBytes)> SaveAsync(
Stream content, string suggestedFileName, string contentType, CancellationToken ct = default)
{
var extension = Path.GetExtension(suggestedFileName);
var storedFileName = $"{Guid.NewGuid():N}{extension}";
// Bucket by year/month so a single directory never grows unbounded.
var subDir = Path.Combine(DateTime.UtcNow.Year.ToString(), DateTime.UtcNow.Month.ToString("00"));
var absoluteDir = Path.Combine(_root, subDir);
Directory.CreateDirectory(absoluteDir);
var relativePath = Path.Combine(subDir, storedFileName).Replace('\\', '/');
var absolutePath = Path.Combine(_root, relativePath);
await using (var fileStream = new FileStream(absolutePath, FileMode.CreateNew, FileAccess.Write))
{
await content.CopyToAsync(fileStream, ct);
}
var sizeBytes = new FileInfo(absolutePath).Length;
return (storedFileName, relativePath, sizeBytes);
}
public Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default)
{
var absolutePath = ResolveSafe(relativePath);
Stream stream = new FileStream(absolutePath, FileMode.Open, FileAccess.Read);
return Task.FromResult(stream);
}
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
{
var absolutePath = ResolveSafe(relativePath);
if (File.Exists(absolutePath)) File.Delete(absolutePath);
return Task.CompletedTask;
}
public Task<bool> ExistsAsync(string relativePath, CancellationToken ct = default)
{
var absolutePath = ResolveSafe(relativePath);
return Task.FromResult(File.Exists(absolutePath));
}
/// <summary>Resolves a stored relative path and rejects any attempt to escape the storage root.</summary>
private string ResolveSafe(string relativePath)
{
var absolutePath = Path.GetFullPath(Path.Combine(_root, relativePath));
var rootFull = Path.GetFullPath(_root);
if (!absolutePath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException("Resolved path escapes the file storage root.");
return absolutePath;
}
}
+38
View File
@@ -2,11 +2,13 @@ using System.Text.Json.Serialization;
using ERPCore.Infra.Auth; using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.Persistence; using ERPCore.Infra.Persistence;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW; using ERPCore.Infra.UoW;
using ERPCore.Repositories; using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces; using ERPCore.Repositories.Interfaces;
using ERPCore.Services; using ERPCore.Services;
using ERPCore.Services.Auth; using ERPCore.Services.Auth;
using ERPCore.Services.Hrm;
using ERPCore.Services.Interfaces; using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock; using ERPCore.Services.Stock;
using ERPCore.System.Errors; using ERPCore.System.Errors;
@@ -95,6 +97,42 @@ builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13) // Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
builder.Services.AddScoped<IAuditService, AuditService>(); builder.Services.AddScoped<IAuditService, AuditService>();
// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
builder.Services.AddScoped<IBranchService, BranchService>();
builder.Services.AddScoped<IDepartmentService, DepartmentService>();
builder.Services.AddScoped<IDesignationService, DesignationService>();
builder.Services.AddScoped<IEmploymentTypeService, EmploymentTypeService>();
builder.Services.AddScoped<IWorkShiftService, WorkShiftService>();
builder.Services.AddScoped<IEmployeeUserLinkService, EmployeeUserLinkService>();
builder.Services.AddScoped<IEmployeeService, EmployeeService>();
builder.Services.AddScoped<IHrDocumentTypeService, HrDocumentTypeService>();
builder.Services.AddScoped<IEmployeeDocumentService, EmployeeDocumentService>();
// HRM: Leave (docs/13-BACKEND-HRM-API.md §5) — LeaveType/LeaveBalance built before
// LeaveRequest since approval increments balances; Attendance depends on LeaveRequest.
builder.Services.AddScoped<ILeaveTypeService, LeaveTypeService>();
builder.Services.AddScoped<ILeaveBalanceService, LeaveBalanceService>();
builder.Services.AddScoped<ILeaveRequestService, LeaveRequestService>();
// HRM: Attendance (docs/13-BACKEND-HRM-API.md §4)
builder.Services.AddScoped<IAttendanceComputationService, AttendanceComputationService>();
builder.Services.AddScoped<IAttendanceUploadService, AttendanceUploadService>();
// HRM: Payroll (docs/13-BACKEND-HRM-API.md §6) — masters/settings before the
// calculation service, which composes them; PayrollRunService orchestrates last.
builder.Services.AddScoped<ISalaryComponentService, SalaryComponentService>();
builder.Services.AddScoped<IEmployeeSalaryStructureService, EmployeeSalaryStructureService>();
builder.Services.AddScoped<IEmployeeLoanService, EmployeeLoanService>();
builder.Services.AddScoped<IPayrollStatutorySettingService, PayrollStatutorySettingService>();
builder.Services.AddScoped<ITaxSlabService, TaxSlabService>();
builder.Services.AddScoped<IPayrollCalculationService, PayrollCalculationService>();
builder.Services.AddScoped<IPayrollRunService, PayrollRunService>();
builder.Services.AddScoped<IPayslipService, PayslipService>();
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
builder.Services.AddScoped<IHrReportService, HrReportService>();
// Health checks (EF Core DB) // Health checks (EF Core DB)
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>(); builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More