Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev
This commit is contained in:
@@ -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<->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();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Dtos.Users;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -13,8 +14,19 @@ namespace ERPCore.Controllers;
|
||||
public sealed class UsersController : ApiControllerBase
|
||||
{
|
||||
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]
|
||||
[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; }
|
||||
}
|
||||
@@ -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<->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; }
|
||||
}
|
||||
@@ -23,6 +23,14 @@ public class User
|
||||
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
|
||||
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<->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>
|
||||
public int? RoleId { 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<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 > 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
|
||||
namespace ERPCore.Dtos.Users;
|
||||
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
@@ -24,6 +24,12 @@ public sealed class CreateUserRequest
|
||||
public string? MobileNumber { get; set; }
|
||||
/// <summary>Left empty to auto-generate (AuthHex emails it to <see cref="Email"/>).</summary>
|
||||
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
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<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.Design" Version="10.0.9">
|
||||
<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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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 });
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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.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).
|
||||
builder.HasOne(u => u.Role).WithMany()
|
||||
.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<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)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -96,6 +133,29 @@ public class ErpDbContext : DbContext
|
||||
// Pick up every IEntityTypeConfiguration in this assembly
|
||||
// (Infra/Persistence/Configurations/*).
|
||||
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
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ using System.Text.Json.Serialization;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.Auth.AuthHex;
|
||||
using ERPCore.Infra.Persistence;
|
||||
using ERPCore.Infra.Storage;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services;
|
||||
using ERPCore.Services.Auth;
|
||||
using ERPCore.Services.Hrm;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using ERPCore.Services.Stock;
|
||||
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)
|
||||
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)
|
||||
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
Reference in New Issue
Block a user