diff --git a/Backend/ERPCore/Controllers/DashboardController.cs b/Backend/ERPCore/Controllers/DashboardController.cs
new file mode 100644
index 0000000..b10062f
--- /dev/null
+++ b/Backend/ERPCore/Controllers/DashboardController.cs
@@ -0,0 +1,20 @@
+using ERPCore.Dtos.Dashboard;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Dashboard overview stats — cross-domain counts, not a stored entity.
+[Route("api/v1/dashboard")]
+public sealed class DashboardController : ApiControllerBase
+{
+ private readonly IDashboardService _dashboard;
+
+ public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
+
+ /// Aggregate counts for stock, GRN, and procurement.
+ [HttpGet("stats")]
+ [ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
+ public async Task> GetStats(CancellationToken ct)
+ => Ok(await _dashboard.GetStatsAsync(ct));
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/AttendanceBatchesController.cs b/Backend/ERPCore/Controllers/Hrm/AttendanceBatchesController.cs
new file mode 100644
index 0000000..83c1f20
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/AttendanceBatchesController.cs
@@ -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;
+
+/// Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).
+[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), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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), StatusCodes.Status200OK)]
+ public async Task>> 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> 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 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> 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> 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> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct)
+ => Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/BranchesController.cs b/Backend/ERPCore/Controllers/Hrm/BranchesController.cs
new file mode 100644
index 0000000..fb62839
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/BranchesController.cs
@@ -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;
+
+/// Branch master endpoints (docs/13-BACKEND-HRM-API.md §2).
+[Route("api/v1/branches")]
+public sealed class BranchesController : ApiControllerBase
+{
+ private readonly IBranchService _branches;
+
+ public BranchesController(IBranchService branches) => _branches = branches;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
+ {
+ await _branches.SetStatusAsync(branchId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs b/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs
new file mode 100644
index 0000000..2a84611
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/DepartmentsController.cs
@@ -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;
+
+/// Department master endpoints (docs/13-BACKEND-HRM-API.md §2).
+[Route("api/v1/departments")]
+public sealed class DepartmentsController : ApiControllerBase
+{
+ private readonly IDepartmentService _departments;
+
+ public DepartmentsController(IDepartmentService departments) => _departments = departments;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
+ {
+ await _departments.SetStatusAsync(departmentId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs b/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs
new file mode 100644
index 0000000..6ebc1fe
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/DesignationsController.cs
@@ -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;
+
+/// Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).
+[Route("api/v1/designations")]
+public sealed class DesignationsController : ApiControllerBase
+{
+ private readonly IDesignationService _designations;
+
+ public DesignationsController(IDesignationService designations) => _designations = designations;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
+ {
+ await _designations.SetStatusAsync(designationId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs b/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs
new file mode 100644
index 0000000..645a90b
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/EmployeesController.cs
@@ -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;
+
+///
+/// Employee (staff) endpoints, incl. the Employee<->User cross-link and staff
+/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
+///
+[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), StatusCodes.Status200OK)]
+ public async Task>> 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));
+
+ /// Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)
+ [HttpGet("email-lookup")]
+ [ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
+ public async Task> 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> 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> 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> 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);
+ }
+
+ /// Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).
+ [HttpPatch("{employeeId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task 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 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 UnlinkUser(int employeeId, CancellationToken ct)
+ {
+ await _links.UnlinkAsync(employeeId, ct);
+ return NoContent();
+ }
+
+ [HttpGet("{employeeId:int}/bank-details")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> ListBankDetails(int employeeId, CancellationToken ct)
+ => Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
+
+ [HttpPut("{employeeId:int}/bank-details")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task>> ReplaceBankDetails(
+ int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct)
+ => Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct));
+
+ [HttpGet("{employeeId:int}/leave-balances")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
+ => Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
+
+ [HttpPut("{employeeId:int}/leave-balances")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> UpdateLeaveBalances(
+ int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
+ => Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
+
+ [HttpGet("{employeeId:int}/salary-structure")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> 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> 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), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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), StatusCodes.Status200OK)]
+ public async Task>> 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> 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 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 SetDocumentStatus(
+ int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct)
+ {
+ await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs b/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs
new file mode 100644
index 0000000..e80d878
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/EmploymentTypesController.cs
@@ -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;
+
+/// EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).
+[Route("api/v1/employment-types")]
+public sealed class EmploymentTypesController : ApiControllerBase
+{
+ private readonly IEmploymentTypeService _employmentTypes;
+
+ public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
+ {
+ await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs b/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs
new file mode 100644
index 0000000..6b4b557
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/HrDocumentTypesController.cs
@@ -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;
+
+/// Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).
+[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), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
+ {
+ await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/HrReportsController.cs b/Backend/ERPCore/Controllers/Hrm/HrReportsController.cs
new file mode 100644
index 0000000..e139ee9
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/HrReportsController.cs
@@ -0,0 +1,52 @@
+using ERPCore.Dtos.Hrm;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers.Hrm;
+
+/// Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.
+[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), StatusCodes.Status200OK)]
+ public async Task>> 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), StatusCodes.Status200OK)]
+ public async Task>> Overtime(
+ [FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
+ => Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct));
+
+ [HttpGet("late-arrivals")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> LateArrivals(
+ [FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
+ => Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
+
+ [HttpGet("payroll-register")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
+ => Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
+
+ [HttpGet("salary-history")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
+ => Ok(await _reports.SalaryHistoryAsync(employeeId, ct));
+
+ [HttpGet("leave-balances")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> LeaveBalances([FromQuery] int year, CancellationToken ct)
+ => Ok(await _reports.LeaveBalanceReportAsync(year, ct));
+
+ [HttpGet("document-expiry")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct)
+ => Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct));
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/LeaveRequestsController.cs b/Backend/ERPCore/Controllers/Hrm/LeaveRequestsController.cs
new file mode 100644
index 0000000..7b6c1fc
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/LeaveRequestsController.cs
@@ -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;
+
+/// Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).
+[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), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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> 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> 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> Cancel(int leaveRequestId, CancellationToken ct)
+ => Ok(await _requests.CancelAsync(leaveRequestId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs b/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs
new file mode 100644
index 0000000..9f28099
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/LeaveTypesController.cs
@@ -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;
+
+/// Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).
+[Route("api/v1/leave-types")]
+public sealed class LeaveTypesController : ApiControllerBase
+{
+ private readonly ILeaveTypeService _leaveTypes;
+
+ public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
+ {
+ await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/PayrollRunsController.cs b/Backend/ERPCore/Controllers/Hrm/PayrollRunsController.cs
new file mode 100644
index 0000000..d2bc6c2
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/PayrollRunsController.cs
@@ -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;
+
+/// Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).
+[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), StatusCodes.Status200OK)]
+ public async Task>> 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> 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), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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> 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> 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), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task>> GeneratePayslips(int payrollRunId, CancellationToken ct)
+ => Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct));
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/PayrollStatutorySettingsController.cs b/Backend/ERPCore/Controllers/Hrm/PayrollStatutorySettingsController.cs
new file mode 100644
index 0000000..15dac2f
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/PayrollStatutorySettingsController.cs
@@ -0,0 +1,33 @@
+using ERPCore.Dtos.Hrm;
+using ERPCore.Infra.Auth;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers.Hrm;
+
+/// Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).
+[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), StatusCodes.Status200OK)]
+ public async Task>> List(CancellationToken ct)
+ => Ok(await _settings.ListAsync(ct));
+
+ [HttpPost]
+ [ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
+ public async Task> 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);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/PayslipsController.cs b/Backend/ERPCore/Controllers/Hrm/PayslipsController.cs
new file mode 100644
index 0000000..4163d81
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/PayslipsController.cs
@@ -0,0 +1,32 @@
+using ERPCore.Dtos.Hrm;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers.Hrm;
+
+/// Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).
+[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> 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 View(int payslipId, CancellationToken ct)
+ {
+ var html = await _payslips.RenderHtmlAsync(payslipId, ct);
+ return html is null ? NotFound() : Content(html, "text/html");
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs b/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs
new file mode 100644
index 0000000..d8108cd
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/SalaryComponentsController.cs
@@ -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;
+
+/// SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).
+[Route("api/v1/salary-components")]
+public sealed class SalaryComponentsController : ApiControllerBase
+{
+ private readonly ISalaryComponentService _components;
+
+ public SalaryComponentsController(ISalaryComponentService components) => _components = components;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
+ {
+ await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/TaxSlabsController.cs b/Backend/ERPCore/Controllers/Hrm/TaxSlabsController.cs
new file mode 100644
index 0000000..df8aec4
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/TaxSlabsController.cs
@@ -0,0 +1,28 @@
+using ERPCore.Dtos.Hrm;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers.Hrm;
+
+/// Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).
+[Route("api/v1/tax-slabs")]
+public sealed class TaxSlabsController : ApiControllerBase
+{
+ private readonly ITaxSlabService _taxSlabs;
+
+ public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> List(CancellationToken ct)
+ => Ok(await _taxSlabs.ListAsync(ct));
+
+ [HttpPost]
+ [ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct)
+ {
+ var result = await _taxSlabs.CreateAsync(request, ct);
+ return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs b/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs
new file mode 100644
index 0000000..88148d9
--- /dev/null
+++ b/Backend/ERPCore/Controllers/Hrm/WorkShiftsController.cs
@@ -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;
+
+/// WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).
+[Route("api/v1/work-shifts")]
+public sealed class WorkShiftsController : ApiControllerBase
+{
+ private readonly IWorkShiftService _shifts;
+
+ public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
+ {
+ await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/ProductionRunsController.cs b/Backend/ERPCore/Controllers/ProductionRunsController.cs
new file mode 100644
index 0000000..00d0eb8
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ProductionRunsController.cs
@@ -0,0 +1,157 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Production;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2–D.3).
+[Route("api/v1/production-runs")]
+public sealed class ProductionRunsController : ApiControllerBase
+{
+ private readonly IProductionRunService _runs;
+
+ public ProductionRunsController(IProductionRunService runs) => _runs = runs;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query,
+ [FromQuery] ProductionRunStatus? status,
+ [FromQuery] int? templateId,
+ [FromQuery] int? warehouseId,
+ CancellationToken ct)
+ => Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
+
+ [HttpGet("{runId:int}")]
+ [ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int runId, CancellationToken ct)
+ {
+ var result = await _runs.GetAsync(runId, ct);
+ if (result is null) return NotFound();
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create([FromBody] CreateRunRequest request, CancellationToken ct)
+ {
+ var result = await _runs.CreateAsync(request, ct);
+
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
+ }
+
+ [HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
+ [ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> UpdateQuantities(
+ int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
+ => Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
+
+ // --- stage actions (docs/30 §D.3) ---------------------------------------
+ //
+ // Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
+ // §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
+ // the status guards instead: a double-fire finds the stage already moved on and gets a
+ // 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
+ // deviation from §D.3's "idempotency-key honored".
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/start")]
+ [ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Start(
+ int runId, int runStageId,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.StartStageAsync(runId, runStageId, ct));
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
+ [ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Complete(
+ int runId, int runStageId, [FromBody] CompleteStageRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
+ [ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Approve(
+ int runId, int runStageId, [FromBody] ApproveStageRequest? request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
+ [ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Transfer(
+ int runId, int runStageId, [FromBody] TransferRemainderRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
+
+ [HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
+ [ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> ReturnLeftover(
+ int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
+
+ [HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
+ [ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> RejectIntake(
+ int runId, int runStageId, [FromBody] RejectRequest? request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
+
+ /// Terminal reject — resets the whole run for a rework pass (FR-MFG-16).
+ [HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
+ [ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Reject(
+ int runId, int runStageId, [FromBody] RejectRequest? request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
+
+ [HttpPost("{runId:int}/cancel")]
+ [ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Cancel(
+ int runId, [FromBody] CancelRunRequest request,
+ [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
+ CancellationToken ct)
+ => Ok(await _runs.CancelAsync(runId, request, ct));
+}
diff --git a/Backend/ERPCore/Controllers/ProductionTemplatesController.cs b/Backend/ERPCore/Controllers/ProductionTemplatesController.cs
new file mode 100644
index 0000000..c637741
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ProductionTemplatesController.cs
@@ -0,0 +1,73 @@
+using ERPCore.Domain.Enums;
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Production;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+/// Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).
+[Route("api/v1/production-templates")]
+public sealed class ProductionTemplatesController : ApiControllerBase
+{
+ private readonly IProductionTemplateService _templates;
+
+ public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
+ => Ok(await _templates.ListAsync(query, status, ct));
+
+ [HttpGet("{templateId:int}")]
+ [ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int templateId, CancellationToken ct)
+ {
+ var result = await _templates.GetAsync(templateId, ct);
+ if (result is null) return NotFound();
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPost]
+ [ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Create(
+ [FromBody] SaveTemplateRequest request, CancellationToken ct)
+ {
+ var result = await _templates.CreateAsync(request, ct);
+
+ SetETag(result.RowVersion);
+ return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
+ }
+
+ [HttpPut("{templateId:int}")]
+ [ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
+ public async Task> Update(
+ int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _templates.UpdateAsync(templateId, request, expected, ct);
+
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+
+ [HttpPatch("{templateId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(
+ int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
+ {
+ await _templates.SetStatusAsync(templateId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs
index 60e2fe3..bb1e3cf 100644
--- a/Backend/ERPCore/Controllers/UsersController.cs
+++ b/Backend/ERPCore/Controllers/UsersController.cs
@@ -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;
+ }
+
+ /// Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)
+ [HttpGet("email-lookup")]
+ [ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)]
+ public async Task> EmailLookup([FromQuery] string email, CancellationToken ct)
+ => Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct)));
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Domain/DocumentTypes.cs b/Backend/ERPCore/Domain/DocumentTypes.cs
index 0a84d94..564e516 100644
--- a/Backend/ERPCore/Domain/DocumentTypes.cs
+++ b/Backend/ERPCore/Domain/DocumentTypes.cs
@@ -14,4 +14,7 @@ public static class DocumentTypes
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
+
+ /// Production run (docs/30 FR-MFG-08) — PRD-2026-00001.
+ public const string Production = "PRD";
}
diff --git a/Backend/ERPCore/Domain/Entities/AttendanceRecord.cs b/Backend/ERPCore/Domain/Entities/AttendanceRecord.cs
new file mode 100644
index 0000000..5e4a890
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/AttendanceRecord.cs
@@ -0,0 +1,40 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Per employee/day attendance row (FR-HR-ATT). 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.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/AttendanceUploadBatch.cs b/Backend/ERPCore/Domain/Entities/AttendanceUploadBatch.cs
new file mode 100644
index 0000000..8e7dd32
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/AttendanceUploadBatch.cs
@@ -0,0 +1,31 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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 (docType "ATT").
+/// Model: docs/12-BACKEND-HRM.md Part C.4.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Branch.cs b/Backend/ERPCore/Domain/Entities/Branch.cs
new file mode 100644
index 0000000..7a71081
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Branch.cs
@@ -0,0 +1,23 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
+/// optionally by and .
+/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
+///
+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; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Department.cs b/Backend/ERPCore/Domain/Entities/Department.cs
new file mode 100644
index 0000000..3f8868b
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Department.cs
@@ -0,0 +1,26 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Department master (FR-HR-MD-01) — unlimited self-nesting for a real org chart
+/// (unlike the two-level-capped ); cycle prevention is a
+/// service-level check on write, not a DB constraint. Model: docs/12-BACKEND-HRM.md Part C.1.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Designation.cs b/Backend/ERPCore/Domain/Entities/Designation.cs
new file mode 100644
index 0000000..6995027
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Designation.cs
@@ -0,0 +1,19 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Employee.cs b/Backend/ERPCore/Domain/Entities/Employee.cs
new file mode 100644
index 0000000..2248904
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Employee.cs
@@ -0,0 +1,76 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Staff record (FR-HR-MD-02) — distinct from (the system login
+/// account): not every employee has a login, and not every login belongs to an
+/// employee. 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 + .
+/// is user-entered (not -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".
+///
+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
+ /// The field used for the bidirectional Employee<->User email cross-check.
+ 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; }
+
+ /// Optional login account link (unique — one User backs at most one Employee).
+ 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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/EmployeeBankDetail.cs b/Backend/ERPCore/Domain/Entities/EmployeeBankDetail.cs
new file mode 100644
index 0000000..488c23f
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/EmployeeBankDetail.cs
@@ -0,0 +1,28 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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 ; payroll disbursement targets it.
+/// Model: docs/12-BACKEND-HRM.md Part C.2.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/EmployeeDocument.cs b/Backend/ERPCore/Domain/Entities/EmployeeDocument.cs
new file mode 100644
index 0000000..45f0727
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/EmployeeDocument.cs
@@ -0,0 +1,36 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Uploaded staff document (FR-HR-DOC-02..04) — the user's "Doc". /
+/// are server-generated (never the client's filename), so the
+/// file is only ever reachable through ,
+/// 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.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/EmployeeLoan.cs b/Backend/ERPCore/Domain/Entities/EmployeeLoan.cs
new file mode 100644
index 0000000..e00c9b9
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/EmployeeLoan.cs
@@ -0,0 +1,35 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Loan/Advance (FR-HR-PAY-03) — discriminates, structurally
+/// identical otherwise. is denormalized (parallel to
+/// StockLayer.QtyRemaining). Numbered via (docType "LOAN").
+/// Model: docs/12-BACKEND-HRM.md Part C.6.
+///
+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 Installments { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructure.cs b/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructure.cs
new file mode 100644
index 0000000..dcdd914
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructure.cs
@@ -0,0 +1,30 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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
+/// null (the current one) per employee at a time.
+/// Model: docs/12-BACKEND-HRM.md Part C.6.
+///
+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 Lines { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructureLine.cs b/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructureLine.cs
new file mode 100644
index 0000000..457b0e7
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/EmployeeSalaryStructureLine.cs
@@ -0,0 +1,12 @@
+namespace ERPCore.Domain.Entities;
+
+/// Allowance/other-deduction line on a salary structure. Model: docs/12-BACKEND-HRM.md Part C.6.
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/EmploymentType.cs b/Backend/ERPCore/Domain/Entities/EmploymentType.cs
new file mode 100644
index 0000000..48ad99a
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/EmploymentType.cs
@@ -0,0 +1,20 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Labor category master (FR-HR-MD-01) — a master, not an enum, mirroring
+/// : employment categories change with company/labor-law
+/// policy without wanting a code deploy. Model: docs/12-BACKEND-HRM.md Part C.1.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/HrDocumentType.cs b/Backend/ERPCore/Domain/Entities/HrDocumentType.cs
new file mode 100644
index 0000000..f60a71c
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/HrDocumentType.cs
@@ -0,0 +1,24 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Staff document catalog (FR-HR-DOC-01) — the user's "DocType": a category of
+/// document (NIC, contract, certificate...), not the uploaded file itself (see
+/// , the "Doc"). Deactivated, not deleted, when
+/// referenced. Model: docs/12-BACKEND-HRM.md Part C.3.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs
index ba9ed6c..1065286 100644
--- a/Backend/ERPCore/Domain/Entities/Item.cs
+++ b/Backend/ERPCore/Domain/Entities/Item.cs
@@ -33,6 +33,14 @@ public class Item
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
+
+ ///
+ /// Optional fixed selling price used by Sales only. null means "use stock value"
+ /// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
+ /// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
+ ///
+ public decimal? SalePrice { get; set; }
+
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/LeaveBalance.cs b/Backend/ERPCore/Domain/Entities/LeaveBalance.cs
new file mode 100644
index 0000000..9061ba0
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/LeaveBalance.cs
@@ -0,0 +1,24 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/LeaveRequest.cs b/Backend/ERPCore/Domain/Entities/LeaveRequest.cs
new file mode 100644
index 0000000..2467daa
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/LeaveRequest.cs
@@ -0,0 +1,31 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Leave request (FR-HR-LV-02) — transactional document, numbered via
+/// (docType "LV"). Model: docs/12-BACKEND-HRM.md Part C.5.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/LeaveType.cs b/Backend/ERPCore/Domain/Entities/LeaveType.cs
new file mode 100644
index 0000000..f489ecf
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/LeaveType.cs
@@ -0,0 +1,23 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+/// Leave type master (FR-HR-LV-01). Model: docs/12-BACKEND-HRM.md Part C.5.
+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;
+ /// Feeds Payroll's No-Pay deduction when true (docs/12-BACKEND-HRM.md §6).
+ 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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/LoanInstallment.cs b/Backend/ERPCore/Domain/Entities/LoanInstallment.cs
new file mode 100644
index 0000000..0d3187c
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/LoanInstallment.cs
@@ -0,0 +1,26 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Loan installment ledger row. is stamped only when the
+/// consuming reaches Locked (docs/12-BACKEND-HRM.md A.4).
+/// Model: docs/12-BACKEND-HRM.md Part C.6.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/PayrollLine.cs b/Backend/ERPCore/Domain/Entities/PayrollLine.cs
new file mode 100644
index 0000000..40e2df9
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PayrollLine.cs
@@ -0,0 +1,41 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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.
+///
+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 Components { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Domain/Entities/PayrollLineComponent.cs b/Backend/ERPCore/Domain/Entities/PayrollLineComponent.cs
new file mode 100644
index 0000000..4b9d13c
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PayrollLineComponent.cs
@@ -0,0 +1,18 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+/// The detailed Basic/Transport/Meal/OT/Late/No-Pay/Loan/EPF/ETF/Tax breakdown. Model: docs/12-BACKEND-HRM.md Part C.6.
+public class PayrollLineComponent
+{
+ public int PayrollLineComponentId { get; set; }
+ public int PayrollLineId { get; set; }
+ public PayrollLine? PayrollLine { get; set; }
+ public PayrollLineComponentCategory ComponentCategory { get; set; }
+ /// Set only for structure-sourced Allowance/OtherDeduction lines; null for system-computed lines.
+ 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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/PayrollRun.cs b/Backend/ERPCore/Domain/Entities/PayrollRun.cs
new file mode 100644
index 0000000..f180d1f
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PayrollRun.cs
@@ -0,0 +1,33 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Payroll run (FR-HR-PAY-05/06) — the transactional document. Numbered via
+/// (docType "PAY"). Model: docs/12-BACKEND-HRM.md Part C.6.
+///
+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; }
+ /// Null = company-wide run.
+ 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 Lines { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Domain/Entities/PayrollStatutorySetting.cs b/Backend/ERPCore/Domain/Entities/PayrollStatutorySetting.cs
new file mode 100644
index 0000000..769e660
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/PayrollStatutorySetting.cs
@@ -0,0 +1,21 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Payslip.cs b/Backend/ERPCore/Domain/Entities/Payslip.cs
new file mode 100644
index 0000000..15ca156
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Payslip.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Thin generation/release marker over a — 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.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/ProductionRun.cs b/Backend/ERPCore/Domain/Entities/ProductionRun.cs
new file mode 100644
index 0000000..4bab646
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ProductionRun.cs
@@ -0,0 +1,59 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One execution instance of a template (FR-MFG-08), numbered PRD-2026-00001.
+/// Every stage, input, output and edge is copied from the template at creation
+/// with quantities scaled by , so a completed run stays
+/// readable even if the template is later edited (FR-MFG-06).
+/// The run's cost pool is derived, never stored:
+/// Σ RunStageInput.ConsumedValue − Σ RunStageInput.ReturnedValue. The terminal
+/// approve divides it by the good quantity to cost the finished layer, then closes it
+/// (FR-MFG-13, 409 RUN_COST_CLOSED).
+/// Mutable aggregate with a token. Model: docs/30 Part C.
+///
+public class ProductionRun
+{
+ public int RunId { get; set; }
+ public string DocNo { get; set; } = string.Empty;
+
+ public int TemplateId { get; set; }
+ public ProductionTemplate? Template { get; set; }
+
+ /// Stock inputs are consumed from, and the finished good received into, this warehouse.
+ public int WarehouseId { get; set; }
+ public Warehouse? Warehouse { get; set; }
+
+ /// Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.
+ public int? OutputBinId { get; set; }
+ public Bin? OutputBin { get; set; }
+
+ /// Target quantity of the finished item; drives .
+ public decimal TargetQty { get; set; }
+
+ /// TargetQty / terminalOutput.QtyPerBatch, applied to every copied quantity.
+ public decimal ScaleFactor { get; set; }
+
+ public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
+
+ /// Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.
+ public int ReworkCount { get; set; }
+
+ public int? CancelReasonCodeId { get; set; }
+ public ReasonCode? CancelReason { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+
+ /// Set by the terminal approve only. A cancelled run leaves this null.
+ public DateTime? CompletedAt { get; set; }
+
+ public uint RowVersion { get; set; }
+
+ public ICollection Stages { get; set; } = new List();
+ public ICollection Edges { get; set; } = new List();
+ public ICollection Events { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/ProductionTemplate.cs b/Backend/ERPCore/Domain/Entities/ProductionTemplate.cs
new file mode 100644
index 0000000..2a79aea
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ProductionTemplate.cs
@@ -0,0 +1,43 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// A reusable production-line definition — the stage graph designed on the canvas
+/// (FR-MFG-01). Never hard-deleted once referenced by a run; deactivated instead
+/// (FR-MD-08 posture). Editing is locked while any run of it is InProgress
+/// (FR-MFG-06, 409 TEMPLATE_IN_USE) — edit-lock replaces versioning, which is
+/// why runs copy display fields at creation. Mutable aggregate with a
+/// token. Model: docs/30 Part C.
+///
+public class ProductionTemplate
+{
+ public int TemplateId { get; set; }
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string? Description { get; set; }
+
+ public EntityStatus Status { get; set; } = EntityStatus.Active;
+
+ ///
+ /// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
+ /// verbatim and never interpreted server-side.
+ ///
+ ///
+ /// Not in docs/30 Part C — added because the builder canvas already draws these and
+ /// without somewhere to keep them a save would silently discard the user's layout notes.
+ /// They carry no graph semantics: no ports, no edges, and the validator never sees them.
+ /// Nullable so a template that has none stores nothing rather than an empty array.
+ ///
+ public string? Annotations { get; set; }
+
+ public int CreatedBy { get; set; }
+ public User? Creator { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+ public uint RowVersion { get; set; }
+
+ public ICollection Stages { get; set; } = new List();
+ public ICollection Edges { get; set; } = new List();
+ public ICollection Runs { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunEdge.cs b/Backend/ERPCore/Domain/Entities/RunEdge.cs
new file mode 100644
index 0000000..ba70037
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunEdge.cs
@@ -0,0 +1,28 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A parent → child arrow copied from the template's set at run
+/// creation.
+/// Addition to docs/30 Part C (recorded). The doc's entity model has no run
+/// edge table, but the run graph needs its own copy: deriving edges at read time through
+/// RunStage.TemplateStageId → STAGE_EDGE would let a later template edit silently
+/// rewrite completed-run history — the exact thing FR-MFG-06 exists to prevent — and
+/// breaks outright once that link is nulled by a stage deletion.
+/// Used for the run canvas, child-readiness evaluation and reject-intake's
+/// "delivering parents". Note that WIP delivery is routed by
+/// RunStageInput.FromRunOutputId, not by these edges — an edge is display and
+/// validation only.
+///
+public class RunEdge
+{
+ public int RunEdgeId { get; set; }
+
+ public int RunId { get; set; }
+ public ProductionRun? Run { get; set; }
+
+ public int ParentRunStageId { get; set; }
+ public RunStage? ParentRunStage { get; set; }
+
+ public int ChildRunStageId { get; set; }
+ public RunStage? ChildRunStage { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunStage.cs b/Backend/ERPCore/Domain/Entities/RunStage.cs
new file mode 100644
index 0000000..9d223b6
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunStage.cs
@@ -0,0 +1,59 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One stage of a run — a copy of a taken at run creation
+/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
+/// Whether this stage is terminal is derived, never stored: a stage is
+/// terminal when it has no outbound . Storing it would let it
+/// drift from the edge set.
+///
+public class RunStage
+{
+ public int RunStageId { get; set; }
+
+ public int RunId { get; set; }
+ public ProductionRun? Run { get; set; }
+
+ ///
+ /// Provenance link back to the template stage. Nullable: a template PUT may
+ /// delete a stage while completed/cancelled runs still reference it (the edit-lock
+ /// only blocks edits during InProgress runs), so the FK is SET NULL rather
+ /// than blocking the edit forever. Everything needed to display a historical run is
+ /// copied below, which is exactly what FR-MFG-06 anticipates.
+ ///
+ public int? TemplateStageId { get; set; }
+ public TemplateStage? TemplateStage { get; set; }
+
+ // --- copied from the template at run creation (FR-MFG-06) ---
+ public string Name { get; set; } = string.Empty;
+ public string? RoleLabel { get; set; }
+ public int EstimatedMinutes { get; set; }
+ public decimal PosX { get; set; }
+ public decimal PosY { get; set; }
+
+ public ProductionStageStatus Status { get; set; } = ProductionStageStatus.Waiting;
+
+ /// Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).
+ public DateTime? ActualStartAt { get; set; }
+ public DateTime? ActualEndAt { get; set; }
+
+ /// Copied from the template stage; definitions survive a rework.
+ public string FieldDefs { get; set; } = "[]";
+
+ /// Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.
+ public string? FieldValues { get; set; }
+
+ ///
+ /// Concurrency token. Stage actions re-read the stage inside their transaction and
+ /// let this xmin check serialize concurrent requests — it is what stops two
+ /// simultaneous terminal approves from both reading Done and posting two
+ /// receipts. See the idempotency note in ProductionRunService.
+ ///
+ public uint RowVersion { get; set; }
+
+ public ICollection Inputs { get; set; } = new List();
+ public ICollection Outputs { get; set; } = new List();
+ public ICollection Events { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunStageEvent.cs b/Backend/ERPCore/Domain/Entities/RunStageEvent.cs
new file mode 100644
index 0000000..631dbcc
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunStageEvent.cs
@@ -0,0 +1,40 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Immutable history of everything that happened to a run (docs/30 Part C). Written by
+/// every mutating action and never updated or deleted, so a run's story — including the
+/// figures discarded by each rework — survives in full.
+/// Addition to docs/30 Part C (recorded): . The doc hangs
+/// events off the stage only, which leaves run-level events (cancel, terminal reject)
+/// with no home and forces the detail timeline to join through stages. Keeping both
+/// links makes optional and the timeline a single query.
+///
+public class RunStageEvent
+{
+ public int EventId { get; set; }
+
+ public int RunId { get; set; }
+ public ProductionRun? Run { get; set; }
+
+ /// Null for run-level events (Cancel).
+ public int? RunStageId { get; set; }
+ public RunStage? RunStage { get; set; }
+
+ public RunStageEventType EventType { get; set; }
+
+ public string? Note { get; set; }
+
+ ///
+ /// Event-specific detail as jsonb — consumed layers on a Start, pulled-back
+ /// quantities on a RejectIntake, the full pre-rework snapshot on a TerminalReject.
+ /// Pre-serialized string, written only through ProductionJson.
+ ///
+ public string? Payload { get; set; }
+
+ public int UserId { get; set; }
+ public User? User { get; set; }
+
+ public DateTime CreatedAt { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunStageInput.cs b/Backend/ERPCore/Domain/Entities/RunStageInput.cs
new file mode 100644
index 0000000..a1b9c09
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunStageInput.cs
@@ -0,0 +1,55 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One input line of a run stage — a copy of a with its
+/// quantity scaled at creation, plus the live consumption/delivery figures.
+/// Model: docs/30 Part C.
+/// Stock inputs accumulate /
+/// at each start and / on leftover
+/// return or run cancel. Those four columns are the whole cost pool
+/// (Σ consumed − Σ returned) and are deliberately not reset by a terminal
+/// reject — already-consumed material stays in the pool (FR-MFG-16).
+/// Upstream inputs accumulate as parent stages
+/// transfer WIP in. The stage becomes Ready only when every upstream input has
+/// DeliveredQty >= PlannedQty (FR-MFG-09, an all-parents join).
+///
+public class RunStageInput
+{
+ public int RunInputId { get; set; }
+
+ public int RunStageId { get; set; }
+ public RunStage? RunStage { get; set; }
+
+ public StageInputSource Source { get; set; }
+
+ public int? ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ /// The parent output feeding this input. This — not — is what routes a transfer.
+ public int? FromRunOutputId { get; set; }
+ public RunStageOutput? FromRunOutput { get; set; }
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ /// Scaled at creation; per-run editable until the stage starts (FR-MFG-08, 409 STAGE_NOT_EDITABLE).
+ public decimal PlannedQty { get; set; }
+
+ ///
+ /// Stock inputs only, in the item's base UOM. A start consumes
+ /// max(0, PlannedQty − ConsumedQty) and adds to these, so a rework restart
+ /// with an unchanged planned quantity consumes nothing and one with a raised planned
+ /// quantity consumes only the delta (FR-MFG-16).
+ ///
+ public decimal ConsumedQty { get; set; }
+ public decimal ConsumedValue { get; set; }
+
+ /// Upstream inputs only: accumulated by parent transfers.
+ public decimal DeliveredQty { get; set; }
+
+ /// Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.
+ public decimal ReturnedQty { get; set; }
+ public decimal ReturnedValue { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/RunStageOutput.cs b/Backend/ERPCore/Domain/Entities/RunStageOutput.cs
new file mode 100644
index 0000000..6b7d801
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RunStageOutput.cs
@@ -0,0 +1,43 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// One output line of a run stage — a copy of a with its
+/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
+/// Model: docs/30 Part C.
+/// Available to transfer is derived, never stored:
+/// ProducedQty − ScrappedQty − TransferredQty. Every transfer path checks it and
+/// raises 422 TRANSFER_EXCEEDS_AVAILABLE (FR-MFG-12).
+/// Scrap cost is absorbed into the run cost pool as normal yield loss — no
+/// write-off ledger entry is posted (FR-MFG-11).
+///
+public class RunStageOutput
+{
+ public int RunOutputId { get; set; }
+
+ public int RunStageId { get; set; }
+ public RunStage? RunStage { get; set; }
+
+ /// Null on intermediate (WIP) outputs; set on the terminal output — the finished good.
+ public int? ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ /// Scaled at creation; per-run editable until the stage starts.
+ public decimal PlannedQty { get; set; }
+
+ /// Recorded at complete. A re-complete after a rework overwrites this, never adds to it.
+ public decimal ProducedQty { get; set; }
+
+ public decimal ScrappedQty { get; set; }
+
+ /// Mandatory when > 0, context Production (FR-MFG-11).
+ public int? ScrapReasonCodeId { get; set; }
+ public ReasonCode? ScrapReason { get; set; }
+
+ /// Total WIP handed to children so far, across approve and any later partial transfers.
+ public decimal TransferredQty { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SalaryComponent.cs b/Backend/ERPCore/Domain/Entities/SalaryComponent.cs
new file mode 100644
index 0000000..1f36147
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SalaryComponent.cs
@@ -0,0 +1,19 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+/// Allowance/ad hoc deduction master (FR-HR-PAY-01). Model: docs/12-BACKEND-HRM.md Part C.6.
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StageEdge.cs b/Backend/ERPCore/Domain/Entities/StageEdge.cs
new file mode 100644
index 0000000..c402369
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StageEdge.cs
@@ -0,0 +1,21 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A parent → child arrow on the template canvas. The edge set must form a DAG with
+/// at least one entry stage and exactly one terminal stage; that is enforced in
+/// ProductionGraphValidator on every save, not by the database (FR-MFG-02).
+/// Model: docs/30 Part C.
+///
+public class StageEdge
+{
+ public int EdgeId { get; set; }
+
+ public int TemplateId { get; set; }
+ public ProductionTemplate? Template { get; set; }
+
+ public int ParentStageId { get; set; }
+ public TemplateStage? ParentStage { get; set; }
+
+ public int ChildStageId { get; set; }
+ public TemplateStage? ChildStage { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StageInput.cs b/Backend/ERPCore/Domain/Entities/StageInput.cs
new file mode 100644
index 0000000..dd63212
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StageInput.cs
@@ -0,0 +1,39 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
+/// shapes applies, enforced by ProductionGraphValidator:
+///
+/// - — set,
+/// null. FIFO-consumed from the run warehouse at stage
+/// start. Allowed on any stage, e.g. packaging added late.
+/// - — set to
+/// an output of a direct parent stage, null. Flows as
+/// internal WIP and never touches stock or the ledger.
+///
+/// Model: docs/30 Part C.
+///
+public class StageInput
+{
+ public int InputId { get; set; }
+
+ public int StageId { get; set; }
+ public TemplateStage? Stage { get; set; }
+
+ public StageInputSource Source { get; set; }
+
+ /// Required when is Stock; null when Upstream.
+ public int? ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ /// Required when is Upstream; must belong to a direct parent.
+ public int? FromOutputId { get; set; }
+ public StageOutput? FromOutput { get; set; }
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ public decimal QtyPerBatch { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/StageOutput.cs b/Backend/ERPCore/Domain/Entities/StageOutput.cs
new file mode 100644
index 0000000..2bd69e6
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/StageOutput.cs
@@ -0,0 +1,27 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
+/// internal WIP only — is null, no stock and no ledger row
+/// is ever written for them. The terminal stage is the exception: it has exactly one
+/// output and that output must reference a real Item (the finished good), which
+/// is what the production receipt creates a layer for. Model: docs/30 Part C.
+///
+public class StageOutput
+{
+ public int OutputId { get; set; }
+
+ public int StageId { get; set; }
+ public TemplateStage? Stage { get; set; }
+
+ /// Null on intermediate stages; required on the terminal stage.
+ public int? ItemId { get; set; }
+ public Item? Item { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public int UomId { get; set; }
+ public Uom? Uom { get; set; }
+
+ public decimal QtyPerBatch { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/TaxSlab.cs b/Backend/ERPCore/Domain/Entities/TaxSlab.cs
new file mode 100644
index 0000000..267418a
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/TaxSlab.cs
@@ -0,0 +1,19 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Configurable APIT-style marginal tax slab (FR-HR-PAY-04) — government slabs
+/// change with the yearly budget, so this is never hardcoded.
+/// null means "and above". Model: docs/12-BACKEND-HRM.md Part C.6.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/TemplateStage.cs b/Backend/ERPCore/Domain/Entities/TemplateStage.cs
new file mode 100644
index 0000000..4223c18
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/TemplateStage.cs
@@ -0,0 +1,36 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
+/// estimated duration, a formula ( + ) and
+/// custom field definitions. Model: docs/30 Part C.
+///
+public class TemplateStage
+{
+ public int StageId { get; set; }
+
+ public int TemplateId { get; set; }
+ public ProductionTemplate? Template { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ /// Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).
+ public string? RoleLabel { get; set; }
+
+ public int EstimatedMinutes { get; set; }
+
+ /// Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).
+ public decimal PosX { get; set; }
+ public decimal PosY { get; set; }
+
+ ///
+ /// Custom field definitions as jsonb: [{ key, label, type, options?, required }]
+ /// (FR-MFG-07). Held as a pre-serialized string, matching the AuditLog.ChangeSet
+ /// precedent; always written through ProductionJson so the column can only ever
+ /// hold canonical JSON.
+ ///
+ public string FieldDefs { get; set; } = "[]";
+
+ public ICollection Inputs { get; set; } = new List();
+ public ICollection Outputs { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/User.cs b/Backend/ERPCore/Domain/Entities/User.cs
index 45ad625..aaec55d 100644
--- a/Backend/ERPCore/Domain/Entities/User.cs
+++ b/Backend/ERPCore/Domain/Entities/User.cs
@@ -23,6 +23,14 @@ public class User
/// AuthHex identity (token UserId GUID); null for the seeded system user.
public Guid? AuthUserId { get; set; }
+ ///
+ /// Mirrors the AuthHex identity's email (backfilled at create time by
+ /// UsersController.Create, 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.
+ ///
+ public string? Email { get; set; }
+
/// Local shadow assignment; null until an admin assigns one.
public int? RoleId { get; set; }
public Role? Role { get; set; }
diff --git a/Backend/ERPCore/Domain/Entities/WorkShift.cs b/Backend/ERPCore/Domain/Entities/WorkShift.cs
new file mode 100644
index 0000000..4897a69
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/WorkShift.cs
@@ -0,0 +1,32 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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).
+/// 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.
+///
+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;
+
+ /// Bitmask, bit 0 = Monday .. bit 6 = Sunday.
+ 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; }
+}
diff --git a/Backend/ERPCore/Domain/Enums/AttendanceBatchStatus.cs b/Backend/ERPCore/Domain/Enums/AttendanceBatchStatus.cs
new file mode 100644
index 0000000..40e7e1f
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/AttendanceBatchStatus.cs
@@ -0,0 +1,15 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Attendance upload batch lifecycle (FR-HR-ATT, docs/12-BACKEND-HRM.md C.4) —
+/// exactly the flow specified by the business: once it
+/// becomes payroll's source of truth; once it is
+/// immutable even to Unlock (a payroll run must be unlocked/regenerated first).
+///
+public enum AttendanceBatchStatus
+{
+ Draft,
+ Validated,
+ Confirmed,
+ UsedInPayroll
+}
diff --git a/Backend/ERPCore/Domain/Enums/AttendanceSourceType.cs b/Backend/ERPCore/Domain/Enums/AttendanceSourceType.cs
new file mode 100644
index 0000000..7a64667
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/AttendanceSourceType.cs
@@ -0,0 +1,13 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Origin of an attendance batch (docs/12-BACKEND-HRM.md C.4).
+/// is a reserved future integration seam (docs §B.7) — no device feed exists yet.
+///
+public enum AttendanceSourceType
+{
+ Excel,
+ Csv,
+ Manual,
+ BiometricDevice
+}
diff --git a/Backend/ERPCore/Domain/Enums/AttendanceStatus.cs b/Backend/ERPCore/Domain/Enums/AttendanceStatus.cs
new file mode 100644
index 0000000..7c21ee5
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/AttendanceStatus.cs
@@ -0,0 +1,17 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// 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. is derived from an overlapping
+/// Approved LeaveRequest with no uploaded punch (§6).
+///
+public enum AttendanceStatus
+{
+ Present,
+ Absent,
+ HalfDay,
+ OnLeave,
+ Holiday,
+ WeekOff
+}
diff --git a/Backend/ERPCore/Domain/Enums/CustomFieldType.cs b/Backend/ERPCore/Domain/Enums/CustomFieldType.cs
new file mode 100644
index 0000000..6419636
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/CustomFieldType.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 fieldType).
+/// Lives inside the field_defs jsonb rather than a column, but is modelled as an
+/// enum so a bad value is rejected at the DTO boundary instead of reaching the database.
+/// is the only type that reads options.
+///
+public enum CustomFieldType
+{
+ Text,
+ Number,
+ Checkbox,
+ Date,
+ Select
+}
diff --git a/Backend/ERPCore/Domain/Enums/EmployeeDocumentStatus.cs b/Backend/ERPCore/Domain/Enums/EmployeeDocumentStatus.cs
new file mode 100644
index 0000000..6ff864f
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/EmployeeDocumentStatus.cs
@@ -0,0 +1,11 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// 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).
+///
+public enum EmployeeDocumentStatus
+{
+ Active,
+ Archived
+}
diff --git a/Backend/ERPCore/Domain/Enums/EmployeeStatus.cs b/Backend/ERPCore/Domain/Enums/EmployeeStatus.cs
new file mode 100644
index 0000000..f38e354
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/EmployeeStatus.cs
@@ -0,0 +1,16 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Employee lifecycle status (docs/12-BACKEND-HRM.md C.2). An Employee is never
+/// hard-deleted; separation is recorded here instead (with LastWorkingDate
+/// 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.
+///
+public enum EmployeeStatus
+{
+ Active,
+ Suspended,
+ Resigned,
+ Terminated,
+ Retired
+}
diff --git a/Backend/ERPCore/Domain/Enums/Gender.cs b/Backend/ERPCore/Domain/Enums/Gender.cs
new file mode 100644
index 0000000..0d727c5
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/Gender.cs
@@ -0,0 +1,9 @@
+namespace ERPCore.Domain.Enums;
+
+/// Employee gender (docs/12-BACKEND-HRM.md C.2). Optional field, stored as a string.
+public enum Gender
+{
+ Male,
+ Female,
+ Other
+}
diff --git a/Backend/ERPCore/Domain/Enums/HrDocumentCategory.cs b/Backend/ERPCore/Domain/Enums/HrDocumentCategory.cs
new file mode 100644
index 0000000..6636523
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/HrDocumentCategory.cs
@@ -0,0 +1,12 @@
+namespace ERPCore.Domain.Enums;
+
+/// Staff document catalog category (docs/12-BACKEND-HRM.md C.3). Stored as a string.
+public enum HrDocumentCategory
+{
+ Identity,
+ Educational,
+ Contract,
+ Certification,
+ Statutory,
+ Other
+}
diff --git a/Backend/ERPCore/Domain/Enums/LeaveRequestStatus.cs b/Backend/ERPCore/Domain/Enums/LeaveRequestStatus.cs
new file mode 100644
index 0000000..a987711
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/LeaveRequestStatus.cs
@@ -0,0 +1,11 @@
+namespace ERPCore.Domain.Enums;
+
+/// Leave request approval lifecycle (FR-HR-LV-02, docs/12-BACKEND-HRM.md C.5).
+public enum LeaveRequestStatus
+{
+ Draft,
+ Submitted,
+ Approved,
+ Rejected,
+ Cancelled
+}
diff --git a/Backend/ERPCore/Domain/Enums/LoanInstallmentStatus.cs b/Backend/ERPCore/Domain/Enums/LoanInstallmentStatus.cs
new file mode 100644
index 0000000..583da06
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/LoanInstallmentStatus.cs
@@ -0,0 +1,13 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// 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.
+///
+public enum LoanInstallmentStatus
+{
+ Pending,
+ Deducted,
+ Skipped
+}
diff --git a/Backend/ERPCore/Domain/Enums/LoanKind.cs b/Backend/ERPCore/Domain/Enums/LoanKind.cs
new file mode 100644
index 0000000..8799a52
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/LoanKind.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+/// Loan vs Advance discriminator (docs/12-BACKEND-HRM.md C.6) — structurally identical, differ only in intent/labeling.
+public enum LoanKind
+{
+ Loan,
+ Advance
+}
diff --git a/Backend/ERPCore/Domain/Enums/LoanStatus.cs b/Backend/ERPCore/Domain/Enums/LoanStatus.cs
new file mode 100644
index 0000000..c6df7fa
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/LoanStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+public enum LoanStatus
+{
+ Active,
+ Closed,
+ Cancelled
+}
diff --git a/Backend/ERPCore/Domain/Enums/PayrollLineComponentCategory.cs b/Backend/ERPCore/Domain/Enums/PayrollLineComponentCategory.cs
new file mode 100644
index 0000000..7c89339
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/PayrollLineComponentCategory.cs
@@ -0,0 +1,12 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// EmployerContribution lines (EPF-employer, ETF) are informational/liability only —
+/// never subtracted from Net Salary (docs/12-BACKEND-HRM.md B.4).
+///
+public enum PayrollLineComponentCategory
+{
+ Earning,
+ Deduction,
+ EmployerContribution
+}
diff --git a/Backend/ERPCore/Domain/Enums/PayrollRunStatus.cs b/Backend/ERPCore/Domain/Enums/PayrollRunStatus.cs
new file mode 100644
index 0000000..484db4a
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/PayrollRunStatus.cs
@@ -0,0 +1,15 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// 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.
+///
+public enum PayrollRunStatus
+{
+ Draft,
+ Approved,
+ Locked
+}
diff --git a/Backend/ERPCore/Domain/Enums/ProductionRunStatus.cs b/Backend/ERPCore/Domain/Enums/ProductionRunStatus.cs
new file mode 100644
index 0000000..7d9a024
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/ProductionRunStatus.cs
@@ -0,0 +1,14 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
+/// and reaches exactly one final state: it completes at the
+/// terminal stage's approve (production receipt, FR-MFG-13) or is cancelled with a
+/// stock return (FR-MFG-17). Stored as a string.
+///
+public enum ProductionRunStatus
+{
+ InProgress,
+ Completed,
+ Cancelled
+}
diff --git a/Backend/ERPCore/Domain/Enums/ProductionStageStatus.cs b/Backend/ERPCore/Domain/Enums/ProductionStageStatus.cs
new file mode 100644
index 0000000..f5e04a0
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/ProductionStageStatus.cs
@@ -0,0 +1,22 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Status of one stage within a production run (docs/30 §B.4, §D.5).
+///
+/// Waiting ──(all upstream inputs fully delivered)──▶ Ready
+/// Ready ──start (FIFO-consume Stock inputs)──▶ InProgress
+/// InProgress ──complete (produced/scrap/fields)──▶ Done
+/// Done ──approve──▶ Approved (non-terminal: WIP transfers out; terminal: receipt)
+///
+/// Rejection is not a resting state (FR-MFG-15/16): a reject immediately produces a
+/// rework transition back into this set and is recorded as a
+/// instead. Stored as a string.
+///
+public enum ProductionStageStatus
+{
+ Waiting,
+ Ready,
+ InProgress,
+ Done,
+ Approved
+}
diff --git a/Backend/ERPCore/Domain/Enums/ReasonContext.cs b/Backend/ERPCore/Domain/Enums/ReasonContext.cs
index b569040..10a8b18 100644
--- a/Backend/ERPCore/Domain/Enums/ReasonContext.cs
+++ b/Backend/ERPCore/Domain/Enums/ReasonContext.cs
@@ -5,5 +5,8 @@ public enum ReasonContext
{
Adjustment,
Return,
- Count
+ Count,
+
+ /// Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).
+ Production
}
diff --git a/Backend/ERPCore/Domain/Enums/RowValidationStatus.cs b/Backend/ERPCore/Domain/Enums/RowValidationStatus.cs
new file mode 100644
index 0000000..627f636
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/RowValidationStatus.cs
@@ -0,0 +1,12 @@
+namespace ERPCore.Domain.Enums;
+
+/// Per-record outcome of the attendance upload validation pipeline (docs/12-BACKEND-HRM.md §B.3.4).
+public enum RowValidationStatus
+{
+ Valid,
+ DuplicateWithinBatch,
+ DuplicateConfirmed,
+ EmployeeNotFound,
+ InvalidDateTime,
+ Error
+}
diff --git a/Backend/ERPCore/Domain/Enums/RunStageEventType.cs b/Backend/ERPCore/Domain/Enums/RunStageEventType.cs
new file mode 100644
index 0000000..0e1e173
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/RunStageEventType.cs
@@ -0,0 +1,20 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Kind of entry in a run's immutable history (docs/30 Part C, RUN_STAGE_EVENT).
+/// Every mutating action on a run writes exactly one event carrying who/when plus a
+/// jsonb payload; additionally snapshots the whole run's
+/// figures for the rework pass being discarded (FR-MFG-16). Stored as a string.
+///
+public enum RunStageEventType
+{
+ Start,
+ Complete,
+ Approve,
+ Transfer,
+ RejectIntake,
+ TerminalReject,
+ LeftoverReturn,
+ Cancel,
+ QuantityEdit
+}
diff --git a/Backend/ERPCore/Domain/Enums/SalaryComponentType.cs b/Backend/ERPCore/Domain/Enums/SalaryComponentType.cs
new file mode 100644
index 0000000..1267aa1
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/SalaryComponentType.cs
@@ -0,0 +1,12 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// 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.
+///
+public enum SalaryComponentType
+{
+ Earning,
+ Deduction
+}
diff --git a/Backend/ERPCore/Domain/Enums/SalaryStructureStatus.cs b/Backend/ERPCore/Domain/Enums/SalaryStructureStatus.cs
new file mode 100644
index 0000000..504036d
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/SalaryStructureStatus.cs
@@ -0,0 +1,8 @@
+namespace ERPCore.Domain.Enums;
+
+/// Effective-dated salary structure status (docs/12-BACKEND-HRM.md C.6) — exactly one Active (open-ended) row per employee at a time.
+public enum SalaryStructureStatus
+{
+ Active,
+ Superseded
+}
diff --git a/Backend/ERPCore/Domain/Enums/StageInputSource.cs b/Backend/ERPCore/Domain/Enums/StageInputSource.cs
new file mode 100644
index 0000000..2635415
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/StageInputSource.cs
@@ -0,0 +1,14 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Where a stage input's material comes from (FR-MFG-04; docs/30 §D.5).
+/// inputs reference an Item and are FIFO-consumed from the run
+/// warehouse when the stage starts. inputs reference a direct
+/// parent stage's output and flow as internal WIP — they never touch the ledger.
+/// Stored as a string.
+///
+public enum StageInputSource
+{
+ Stock,
+ Upstream
+}
diff --git a/Backend/ERPCore/Domain/LedgerSourceTypes.cs b/Backend/ERPCore/Domain/LedgerSourceTypes.cs
new file mode 100644
index 0000000..e6cab32
--- /dev/null
+++ b/Backend/ERPCore/Domain/LedgerSourceTypes.cs
@@ -0,0 +1,34 @@
+namespace ERPCore.Domain;
+
+///
+/// Additional STOCK_LEDGER.source_doc_type values for manufacturing movements
+/// (docs/30 §A.2). source_doc_id is always the run_id, so
+/// LIKE 'PRD%' traces every stock movement a run caused.
+///
+///
+/// Deviation from docs/30 §A.2 (recorded). The doc proposes the long names
+/// ProductionIssue/ProductionReceipt/ProductionReturn/
+/// ProductionCancelReturn, but both stock_ledger.SourceDocType and
+/// journal_entry_stubs.SourceDocType are varchar(10) and every existing
+/// value is a short prefix (GRN, TRF, ADJ, PRET). Widening
+/// those columns would be a second Phase-1 schema change beyond the single deviation
+/// §A.1 declares (NFR-08), so the short codes below extend the existing convention
+/// instead. docs/30 §A.2 and §D.5 are amended to match.
+/// These are not entries — production issues no
+/// document per movement. The run's own document number uses
+/// (PRD-2026-00001).
+///
+public static class LedgerSourceTypes
+{
+ /// Stock consumed by a stage start (FR-MFG-10). Outbound.
+ public const string ProductionIssue = "PRDI";
+
+ /// Finished goods received at the terminal approve (FR-MFG-13). Inbound.
+ public const string ProductionReceipt = "PRDR";
+
+ /// Unconsumed material returned before receipt (FR-MFG-14). Inbound.
+ public const string ProductionReturn = "PRDL";
+
+ /// Net consumed stock returned when a run is cancelled (FR-MFG-17). Inbound.
+ public const string ProductionCancelReturn = "PRDC";
+}
diff --git a/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs
new file mode 100644
index 0000000..857ba9a
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Dashboard/DashboardDtos.cs
@@ -0,0 +1,24 @@
+namespace ERPCore.Dtos.Dashboard;
+
+///
+/// Aggregate counts for the dashboard overview — mirrors the widget set in
+/// docs/dashboard-implementation.pdf: reorder alerts, on-hand summary, stock valuation,
+/// pending-approval POs, pending GRNs, open requisitions, open counts awaiting posting,
+/// and open RFQs. Recent movements isn't here — it's just GET /stock/ledger with a small
+/// pageSize, no aggregation needed. A single computed-on-read object, not a stored
+/// entity — same as reorder alerts (docs/11 §5.7).
+///
+public sealed record DashboardStatsDto(
+ int LowStockAlerts,
+ decimal OnHandTotal,
+ int OnHandWarehouses,
+ decimal StockValuationTotal,
+ IReadOnlyList StockValuationByWarehouse,
+ int PendingApprovalPurchaseOrders,
+ int PendingGrns,
+ int OpenRequisitions,
+ int PendingCounts,
+ int OpenRfqs);
+
+/// One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.
+public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
diff --git a/Backend/ERPCore/Dtos/Hrm/AttendanceDtos.cs b/Backend/ERPCore/Dtos/Hrm/AttendanceDtos.cs
new file mode 100644
index 0000000..6ea99c6
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Hrm/AttendanceDtos.cs
@@ -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; }
+ /// "keep" discards the other duplicate row(s); "discard" removes this row; "supersede" (cross-batch-confirmed only) replaces the prior confirmed record.
+ [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;
+}
diff --git a/Backend/ERPCore/Dtos/Hrm/DocumentDtos.cs b/Backend/ERPCore/Dtos/Hrm/DocumentDtos.cs
new file mode 100644
index 0000000..a09d426
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Hrm/DocumentDtos.cs
@@ -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);
+
+/// Metadata accompanying a multipart file upload (the file itself is bound separately).
+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; }
+}
diff --git a/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
new file mode 100644
index 0000000..2be6c3a
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Hrm/EmployeeDtos.cs
@@ -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; }
+
+ /// Explicit, human-confirmed link to an existing User found via email-lookup — never automatic.
+ 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 Items { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Hrm/EmployeeUserLinkDtos.cs b/Backend/ERPCore/Dtos/Hrm/EmployeeUserLinkDtos.cs
new file mode 100644
index 0000000..8b2130e
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Hrm/EmployeeUserLinkDtos.cs
@@ -0,0 +1,17 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Hrm;
+
+/// Advisory match surfaced by the reverse-direction email-lookup (docs/12-BACKEND-HRM.md A.5).
+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; }
+}
diff --git a/Backend/ERPCore/Dtos/Hrm/LeaveDtos.cs b/Backend/ERPCore/Dtos/Hrm/LeaveDtos.cs
new file mode 100644
index 0000000..2deafe0
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Hrm/LeaveDtos.cs
@@ -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 Items { get; set; } = new();
+}
diff --git a/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs b/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs
new file mode 100644
index 0000000..c90c9eb
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Hrm/OrgMasterDtos.cs
@@ -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; }
+}
+
+/// Shared by all five masters' PATCH .../status endpoints.
+public sealed class UpdateHrMasterStatusRequest
+{
+ [Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Hrm/PayrollDtos.cs b/Backend/ERPCore/Dtos/Hrm/PayrollDtos.cs
new file mode 100644
index 0000000..d9fd15c
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Hrm/PayrollDtos.cs
@@ -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 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 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 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 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);
diff --git a/Backend/ERPCore/Dtos/Hrm/ReportDtos.cs b/Backend/ERPCore/Dtos/Hrm/ReportDtos.cs
new file mode 100644
index 0000000..6429fd1
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Hrm/ReportDtos.cs
@@ -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);
diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
index adddcfa..9433c76 100644
--- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs
+++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
@@ -9,7 +9,7 @@ namespace ERPCore.Dtos.Items;
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
- string? TaxClass, EntityStatus Status);
+ string? TaxClass, decimal? SalePrice, EntityStatus Status);
/// A single per-warehouse reorder policy row.
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
@@ -27,7 +27,7 @@ public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
- string? TaxClass, EntityStatus Status, IReadOnlyList Reorder,
+ string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList Reorder,
IReadOnlyList Conversions,
DateTime CreatedAt, DateTime? UpdatedAt);
@@ -62,6 +62,8 @@ public sealed class CreateItemRequest
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
+ /// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.
+ [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
}
public sealed class UpdateItemRequest
@@ -79,6 +81,8 @@ public sealed class UpdateItemRequest
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
+ /// Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.
+ [Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
}
public sealed class UpdateItemStatusRequest
diff --git a/Backend/ERPCore/Dtos/Production/RunDtos.cs b/Backend/ERPCore/Dtos/Production/RunDtos.cs
new file mode 100644
index 0000000..50de0a8
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Production/RunDtos.cs
@@ -0,0 +1,113 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Production;
+
+// Production run contract (docs/30-BACKEND-PHASE2.md §D.2–D.3).
+//
+// Statuses are never client-settable (02-SECURITY §B.6): a run's status and every stage
+// status move only through the stage-action endpoints. Requests here carry quantities and
+// references, nothing else.
+
+// --- responses ---------------------------------------------------------------
+
+/// Per-status stage counts driving the board's progress strip (FR-MFG-18, docs/21 §3).
+public sealed record StageSummaryDto(int Waiting, int Ready, int InProgress, int Done, int Approved);
+
+/// Row on the run board (docs/30 §D.2 GET /production-runs).
+public sealed record RunSummaryDto(
+ int RunId, string DocNo, int TemplateId, string TemplateName,
+ decimal TargetQty, ProductionRunStatus Status, int ReworkCount,
+ StageSummaryDto StageSummary, int WarehouseId,
+ int? FinishedItemId, string? FinishedItemName,
+ int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
+
+///
+/// The run cost pool (FR-MFG-13). Always derived from the stage inputs, never stored —
+/// surfaced so the UI can preview the finished unit cost before approving the terminal.
+///
+public sealed record CostPoolDto(decimal Consumed, decimal Returned, decimal Net);
+
+public sealed record RunStageInputDto(
+ int RunInputId, StageInputSource Source, int? ItemId, int? FromRunOutputId, int UomId,
+ decimal PlannedQty, decimal ConsumedQty, decimal ConsumedValue,
+ decimal DeliveredQty, decimal ReturnedQty, decimal ReturnedValue);
+
+///
+/// One output of a run stage. AvailableToTransfer is derived — produced − scrapped −
+/// transferred (FR-MFG-12) — and never stored.
+///
+public sealed record RunStageOutputDto(
+ int RunOutputId, int? ItemId, string Name, int UomId,
+ decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
+ decimal TransferredQty, decimal AvailableToTransfer);
+
+///
+/// One stage of a run. IsTerminal (no outbound edge) and IsEntry (no inbound
+/// edge) are derived from the run edge set rather than stored, so they cannot drift from it.
+/// ActualMinutes is the elapsed whole minutes once the stage has finished, null while
+/// it is still running (FR-MFG-19). FieldValues is the raw jsonb captured at complete,
+/// passed through verbatim.
+///
+public sealed record RunStageDto(
+ int RunStageId, int? TemplateStageId, string Name, string? RoleLabel,
+ int EstimatedMinutes, decimal PosX, decimal PosY,
+ ProductionStageStatus Status, bool IsTerminal, bool IsEntry,
+ DateTime? ActualStartAt, DateTime? ActualEndAt, int? ActualMinutes,
+ IReadOnlyList FieldDefs, JsonElement? FieldValues,
+ IReadOnlyList Inputs, IReadOnlyList Outputs);
+
+public sealed record RunEdgeDto(int RunEdgeId, int ParentRunStageId, int ChildRunStageId);
+
+public sealed record RunEventDto(
+ int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
+ JsonElement? Payload, int UserId, DateTime CreatedAt);
+
+/// Full run graph (docs/30 §D.2 GET /production-runs/{id}).
+public sealed record RunGraphDto(
+ int RunId, string DocNo, int TemplateId, string TemplateName,
+ int WarehouseId, int? OutputBinId,
+ decimal TargetQty, decimal ScaleFactor,
+ ProductionRunStatus Status, int ReworkCount, int? CancelReasonCodeId,
+ CostPoolDto CostPool,
+ IReadOnlyList Stages, IReadOnlyList Edges, IReadOnlyList Events,
+ int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
+
+// --- requests ----------------------------------------------------------------
+
+public sealed class CreateRunRequest
+{
+ [Range(1, int.MaxValue)]
+ public int TemplateId { get; set; }
+
+ /// Quantity of the finished item; drives the whole graph's scale factor (FR-MFG-08).
+ [Range(0.0001, double.MaxValue)]
+ public decimal TargetQty { get; set; }
+
+ [Range(1, int.MaxValue)]
+ public int WarehouseId { get; set; }
+
+ /// Optional bin for the finished goods; must belong to .
+ public int? OutputBinId { get; set; }
+}
+
+///
+/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
+/// (409 STAGE_NOT_EDITABLE).
+///
+public sealed class UpdateStageQuantitiesRequest
+{
+ public List Inputs { get; set; } = new();
+ public List Outputs { get; set; } = new();
+}
+
+public sealed class StageQuantityLine
+{
+ /// The runInputId or runOutputId being adjusted.
+ [Range(1, int.MaxValue)]
+ public int Id { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal PlannedQty { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Production/StageActionDtos.cs b/Backend/ERPCore/Dtos/Production/StageActionDtos.cs
new file mode 100644
index 0000000..c4ad03b
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Production/StageActionDtos.cs
@@ -0,0 +1,177 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Production;
+
+// Stage-action contract (docs/30-BACKEND-PHASE2.md §D.3). Every action is transactional,
+// guards on the stage's current status, and returns the refreshed stage plus whatever
+// stock effects it caused.
+
+// --- start -------------------------------------------------------------------
+
+/// One FIFO layer a stage start drew from, at that layer's cost.
+public sealed record ConsumedLayerDto(int LayerId, decimal Qty, decimal UnitCost);
+
+/// What a single Stock input consumed at start (FR-MFG-10).
+public sealed record ConsumedInputDto(
+ int RunInputId, int ItemId, decimal Qty, decimal Value, IReadOnlyList ConsumedLayers);
+
+public sealed record StartStageResultDto(
+ int RunStageId, ProductionStageStatus Status, DateTime? ActualStartAt,
+ IReadOnlyList Consumed, IReadOnlyList LedgerRefs,
+ RunStageDto Stage);
+
+// --- complete ----------------------------------------------------------------
+
+public sealed class CompleteStageRequest
+{
+ [Required, MinLength(1)]
+ public List Outputs { get; set; } = new();
+
+ ///
+ /// Values for the stage's custom fields, keyed by fieldDefs[].key. Every field
+ /// marked required must be present and non-empty (400 REQUIRED_FIELD_MISSING).
+ ///
+ public JsonElement? FieldValues { get; set; }
+}
+
+public sealed class CompleteOutputLine
+{
+ [Range(1, int.MaxValue)]
+ public int RunOutputId { get; set; }
+
+ [Range(0, double.MaxValue)]
+ public decimal ProducedQty { get; set; }
+
+ [Range(0, double.MaxValue)]
+ public decimal ScrappedQty { get; set; }
+
+ /// Mandatory once > 0; must be a Production reason.
+ public int? ScrapReasonCodeId { get; set; }
+}
+
+// --- approve / transfer ------------------------------------------------------
+
+///
+/// One WIP hand-off from a parent output to a child input. Deliveries route by
+/// fromRunOutputId, not by edge — see the note on RunEdge.
+///
+public sealed record TransferDto(
+ int RunOutputId, int RunInputId, int ChildRunStageId, decimal Qty,
+ decimal ChildDeliveredQty, ProductionStageStatus ChildStatus);
+
+/// The finished-goods layer a terminal approve created (FR-MFG-13).
+public sealed record ReceiptDto(
+ int LayerId, int ItemId, int WarehouseId, int? BinId,
+ decimal QtyReceived, decimal UnitCost, decimal Value);
+
+public sealed record ApproveStageResultDto(
+ int RunStageId, ProductionStageStatus Status, ProductionRunStatus RunStatus,
+ IReadOnlyList Transfers,
+ ReceiptDto? Receipt, CostPoolDto? CostPool, IReadOnlyList LedgerRefs,
+ RunStageDto Stage);
+
+public sealed class ApproveStageRequest
+{
+ ///
+ /// Optional partial transfers. Omitted or empty means transfer the full available
+ /// quantity of every output (FR-MFG-12).
+ ///
+ public List Transfers { get; set; } = new();
+}
+
+public sealed class TransferLine
+{
+ [Range(1, int.MaxValue)]
+ public int RunOutputId { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal Qty { get; set; }
+
+ ///
+ /// Optional explicit target. When an output feeds several child inputs the server
+ /// otherwise fills them in runInputId order; naming one removes the ambiguity.
+ ///
+ public int? RunInputId { get; set; }
+}
+
+public sealed class TransferRemainderRequest
+{
+ [Range(1, int.MaxValue)]
+ public int RunOutputId { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal Qty { get; set; }
+
+ public int? RunInputId { get; set; }
+}
+
+public sealed record TransferResultDto(
+ int RunStageId, IReadOnlyList Transfers, RunStageDto Stage);
+
+// --- leftover return ---------------------------------------------------------
+
+public sealed class ReturnLeftoverRequest
+{
+ ///
+ /// Quantity to return, in the item's base UOM — the same unit
+ /// consumedQty/returnedQty are stored in, since the return posts straight
+ /// to stock. Cannot exceed consumed − already returned.
+ ///
+ [Range(0.0001, double.MaxValue)]
+ public decimal Qty { get; set; }
+
+ /// Mandatory, and must be a Production-context reason (FR-MFG-14).
+ public int? ReasonCodeId { get; set; }
+}
+
+public sealed record ProductionCreatedLayerDto(int LayerId, decimal UnitCost);
+
+public sealed record ReturnLeftoverResultDto(
+ int RunInputId, decimal ReturnedQty, decimal ReturnedValue,
+ ProductionCreatedLayerDto CreatedLayer, IReadOnlyList LedgerRefs, CostPoolDto CostPool);
+
+// --- rejection / rework ------------------------------------------------------
+
+public sealed class RejectRequest
+{
+ [StringLength(500)]
+ public string? Note { get; set; }
+}
+
+/// One parent whose delivered work was pulled back by a reject-intake (FR-MFG-15).
+public sealed record PulledBackDto(
+ int RunInputId, int ParentRunStageId, int ParentRunOutputId,
+ decimal Qty, ProductionStageStatus PriorParentStatus, ProductionStageStatus ParentStatus);
+
+public sealed record RejectIntakeResultDto(
+ int RunStageId, ProductionStageStatus Status,
+ IReadOnlyList PulledBack, RunGraphDto Run);
+
+public sealed record TerminalRejectResultDto(
+ int RunStageId, int ReworkCount, RunGraphDto Run);
+
+// --- cancel ------------------------------------------------------------------
+
+public sealed class CancelRunRequest
+{
+ public int? ReasonCodeId { get; set; }
+
+ [StringLength(500)]
+ public string? Note { get; set; }
+}
+
+public sealed record CancelReturnDto(int ItemId, decimal Qty, decimal UnitCost, int LayerId);
+
+///
+/// Scrapped output quantities written off by a cancel. Recorded on the event only — scrap
+/// sits on outputs, which never entered stock, so there is nothing to return (FR-MFG-17).
+///
+public sealed record ScrapWriteOffDto(int RunOutputId, string Name, decimal Qty);
+
+public sealed record CancelRunResultDto(
+ int RunId, ProductionRunStatus Status,
+ IReadOnlyList Returns,
+ IReadOnlyList ScrappedWrittenOff,
+ IReadOnlyList LedgerRefs);
diff --git a/Backend/ERPCore/Dtos/Production/TemplateDtos.cs b/Backend/ERPCore/Dtos/Production/TemplateDtos.cs
new file mode 100644
index 0000000..d9185f1
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Production/TemplateDtos.cs
@@ -0,0 +1,177 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Production;
+
+// Production template contract (docs/30-BACKEND-PHASE2.md §D.1).
+//
+// The `Key` vocabulary: every stage and every output carries a client-facing string key
+// alongside its database id. On a GET the key IS the stringified id; on a save the client
+// echoes those keys back for rows it kept and mints "tmp-" keys for rows it just
+// drew. Edges and Upstream inputs then reference stages/outputs *by key* only, which is
+// what lets one payload shape — and one validator — serve both POST (nothing has an id
+// yet) and PUT (most things do).
+
+// --- responses ---------------------------------------------------------------
+
+///
+/// Row on the template list (docs/30 §D.1 GET /production-templates).
+///
+///
+/// StageNames is an addition to the documented shape. The template overview is a
+/// canvas showing every template as a production line with its stages left-to-right
+/// (docs/21 §1), so it needs the names for every listed row — without them the client would
+/// have to fetch each template's full graph just to label the boxes. Ordered by stage id,
+/// matching the graph endpoint.
+///
+public sealed record TemplateSummaryDto(
+ int TemplateId, string Code, string Name, EntityStatus Status,
+ int StageCount, IReadOnlyList StageNames,
+ int ActiveRunCount, int CreatedBy, DateTime CreatedAt);
+
+/// One custom field definition, serialized verbatim into the stage's field_defs jsonb.
+public sealed record FieldDefDto(
+ string Key, string Label, CustomFieldType Type, IReadOnlyList? Options, bool Required);
+
+public sealed record StageInputDto(
+ int InputId, StageInputSource Source, int? ItemId,
+ int? FromOutputId, string? FromOutputKey, int UomId, decimal QtyPerBatch);
+
+public sealed record StageOutputDto(
+ int OutputId, string Key, int? ItemId, string Name, int UomId, decimal QtyPerBatch);
+
+public sealed record TemplateStageDto(
+ int StageId, string Key, string Name, string? RoleLabel, int EstimatedMinutes,
+ decimal PosX, decimal PosY, IReadOnlyList FieldDefs,
+ IReadOnlyList Inputs, IReadOnlyList Outputs);
+
+public sealed record TemplateEdgeDto(
+ int EdgeId, int ParentStageId, int ChildStageId, string ParentKey, string ChildKey);
+
+///
+/// A canvas-only grouping box or divider line. Round-tripped verbatim: no server-side
+/// meaning whatsoever, and invisible to the graph validator.
+///
+public sealed record CanvasAnnotationDto(
+ string Kind, decimal PosX, decimal PosY, decimal Width, decimal Height,
+ string? Label, decimal? Rotation);
+
+/// Full graph (docs/30 §D.1 GET /production-templates/{id}).
+///
+/// ActiveRunCount and Annotations are additions to the documented shape.
+/// The first is what puts the builder into its edit-locked state (docs/21 §2) and mirrors
+/// the condition UpdateAsync enforces — without it the builder would need a second
+/// request to the list endpoint just to know whether to disable itself.
+///
+public sealed record TemplateGraphDto(
+ int TemplateId, string Code, string Name, string? Description, EntityStatus Status,
+ IReadOnlyList Stages, IReadOnlyList Edges,
+ IReadOnlyList Annotations, int ActiveRunCount,
+ int CreatedBy, DateTime CreatedAt);
+
+// --- requests ----------------------------------------------------------------
+// Narrow by design (02-SECURITY §B.6): no status, no ids, no createdBy, no timestamps.
+// POST and PUT share this shape; PUT replaces the whole graph.
+
+public sealed class SaveTemplateRequest
+{
+ [Required, StringLength(30, MinimumLength = 1)]
+ public string Code { get; set; } = string.Empty;
+
+ [Required, StringLength(150, MinimumLength = 1)]
+ public string Name { get; set; } = string.Empty;
+
+ [StringLength(500)]
+ public string? Description { get; set; }
+
+ [Required, MinLength(1)]
+ public List Stages { get; set; } = new();
+
+ /// Empty is legal — a single-stage template is both entry and terminal.
+ public List Edges { get; set; } = new();
+
+ ///
+ /// Canvas boxes/lines. Capped because this is free-form client state going into a jsonb
+ /// column — a hand-rolled request should not be able to store an unbounded blob.
+ ///
+ [MaxLength(200)]
+ public List Annotations { get; set; } = new();
+}
+
+public sealed class SaveStageRequest
+{
+ /// Existing stage id as a string, or a client-minted tmp-* key for a new stage.
+ [Required, StringLength(60, MinimumLength = 1)]
+ public string Key { get; set; } = string.Empty;
+
+ [Required, StringLength(150, MinimumLength = 1)]
+ public string Name { get; set; } = string.Empty;
+
+ [StringLength(60)]
+ public string? RoleLabel { get; set; }
+
+ [Range(0, 1_000_000)]
+ public int EstimatedMinutes { get; set; }
+
+ /// Canvas coordinates, stored verbatim and never interpreted server-side.
+ public decimal PosX { get; set; }
+ public decimal PosY { get; set; }
+
+ public List FieldDefs { get; set; } = new();
+ public List Inputs { get; set; } = new();
+ public List Outputs { get; set; } = new();
+}
+
+public sealed class SaveInputRequest
+{
+ [Required]
+ public StageInputSource Source { get; set; }
+
+ /// Required when is Stock; must be null when Upstream.
+ public int? ItemId { get; set; }
+
+ /// Required when is Upstream; must name an output of a direct parent.
+ [StringLength(60)]
+ public string? FromOutputKey { get; set; }
+
+ [Range(1, int.MaxValue)]
+ public int UomId { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal QtyPerBatch { get; set; }
+}
+
+public sealed class SaveOutputRequest
+{
+ /// Existing output id as a string, or a tmp-* key. Referenced by Upstream inputs.
+ [Required, StringLength(60, MinimumLength = 1)]
+ public string Key { get; set; } = string.Empty;
+
+ /// Required on the terminal stage's single output; must be null on every other output.
+ public int? ItemId { get; set; }
+
+ [Required, StringLength(150, MinimumLength = 1)]
+ public string Name { get; set; } = string.Empty;
+
+ [Range(1, int.MaxValue)]
+ public int UomId { get; set; }
+
+ [Range(0.0001, double.MaxValue)]
+ public decimal QtyPerBatch { get; set; }
+}
+
+public sealed class SaveEdgeRequest
+{
+ [Required, StringLength(60, MinimumLength = 1)]
+ public string ParentKey { get; set; } = string.Empty;
+
+ [Required, StringLength(60, MinimumLength = 1)]
+ public string ChildKey { get; set; } = string.Empty;
+}
+
+/// Body of PATCH /production-templates/{id}/status (docs/30 §D.1).
+public sealed class UpdateTemplateStatusRequest
+{
+ [Required]
+ public EntityStatus Status { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs
index 0d4c5ce..2de2099 100644
--- a/Backend/ERPCore/Dtos/Users/UserDtos.cs
+++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs
@@ -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);
///
@@ -24,6 +24,12 @@ public sealed class CreateUserRequest
public string? MobileNumber { get; set; }
/// Left empty to auto-generate (AuthHex emails it to ).
public string? Password { get; set; }
+
+ ///
+ /// 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).
+ ///
+ public int? LinkEmployeeId { get; set; }
}
public sealed class UpdateUserRoleRequest
diff --git a/Backend/ERPCore/ERPCore.csproj b/Backend/ERPCore/ERPCore.csproj
index 9bf3c00..77137ac 100644
--- a/Backend/ERPCore/ERPCore.csproj
+++ b/Backend/ERPCore/ERPCore.csproj
@@ -7,6 +7,8 @@
+
+
all
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceRecordConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceRecordConfiguration.cs
new file mode 100644
index 0000000..64e1803
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceRecordConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("hr_attendance_records");
+ builder.HasKey(r => r.AttendanceRecordId);
+
+ builder.Property(r => r.Notes).HasMaxLength(1000);
+ builder.Property(r => r.AttendanceStatus).HasConversion().HasMaxLength(20).IsRequired();
+ builder.Property(r => r.RowValidationStatus).HasConversion().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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceUploadBatchConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceUploadBatchConfiguration.cs
new file mode 100644
index 0000000..30645a4
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/AttendanceUploadBatchConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().HasMaxLength(20).IsRequired();
+ builder.Property(b => b.Status).HasConversion().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 });
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs
new file mode 100644
index 0000000..660539e
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BranchConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(EntityStatus.Active);
+
+ builder.Property(b => b.CreatedAt).IsRequired();
+ builder.Property(b => b.RowVersion).IsRowVersion();
+
+ builder.HasIndex(b => b.Status);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/DepartmentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/DepartmentConfiguration.cs
new file mode 100644
index 0000000..dc51ed5
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/DepartmentConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/DesignationConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/DesignationConfiguration.cs
new file mode 100644
index 0000000..f5255d8
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/DesignationConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().HasMaxLength(20).IsRequired()
+ .HasDefaultValue(EntityStatus.Active);
+
+ builder.Property(d => d.CreatedAt).IsRequired();
+ builder.Property(d => d.RowVersion).IsRowVersion();
+
+ builder.HasIndex(d => d.Status);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeBankDetailConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeBankDetailConfiguration.cs
new file mode 100644
index 0000000..35af1a9
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeBankDetailConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeConfiguration.cs
new file mode 100644
index 0000000..08daa78
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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(e => e.UserId).OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(e => e.UserId).IsUnique();
+
+ builder.Property(e => e.Status)
+ .HasConversion().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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeDocumentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeDocumentConfiguration.cs
new file mode 100644
index 0000000..b75f6cb
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeDocumentConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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