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().HasMaxLength(20).IsRequired() + .HasDefaultValue(EmployeeDocumentStatus.Active); + + builder.HasOne(d => d.Employee).WithMany() + .HasForeignKey(d => d.EmployeeId).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(d => d.HrDocumentType).WithMany() + .HasForeignKey(d => d.HrDocumentTypeId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(d => d.UploadedAt).IsRequired(); + builder.Property(d => d.RowVersion).IsRowVersion(); + + builder.HasIndex(d => d.EmployeeId); + builder.HasIndex(d => d.ExpiryDate); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeLoanConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeLoanConfiguration.cs new file mode 100644 index 0000000..02dab41 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeLoanConfiguration.cs @@ -0,0 +1,56 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class EmployeeLoanConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employee_loans"); + builder.HasKey(l => l.EmployeeLoanId); + + builder.Property(l => l.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(l => l.DocNo).IsUnique(); + builder.Property(l => l.LoanKind).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(l => l.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.Property(l => l.PrincipalAmount).HasPrecision(18, 2); + builder.Property(l => l.InterestRate).HasPrecision(6, 4); + builder.Property(l => l.InstallmentAmount).HasPrecision(18, 2); + builder.Property(l => l.OutstandingBalance).HasPrecision(18, 2); + + builder.HasOne(l => l.Employee).WithMany() + .HasForeignKey(l => l.EmployeeId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(l => l.Installments).WithOne(i => i.EmployeeLoan!) + .HasForeignKey(i => i.EmployeeLoanId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(l => l.CreatedAt).IsRequired(); + builder.Property(l => l.RowVersion).IsRowVersion(); + + builder.HasIndex(l => l.EmployeeId); + builder.HasIndex(l => l.Status); + } +} + +public sealed class LoanInstallmentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_loan_installments"); + builder.HasKey(i => i.LoanInstallmentId); + + builder.Property(i => i.ScheduledAmount).HasPrecision(18, 2); + builder.Property(i => i.PaidAmount).HasPrecision(18, 2); + builder.Property(i => i.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(i => i.PayrollRun).WithMany() + .HasForeignKey(i => i.PayrollRunId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(i => i.RowVersion).IsRowVersion(); + + builder.HasIndex(i => new { i.DueYear, i.DueMonth }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeSalaryStructureConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeSalaryStructureConfiguration.cs new file mode 100644 index 0000000..44aef27 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmployeeSalaryStructureConfiguration.cs @@ -0,0 +1,43 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class EmployeeSalaryStructureConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employee_salary_structures"); + builder.HasKey(s => s.EmployeeSalaryStructureId); + + builder.Property(s => s.BasicSalary).HasPrecision(18, 2); + builder.Property(s => s.Currency).IsRequired().HasMaxLength(3); + builder.Property(s => s.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(s => s.Employee).WithMany() + .HasForeignKey(s => s.EmployeeId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(s => s.Lines).WithOne(l => l.EmployeeSalaryStructure!) + .HasForeignKey(l => l.EmployeeSalaryStructureId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(s => s.CreatedAt).IsRequired(); + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasIndex(s => new { s.EmployeeId, s.EffectiveTo }); + } +} + +public sealed class EmployeeSalaryStructureLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employee_salary_structure_lines"); + builder.HasKey(l => l.EmployeeSalaryStructureLineId); + + builder.Property(l => l.Amount).HasPrecision(18, 2); + + builder.HasOne(l => l.SalaryComponent).WithMany() + .HasForeignKey(l => l.SalaryComponentId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/EmploymentTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/EmploymentTypeConfiguration.cs new file mode 100644 index 0000000..bd8eaef --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/EmploymentTypeConfiguration.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 EmploymentTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_employment_types"); + builder.HasKey(e => e.EmploymentTypeId); + + builder.Property(e => e.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(e => e.Code).IsUnique(); + builder.Property(e => e.Name).IsRequired().HasMaxLength(200); + + builder.Property(e => e.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(e => e.CreatedAt).IsRequired(); + builder.Property(e => e.RowVersion).IsRowVersion(); + + builder.HasIndex(e => e.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/HrDocumentTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/HrDocumentTypeConfiguration.cs new file mode 100644 index 0000000..935afd4 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/HrDocumentTypeConfiguration.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 HrDocumentTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_document_types"); + builder.HasKey(t => t.HrDocumentTypeId); + + builder.Property(t => t.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(t => t.Code).IsUnique(); + builder.Property(t => t.Name).IsRequired().HasMaxLength(200); + builder.Property(t => t.Category).HasConversion().HasMaxLength(20).IsRequired(); + + builder.Property(t => t.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(t => t.CreatedAt).IsRequired(); + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasIndex(t => t.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs index dab0f1f..53feb18 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs @@ -19,6 +19,9 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration builder.Property(i => i.Description).HasMaxLength(1000); builder.Property(i => i.TaxClass).HasMaxLength(20); + // Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value). + builder.Property(i => i.SalePrice).HasPrecision(18, 4); + builder.Property(i => i.StockNature) .HasConversion().HasMaxLength(20).IsRequired(); builder.Property(i => i.TrackingMode) diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/LeaveBalanceConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveBalanceConfiguration.cs new file mode 100644 index 0000000..093ff09 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveBalanceConfiguration.cs @@ -0,0 +1,28 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class LeaveBalanceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_leave_balances"); + builder.HasKey(b => b.LeaveBalanceId); + + builder.Property(b => b.EntitledDays).HasPrecision(6, 2); + builder.Property(b => b.TakenDays).HasPrecision(6, 2); + builder.Property(b => b.CarriedForwardDays).HasPrecision(6, 2); + builder.Property(b => b.AdjustmentDays).HasPrecision(6, 2); + + builder.HasOne(b => b.Employee).WithMany() + .HasForeignKey(b => b.EmployeeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(b => b.LeaveType).WithMany() + .HasForeignKey(b => b.LeaveTypeId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(b => b.RowVersion).IsRowVersion(); + + builder.HasIndex(b => new { b.EmployeeId, b.LeaveTypeId, b.Year }).IsUnique(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/LeaveRequestConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveRequestConfiguration.cs new file mode 100644 index 0000000..fe370c3 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveRequestConfiguration.cs @@ -0,0 +1,35 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class LeaveRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_leave_requests"); + builder.HasKey(r => r.LeaveRequestId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + builder.Property(r => r.DaysCount).HasPrecision(6, 2); + builder.Property(r => r.Reason).HasMaxLength(1000); + builder.Property(r => r.RejectionReason).HasMaxLength(1000); + + builder.Property(r => r.Status).HasConversion().HasMaxLength(20).IsRequired(); + + builder.HasOne(r => r.Employee).WithMany() + .HasForeignKey(r => r.EmployeeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.LeaveType).WithMany() + .HasForeignKey(r => r.LeaveTypeId).OnDelete(DeleteBehavior.Restrict); + + builder.Property(r => r.CreatedAt).IsRequired(); + builder.Property(r => r.RowVersion).IsRowVersion(); + + builder.HasIndex(r => r.EmployeeId); + builder.HasIndex(r => r.Status); + builder.HasIndex(r => new { r.StartDate, r.EndDate }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/LeaveTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveTypeConfiguration.cs new file mode 100644 index 0000000..dec12cb --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/LeaveTypeConfiguration.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 LeaveTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_leave_types"); + builder.HasKey(t => t.LeaveTypeId); + + builder.Property(t => t.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(t => t.Code).IsUnique(); + builder.Property(t => t.Name).IsRequired().HasMaxLength(200); + builder.Property(t => t.AccrualPerYear).HasPrecision(6, 2); + + builder.Property(t => t.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(t => t.CreatedAt).IsRequired(); + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasIndex(t => t.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PayrollLineConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollLineConfiguration.cs new file mode 100644 index 0000000..56b62ee --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollLineConfiguration.cs @@ -0,0 +1,52 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PayrollLineConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payroll_lines"); + builder.HasKey(l => l.PayrollLineId); + + foreach (var money in new[] + { + nameof(PayrollLine.BasicSalary), nameof(PayrollLine.TotalAllowances), nameof(PayrollLine.OvertimeAmount), + nameof(PayrollLine.GrossSalary), nameof(PayrollLine.LateDeductionAmount), nameof(PayrollLine.NoPayAmount), + nameof(PayrollLine.LoanDeductionAmount), nameof(PayrollLine.EpfEmployeeAmount), nameof(PayrollLine.EpfEmployerAmount), + nameof(PayrollLine.EtfEmployerAmount), nameof(PayrollLine.TaxAmount), nameof(PayrollLine.OtherDeductionsAmount), + nameof(PayrollLine.NetSalary) + }) + { + builder.Property(money).HasColumnType("numeric(18,2)"); + } + + builder.HasOne(l => l.Employee).WithMany() + .HasForeignKey(l => l.EmployeeId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(l => l.Components).WithOne(c => c.PayrollLine!) + .HasForeignKey(c => c.PayrollLineId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(l => l.RowVersion).IsRowVersion(); + + builder.HasIndex(l => new { l.PayrollRunId, l.EmployeeId }).IsUnique(); + } +} + +public sealed class PayrollLineComponentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payroll_line_components"); + builder.HasKey(c => c.PayrollLineComponentId); + + builder.Property(c => c.ComponentCategory).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(c => c.Label).IsRequired().HasMaxLength(200); + builder.Property(c => c.Amount).HasPrecision(18, 2); + + builder.HasOne(c => c.SalaryComponent).WithMany() + .HasForeignKey(c => c.SalaryComponentId).OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PayrollRunConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollRunConfiguration.cs new file mode 100644 index 0000000..4edc274 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollRunConfiguration.cs @@ -0,0 +1,31 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PayrollRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payroll_runs"); + builder.HasKey(r => r.PayrollRunId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + builder.Property(r => r.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(r => r.UnlockReason).HasMaxLength(500); + + builder.HasOne(r => r.Branch).WithMany() + .HasForeignKey(r => r.BranchId).OnDelete(DeleteBehavior.Restrict); + + builder.HasMany(r => r.Lines).WithOne(l => l.PayrollRun!) + .HasForeignKey(l => l.PayrollRunId).OnDelete(DeleteBehavior.Cascade); + + builder.Property(r => r.GeneratedAt).IsRequired(); + builder.Property(r => r.RowVersion).IsRowVersion(); + + builder.HasIndex(r => new { r.PeriodYear, r.PeriodMonth, r.BranchId }); + builder.HasIndex(r => r.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PayrollStatutorySettingConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollStatutorySettingConfiguration.cs new file mode 100644 index 0000000..37da71e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PayrollStatutorySettingConfiguration.cs @@ -0,0 +1,42 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PayrollStatutorySettingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payroll_statutory_settings"); + builder.HasKey(s => s.PayrollStatutorySettingId); + + builder.Property(s => s.EpfEmployeeRate).HasPrecision(6, 4); + builder.Property(s => s.EpfEmployerRate).HasPrecision(6, 4); + builder.Property(s => s.EtfEmployerRate).HasPrecision(6, 4); + builder.Property(s => s.OtMultiplierDefault).HasPrecision(6, 2); + + builder.Property(s => s.CreatedAt).IsRequired(); + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasIndex(s => s.EffectiveFrom); + } +} + +public sealed class TaxSlabConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_tax_slabs"); + builder.HasKey(s => s.TaxSlabId); + + builder.Property(s => s.LowerBound).HasPrecision(18, 2); + builder.Property(s => s.UpperBound).HasPrecision(18, 2); + builder.Property(s => s.Rate).HasPrecision(6, 4); + + builder.Property(s => s.CreatedAt).IsRequired(); + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasIndex(s => s.EffectiveFrom); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PayslipConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PayslipConfiguration.cs new file mode 100644 index 0000000..2e45622 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/PayslipConfiguration.cs @@ -0,0 +1,20 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +public sealed class PayslipConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_payslips"); + builder.HasKey(p => p.PayslipId); + + builder.HasOne(p => p.PayrollLine).WithOne() + .HasForeignKey(p => p.PayrollLineId).OnDelete(DeleteBehavior.Cascade); + builder.HasIndex(p => p.PayrollLineId).IsUnique(); + + builder.Property(p => p.GeneratedAt).IsRequired(); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs new file mode 100644 index 0000000..ca46d0f --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductionConfiguration.cs @@ -0,0 +1,277 @@ +using ERPCore.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ERPCore.Infra.Persistence.Configurations; + +// Manufacturing / Production Lines (docs/30 Part C). All eleven tables live in this one +// file, following the StockConfiguration.cs precedent of grouping an aggregate's +// configurations together. +// +// Cascade shape (deliberate): the two aggregate roots cascade to their own children — +// template → stages/edges → inputs/outputs, run → stages/edges/events → inputs/outputs. +// Every *cross* reference is Restrict, because making them cascade would give EF two +// delete paths to the same table and the model validator rejects that. The consequence +// is an ordering rule for the service layer: when replacing a graph, delete edges before +// stages and inputs before outputs. + +public sealed class ProductionTemplateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("production_templates"); + builder.HasKey(t => t.TemplateId); + + builder.Property(t => t.Code).IsRequired().HasMaxLength(30); + builder.HasIndex(t => t.Code).IsUnique(); + + builder.Property(t => t.Name).IsRequired().HasMaxLength(150); + builder.Property(t => t.Description).HasMaxLength(500); + + builder.Property(t => t.Status).HasConversion().HasMaxLength(20).IsRequired(); + + // Same jsonb-as-string mapping as TemplateStage.FieldDefs and AuditLog.ChangeSet: a + // typed/owned mapping would make AuditScribe emit audit rows for the nested entries. + builder.Property(t => t.Annotations).HasColumnType("jsonb"); + + builder.Property(t => t.CreatedAt).IsRequired(); + + // PostgreSQL xmin system column as the optimistic concurrency token (ETag). + builder.Property(t => t.RowVersion).IsRowVersion(); + + builder.HasOne(t => t.Creator).WithMany().HasForeignKey(t => t.CreatedBy).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(t => t.Status); + } +} + +public sealed class TemplateStageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("template_stages"); + builder.HasKey(s => s.StageId); + + builder.Property(s => s.Name).IsRequired().HasMaxLength(150); + builder.Property(s => s.RoleLabel).HasMaxLength(60); + builder.Property(s => s.PosX).HasPrecision(18, 4); + builder.Property(s => s.PosY).HasPrecision(18, 4); + builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb"); + + builder.HasOne(s => s.Template).WithMany(t => t.Stages) + .HasForeignKey(s => s.TemplateId).OnDelete(DeleteBehavior.Cascade); + } +} + +public sealed class StageEdgeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stage_edges"); + builder.HasKey(e => e.EdgeId); + + builder.HasOne(e => e.Template).WithMany(t => t.Edges) + .HasForeignKey(e => e.TemplateId).OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.ParentStage).WithMany() + .HasForeignKey(e => e.ParentStageId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(e => e.ChildStage).WithMany() + .HasForeignKey(e => e.ChildStageId).OnDelete(DeleteBehavior.Restrict); + + // One arrow per ordered pair; self-loops and cycles are rejected by the validator. + builder.HasIndex(e => new { e.ParentStageId, e.ChildStageId }).IsUnique(); + } +} + +public sealed class StageInputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stage_inputs"); + builder.HasKey(i => i.InputId); + + builder.Property(i => i.Source).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(i => i.QtyPerBatch).HasPrecision(18, 4); + + builder.HasOne(i => i.Stage).WithMany(s => s.Inputs) + .HasForeignKey(i => i.StageId).OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(i => i.FromOutput).WithMany() + .HasForeignKey(i => i.FromOutputId).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class StageOutputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stage_outputs"); + builder.HasKey(o => o.OutputId); + + builder.Property(o => o.Name).IsRequired().HasMaxLength(150); + builder.Property(o => o.QtyPerBatch).HasPrecision(18, 4); + + builder.HasOne(o => o.Stage).WithMany(s => s.Outputs) + .HasForeignKey(o => o.StageId).OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class ProductionRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("production_runs"); + builder.HasKey(r => r.RunId); + + builder.Property(r => r.DocNo).IsRequired().HasMaxLength(30); + builder.HasIndex(r => r.DocNo).IsUnique(); + + builder.Property(r => r.TargetQty).HasPrecision(18, 4); + builder.Property(r => r.ScaleFactor).HasPrecision(18, 6); + builder.Property(r => r.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(r => r.CreatedAt).IsRequired(); + builder.Property(r => r.RowVersion).IsRowVersion(); + + builder.HasOne(r => r.Template).WithMany(t => t.Runs) + .HasForeignKey(r => r.TemplateId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.Warehouse).WithMany().HasForeignKey(r => r.WarehouseId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.OutputBin).WithMany().HasForeignKey(r => r.OutputBinId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.CancelReason).WithMany().HasForeignKey(r => r.CancelReasonCodeId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(r => r.Creator).WithMany().HasForeignKey(r => r.CreatedBy).OnDelete(DeleteBehavior.Restrict); + + // Run board filters (docs/30 §D.2) and the template edit-lock count (FR-MFG-06). + builder.HasIndex(r => r.Status); + builder.HasIndex(r => new { r.TemplateId, r.Status }); + } +} + +public sealed class RunStageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("run_stages"); + builder.HasKey(s => s.RunStageId); + + builder.Property(s => s.Name).IsRequired().HasMaxLength(150); + builder.Property(s => s.RoleLabel).HasMaxLength(60); + builder.Property(s => s.PosX).HasPrecision(18, 4); + builder.Property(s => s.PosY).HasPrecision(18, 4); + builder.Property(s => s.Status).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(s => s.FieldDefs).IsRequired().HasColumnType("jsonb"); + builder.Property(s => s.FieldValues).HasColumnType("jsonb"); + builder.Property(s => s.RowVersion).IsRowVersion(); + + builder.HasOne(s => s.Run).WithMany(r => r.Stages) + .HasForeignKey(s => s.RunId).OnDelete(DeleteBehavior.Cascade); + + // SetNull, not Restrict: a template edit may delete a stage that completed or + // cancelled runs still point at. Everything needed to render such a run is copied + // onto this row, so losing the provenance link is the intended trade (FR-MFG-06). + builder.HasOne(s => s.TemplateStage).WithMany() + .HasForeignKey(s => s.TemplateStageId).OnDelete(DeleteBehavior.SetNull); + + builder.HasIndex(s => new { s.RunId, s.Status }); + } +} + +public sealed class RunEdgeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("run_edges"); + builder.HasKey(e => e.RunEdgeId); + + builder.HasOne(e => e.Run).WithMany(r => r.Edges) + .HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(e => e.ParentRunStage).WithMany() + .HasForeignKey(e => e.ParentRunStageId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(e => e.ChildRunStage).WithMany() + .HasForeignKey(e => e.ChildRunStageId).OnDelete(DeleteBehavior.Restrict); + + builder.HasIndex(e => new { e.ParentRunStageId, e.ChildRunStageId }).IsUnique(); + } +} + +public sealed class RunStageInputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("run_stage_inputs"); + builder.HasKey(i => i.RunInputId); + + builder.Property(i => i.Source).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(i => i.PlannedQty).HasPrecision(18, 4); + builder.Property(i => i.ConsumedQty).HasPrecision(18, 4); + builder.Property(i => i.ConsumedValue).HasPrecision(18, 4); + builder.Property(i => i.DeliveredQty).HasPrecision(18, 4); + builder.Property(i => i.ReturnedQty).HasPrecision(18, 4); + builder.Property(i => i.ReturnedValue).HasPrecision(18, 4); + + builder.HasOne(i => i.RunStage).WithMany(s => s.Inputs) + .HasForeignKey(i => i.RunStageId).OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(i => i.Item).WithMany().HasForeignKey(i => i.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(i => i.Uom).WithMany().HasForeignKey(i => i.UomId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(i => i.FromRunOutput).WithMany() + .HasForeignKey(i => i.FromRunOutputId).OnDelete(DeleteBehavior.Restrict); + + // Transfers route by the source output, so this is the hot lookup. + builder.HasIndex(i => i.FromRunOutputId); + } +} + +public sealed class RunStageOutputConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("run_stage_outputs"); + builder.HasKey(o => o.RunOutputId); + + builder.Property(o => o.Name).IsRequired().HasMaxLength(150); + builder.Property(o => o.PlannedQty).HasPrecision(18, 4); + builder.Property(o => o.ProducedQty).HasPrecision(18, 4); + builder.Property(o => o.ScrappedQty).HasPrecision(18, 4); + builder.Property(o => o.TransferredQty).HasPrecision(18, 4); + + builder.HasOne(o => o.RunStage).WithMany(s => s.Outputs) + .HasForeignKey(o => o.RunStageId).OnDelete(DeleteBehavior.Cascade); + + builder.HasOne(o => o.Item).WithMany().HasForeignKey(o => o.ItemId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(o => o.Uom).WithMany().HasForeignKey(o => o.UomId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(o => o.ScrapReason).WithMany().HasForeignKey(o => o.ScrapReasonCodeId).OnDelete(DeleteBehavior.Restrict); + } +} + +public sealed class RunStageEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + // Append-only history: the app never updates or deletes these rows. + builder.ToTable("run_stage_events"); + builder.HasKey(e => e.EventId); + + builder.Property(e => e.EventType).HasConversion().HasMaxLength(20).IsRequired(); + builder.Property(e => e.Note).HasMaxLength(500); + builder.Property(e => e.Payload).HasColumnType("jsonb"); + builder.Property(e => e.CreatedAt).IsRequired(); + + builder.HasOne(e => e.Run).WithMany(r => r.Events) + .HasForeignKey(e => e.RunId).OnDelete(DeleteBehavior.Cascade); + + // SetNull rather than Cascade: the run-level cascade above already removes these + // rows, and a second cascade path (run → stage → event) is what EF rejects. + builder.HasOne(e => e.RunStage).WithMany(s => s.Events) + .HasForeignKey(e => e.RunStageId).OnDelete(DeleteBehavior.SetNull); + + builder.HasOne(e => e.User).WithMany().HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Restrict); + + // The run-detail timeline reads the whole run's history in one ordered pass. + builder.HasIndex(e => new { e.RunId, e.EventId }); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SalaryComponentConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SalaryComponentConfiguration.cs new file mode 100644 index 0000000..b67800c --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/SalaryComponentConfiguration.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 SalaryComponentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_salary_components"); + builder.HasKey(c => c.SalaryComponentId); + + builder.Property(c => c.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(c => c.Code).IsUnique(); + builder.Property(c => c.Name).IsRequired().HasMaxLength(200); + builder.Property(c => c.ComponentType).HasConversion().HasMaxLength(20).IsRequired(); + + builder.Property(c => c.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(c => c.CreatedAt).IsRequired(); + builder.Property(c => c.RowVersion).IsRowVersion(); + + builder.HasIndex(c => c.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs index 685a9aa..ada9c26 100644 --- a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs +++ b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs @@ -23,6 +23,12 @@ public sealed class UserConfiguration : IEntityTypeConfiguration builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id"); builder.HasIndex(u => u.AuthUserId).IsUnique(); + // Employee<->User cross-link match field (docs/12-BACKEND-HRM.md A.5). Unique, + // like AuthUserId — Postgres allows multiple NULLs in a unique index natively, + // so employees/users without an email don't collide. + builder.Property(u => u.Email).HasMaxLength(320); + builder.HasIndex(u => u.Email).IsUnique(); + // Local shadow Role assignment (nullable — unset until an admin assigns one). builder.HasOne(u => u.Role).WithMany() .HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict); diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/WorkShiftConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/WorkShiftConfiguration.cs new file mode 100644 index 0000000..8c8e68e --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Configurations/WorkShiftConfiguration.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 WorkShiftConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("hr_work_shifts"); + builder.HasKey(w => w.WorkShiftId); + + builder.Property(w => w.Code).IsRequired().HasMaxLength(20); + builder.HasIndex(w => w.Code).IsUnique(); + builder.Property(w => w.Name).IsRequired().HasMaxLength(200); + builder.Property(w => w.OtMultiplier).HasPrecision(6, 2); + + builder.Property(w => w.Status) + .HasConversion().HasMaxLength(20).IsRequired() + .HasDefaultValue(EntityStatus.Active); + + builder.Property(w => w.CreatedAt).IsRequired(); + builder.Property(w => w.RowVersion).IsRowVersion(); + + builder.HasIndex(w => w.Status); + } +} diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs index 79a2352..8972da9 100644 --- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -30,6 +30,12 @@ public static class DataSeeder ("WRONG", "Wrong Item", ReasonContext.Return), ("OVER", "Over-supply", ReasonContext.Return), ("QREJ", "Quality Reject", ReasonContext.Return), + // Manufacturing (docs/30 §A.2) — scrap at stage complete, leftover return before + // receipt, and the mandatory reason on a run cancel. + ("PRD-SCRAP", "Production Scrap", ReasonContext.Production), + ("PRD-LEFTOVER", "Production Leftover Return", ReasonContext.Production), + ("PRD-CANCEL", "Production Run Cancelled", ReasonContext.Production), + ("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production), ]; public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default) diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs index dfcdc6c..ca895cd 100644 --- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs +++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs @@ -89,6 +89,58 @@ public class ErpDbContext : DbContext public DbSet AuditLogs => Set(); public DbSet JournalEntryStubs => Set(); + // --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) --- + public DbSet Branches => Set(); + public DbSet Departments => Set(); + public DbSet Designations => Set(); + public DbSet EmploymentTypes => Set(); + public DbSet WorkShifts => Set(); + + // --- HRM: employee core (docs/12-BACKEND-HRM.md Part C.2) --- + public DbSet Employees => Set(); + public DbSet EmployeeBankDetails => Set(); + + // --- HRM: staff documents (docs/12-BACKEND-HRM.md Part C.3) --- + public DbSet HrDocumentTypes => Set(); + public DbSet EmployeeDocuments => Set(); + + // --- HRM: attendance (docs/12-BACKEND-HRM.md Part C.4) --- + public DbSet AttendanceUploadBatches => Set(); + public DbSet AttendanceRecords => Set(); + + // --- HRM: leave (docs/12-BACKEND-HRM.md Part C.5) --- + public DbSet LeaveTypes => Set(); + public DbSet LeaveRequests => Set(); + public DbSet LeaveBalances => Set(); + + // --- HRM: payroll (docs/12-BACKEND-HRM.md Part C.6) --- + public DbSet SalaryComponents => Set(); + public DbSet EmployeeSalaryStructures => Set(); + public DbSet EmployeeSalaryStructureLines => Set(); + public DbSet EmployeeLoans => Set(); + public DbSet LoanInstallments => Set(); + public DbSet PayrollStatutorySettings => Set(); + public DbSet TaxSlabs => Set(); + public DbSet PayrollRuns => Set(); + public DbSet PayrollLines => Set(); + public DbSet PayrollLineComponents => Set(); + public DbSet Payslips => Set(); + + // --- Manufacturing: production templates (docs/30-BACKEND-PHASE2.md Part C) --- + public DbSet ProductionTemplates => Set(); + public DbSet TemplateStages => Set(); + public DbSet StageEdges => Set(); + public DbSet StageInputs => Set(); + public DbSet StageOutputs => Set(); + + // --- Manufacturing: production runs (docs/30-BACKEND-PHASE2.md Part C) --- + public DbSet ProductionRuns => Set(); + public DbSet RunStages => Set(); + public DbSet RunEdges => Set(); + public DbSet RunStageInputs => Set(); + public DbSet RunStageOutputs => Set(); + public DbSet RunStageEvents => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -96,6 +148,29 @@ public class ErpDbContext : DbContext // Pick up every IEntityTypeConfiguration in this assembly // (Infra/Persistence/Configurations/*). modelBuilder.ApplyConfigurationsFromAssembly(typeof(ErpDbContext).Assembly); + + // Npgsql requires DateTime values written to `timestamp with time zone` columns + // to have Kind=Utc; dates deserialized from a JSON request body (hire date, + // salary-structure effective date, etc.) come in as Kind=Unspecified and would + // otherwise throw at SaveChanges time. Force Utc kind globally for every + // DateTime/DateTime? property rather than remembering to convert at each HRM + // service call site (docs/12-BACKEND-HRM.md — new in Phase 2; Phase 1 never hit + // this because it only ever persisted server-generated DateTime.UtcNow values). + var utcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( + v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + var nullableUtcConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter( + v => v.HasValue ? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)) : v, + v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (property.ClrType == typeof(DateTime)) property.SetValueConverter(utcConverter); + else if (property.ClrType == typeof(DateTime?)) property.SetValueConverter(nullableUtcConverter); + } + } } // Audit trail (FR-X-02): capture mutations before save (accurate old→new), then diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index fa5a7fb..c484971 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -22,6 +22,159 @@ namespace ERPCore.Infra.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => + { + b.Property("AttendanceRecordId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceRecordId")); + + b.Property("AttendanceDate") + .HasColumnType("timestamp with time zone"); + + b.Property("AttendanceStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("AttendanceUploadBatchId") + .HasColumnType("integer"); + + b.Property("CheckIn") + .HasColumnType("interval"); + + b.Property("CheckOut") + .HasColumnType("interval"); + + b.Property("DuplicateOfAttendanceRecordId") + .HasColumnType("integer"); + + b.Property("EarlyLeaveMinutes") + .HasColumnType("integer"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EditedBy") + .HasColumnType("integer"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("IsManualOverride") + .HasColumnType("boolean"); + + b.Property("LateMinutes") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OvertimeMinutes") + .HasColumnType("integer"); + + b.Property("RowValidationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("WorkShiftId") + .HasColumnType("integer"); + + b.Property("WorkingMinutes") + .HasColumnType("integer"); + + b.HasKey("AttendanceRecordId"); + + b.HasIndex("AttendanceUploadBatchId"); + + b.HasIndex("WorkShiftId"); + + b.HasIndex("EmployeeId", "AttendanceDate"); + + b.ToTable("hr_attendance_records", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceUploadBatch", b => + { + b.Property("AttendanceUploadBatchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AttendanceUploadBatchId")); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConfirmedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OriginalFileName") + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone"); + + b.Property("RowCountDuplicate") + .HasColumnType("integer"); + + b.Property("RowCountError") + .HasColumnType("integer"); + + b.Property("RowCountTotal") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SourceType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadedBy") + .HasColumnType("integer"); + + b.HasKey("AttendanceUploadBatchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("PeriodStart", "PeriodEnd"); + + b.ToTable("hr_attendance_upload_batches", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => { b.Property("AuditId") @@ -119,6 +272,57 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("bins", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Branch", b => + { + b.Property("BranchId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BranchId")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_branches", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b => { b.Property("BrandId") @@ -203,6 +407,644 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("categories", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => + { + b.Property("DepartmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DepartmentId")); + + b.Property("BranchId") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HeadEmployeeId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentDepartmentId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("DepartmentId"); + + b.HasIndex("BranchId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("HeadEmployeeId"); + + b.HasIndex("ParentDepartmentId"); + + b.HasIndex("Status"); + + b.ToTable("hr_departments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Designation", b => + { + b.Property("DesignationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DesignationId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("DesignationId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_designations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => + { + b.Property("EmployeeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeId")); + + b.Property("AddressLine1") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AddressLine2") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BranchId") + .HasColumnType("integer"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ConfirmationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DateOfBirth") + .HasColumnType("timestamp with time zone"); + + b.Property("DepartmentId") + .HasColumnType("integer"); + + b.Property("DesignationId") + .HasColumnType("integer"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EmergencyContactRelationship") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmployeeCode") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EmploymentTypeId") + .HasColumnType("integer"); + + b.Property("EpfNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EtfNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Gender") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("HireDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastWorkingDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Nic") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PersonalMobile") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PostalCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfilePhotoPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReportingManagerId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("TaxIdentificationNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("WorkShiftId") + .HasColumnType("integer"); + + b.HasKey("EmployeeId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("DesignationId"); + + b.HasIndex("Email"); + + b.HasIndex("EmployeeCode") + .IsUnique(); + + b.HasIndex("EmploymentTypeId"); + + b.HasIndex("ReportingManagerId"); + + b.HasIndex("Status"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("WorkShiftId"); + + b.ToTable("hr_employees", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.Property("EmployeeBankDetailId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeBankDetailId")); + + b.Property("AccountHolderName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BranchName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("SwiftCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EmployeeBankDetailId"); + + b.HasIndex("EmployeeId"); + + b.ToTable("hr_employee_bank_details", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.Property("EmployeeDocumentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeDocumentId")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("ExpiryDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HrDocumentTypeId") + .HasColumnType("integer"); + + b.Property("IssueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("character varying(260)"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UploadedBy") + .HasColumnType("integer"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VerifiedBy") + .HasColumnType("integer"); + + b.HasKey("EmployeeDocumentId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("ExpiryDate"); + + b.HasIndex("HrDocumentTypeId"); + + b.ToTable("hr_employee_documents", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Property("EmployeeLoanId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeLoanId")); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("InstallmentAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("LoanKind") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("NumberOfInstallments") + .HasColumnType("integer"); + + b.Property("OutstandingBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("StartMonth") + .HasColumnType("integer"); + + b.Property("StartYear") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("EmployeeLoanId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("Status"); + + b.ToTable("hr_employee_loans", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Property("EmployeeSalaryStructureId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeSalaryStructureId")); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("integer"); + + b.Property("BasicSalary") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("EmployeeSalaryStructureId"); + + b.HasIndex("EmployeeId", "EffectiveTo"); + + b.ToTable("hr_employee_salary_structures", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.Property("EmployeeSalaryStructureLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmployeeSalaryStructureLineId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("EmployeeSalaryStructureId") + .HasColumnType("integer"); + + b.Property("SalaryComponentId") + .HasColumnType("integer"); + + b.HasKey("EmployeeSalaryStructureLineId"); + + b.HasIndex("EmployeeSalaryStructureId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_employee_salary_structure_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmploymentType", b => + { + b.Property("EmploymentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EmploymentTypeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EmploymentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_employment_types", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.Property("GrnId") @@ -347,6 +1189,64 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("grn_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.HrDocumentType", b => + { + b.Property("HrDocumentTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("HrDocumentTypeId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiryTracked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequiredAtOnboarding") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("HrDocumentTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_document_types", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => { b.Property("ItemId") @@ -385,6 +1285,10 @@ namespace ERPCore.Infra.Persistence.Migrations .HasColumnType("xid") .HasColumnName("xmin"); + b.Property("SalePrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + b.Property("Sku") .IsRequired() .HasMaxLength(50) @@ -548,6 +1452,253 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("journal_entry_stubs", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.Property("LeaveBalanceId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveBalanceId")); + + b.Property("AdjustmentDays") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("CarriedForwardDays") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("EntitledDays") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("LeaveTypeId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("TakenDays") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("LeaveBalanceId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("EmployeeId", "LeaveTypeId", "Year") + .IsUnique(); + + b.ToTable("hr_leave_balances", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.Property("LeaveRequestId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveRequestId")); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DaysCount") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LeaveTypeId") + .HasColumnType("integer"); + + b.Property("Reason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("LeaveRequestId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LeaveTypeId"); + + b.HasIndex("Status"); + + b.HasIndex("StartDate", "EndDate"); + + b.ToTable("hr_leave_requests", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveType", b => + { + b.Property("LeaveTypeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LeaveTypeId")); + + b.Property("AccrualPerYear") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("CarryForwardAllowed") + .HasColumnType("boolean"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CountsAsNoPay") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.Property("MaxCarryForwardDays") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequiresApproval") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("LeaveTypeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_leave_types", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.Property("LoanInstallmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LoanInstallmentId")); + + b.Property("DueMonth") + .HasColumnType("integer"); + + b.Property("DueYear") + .HasColumnType("integer"); + + b.Property("EmployeeLoanId") + .HasColumnType("integer"); + + b.Property("InstallmentNumber") + .HasColumnType("integer"); + + b.Property("PaidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ScheduledAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("LoanInstallmentId"); + + b.HasIndex("EmployeeLoanId"); + + b.HasIndex("PayrollRunId"); + + b.HasIndex("DueYear", "DueMonth"); + + b.ToTable("hr_loan_installments", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => { b.Property("NavItemId") @@ -714,6 +1865,285 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("number_sequences", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Property("PayrollLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollLineId")); + + b.Property("AbsentDays") + .HasColumnType("integer"); + + b.Property("BasicSalary") + .HasColumnType("numeric(18,2)"); + + b.Property("EmployeeId") + .HasColumnType("integer"); + + b.Property("EpfEmployeeAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("EpfEmployerAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("EtfEmployerAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("GrossSalary") + .HasColumnType("numeric(18,2)"); + + b.Property("LateDeductionAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("LateMinutesTotal") + .HasColumnType("integer"); + + b.Property("LeaveDays") + .HasColumnType("integer"); + + b.Property("LoanDeductionAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("NetSalary") + .HasColumnType("numeric(18,2)"); + + b.Property("NoPayAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("OtMinutesTotal") + .HasColumnType("integer"); + + b.Property("OtherDeductionsAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("OvertimeAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("PayrollRunId") + .HasColumnType("integer"); + + b.Property("PresentDays") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("TaxAmount") + .HasColumnType("numeric(18,2)"); + + b.Property("TotalAllowances") + .HasColumnType("numeric(18,2)"); + + b.Property("WorkingDays") + .HasColumnType("integer"); + + b.HasKey("PayrollLineId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("PayrollRunId", "EmployeeId") + .IsUnique(); + + b.ToTable("hr_payroll_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.Property("PayrollLineComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollLineComponentId")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ComponentCategory") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PayrollLineId") + .HasColumnType("integer"); + + b.Property("SalaryComponentId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("PayrollLineComponentId"); + + b.HasIndex("PayrollLineId"); + + b.HasIndex("SalaryComponentId"); + + b.ToTable("hr_payroll_line_components", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Property("PayrollRunId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollRunId")); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ApprovedBy") + .HasColumnType("integer"); + + b.Property("BranchId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GeneratedBy") + .HasColumnType("integer"); + + b.Property("LockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockedBy") + .HasColumnType("integer"); + + b.Property("PeriodMonth") + .HasColumnType("integer"); + + b.Property("PeriodYear") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UnlockReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UnlockedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UnlockedBy") + .HasColumnType("integer"); + + b.HasKey("PayrollRunId"); + + b.HasIndex("BranchId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("PeriodYear", "PeriodMonth", "BranchId"); + + b.ToTable("hr_payroll_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollStatutorySetting", b => + { + b.Property("PayrollStatutorySettingId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayrollStatutorySettingId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone"); + + b.Property("EpfEmployeeRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("EpfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("EtfEmployerRate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("OtMultiplierDefault") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("PayrollStatutorySettingId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_payroll_statutory_settings", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.Property("PayslipId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PayslipId")); + + b.Property("GeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PayrollLineId") + .HasColumnType("integer"); + + b.Property("ReleasedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleasedBy") + .HasColumnType("integer"); + + b.HasKey("PayslipId"); + + b.HasIndex("PayrollLineId") + .IsUnique(); + + b.ToTable("hr_payslips", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => { b.Property("PermissionId") @@ -970,6 +2400,136 @@ namespace ERPCore.Infra.Persistence.Migrations }); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.Property("RunId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunId")); + + b.Property("CancelReasonCodeId") + .HasColumnType("integer"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OutputBinId") + .HasColumnType("integer"); + + b.Property("ReworkCount") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ScaleFactor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TargetQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("RunId"); + + b.HasIndex("CancelReasonCodeId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("OutputBinId"); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("TemplateId", "Status"); + + b.ToTable("production_runs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.Property("TemplateId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TemplateId")); + + b.Property("Annotations") + .HasColumnType("jsonb"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TemplateId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedBy"); + + b.HasIndex("Status"); + + b.ToTable("production_templates", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.Property("PoId") @@ -1339,6 +2899,319 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("role_permissions", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => + { + b.Property("RunEdgeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunEdgeId")); + + b.Property("ChildRunStageId") + .HasColumnType("integer"); + + b.Property("ParentRunStageId") + .HasColumnType("integer"); + + b.Property("RunId") + .HasColumnType("integer"); + + b.HasKey("RunEdgeId"); + + b.HasIndex("ChildRunStageId"); + + b.HasIndex("RunId"); + + b.HasIndex("ParentRunStageId", "ChildRunStageId") + .IsUnique(); + + b.ToTable("run_edges", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.Property("RunStageId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunStageId")); + + b.Property("ActualEndAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActualStartAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer"); + + b.Property("FieldDefs") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("FieldValues") + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("PosX") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("PosY") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RoleLabel") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("RunId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TemplateStageId") + .HasColumnType("integer"); + + b.HasKey("RunStageId"); + + b.HasIndex("TemplateStageId"); + + b.HasIndex("RunId", "Status"); + + b.ToTable("run_stages", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EventId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("RunId") + .HasColumnType("integer"); + + b.Property("RunStageId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("EventId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("UserId"); + + b.HasIndex("RunId", "EventId"); + + b.ToTable("run_stage_events", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => + { + b.Property("RunInputId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunInputId")); + + b.Property("ConsumedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ConsumedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("DeliveredQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("FromRunOutputId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("PlannedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReturnedValue") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunStageId") + .HasColumnType("integer"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("RunInputId"); + + b.HasIndex("FromRunOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("UomId"); + + b.ToTable("run_stage_inputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => + { + b.Property("RunOutputId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RunOutputId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("PlannedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ProducedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunStageId") + .HasColumnType("integer"); + + b.Property("ScrapReasonCodeId") + .HasColumnType("integer"); + + b.Property("ScrappedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("TransferredQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("RunOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RunStageId"); + + b.HasIndex("ScrapReasonCodeId"); + + b.HasIndex("UomId"); + + b.ToTable("run_stage_outputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SalaryComponent", b => + { + b.Property("SalaryComponentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SalaryComponentId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ComponentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEpfEtfApplicable") + .HasColumnType("boolean"); + + b.Property("IsTaxable") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("SalaryComponentId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_salary_components", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { b.Property("SerialId") @@ -1368,6 +3241,114 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("serials", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => + { + b.Property("EdgeId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EdgeId")); + + b.Property("ChildStageId") + .HasColumnType("integer"); + + b.Property("ParentStageId") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.HasKey("EdgeId"); + + b.HasIndex("ChildStageId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("ParentStageId", "ChildStageId") + .IsUnique(); + + b.ToTable("stage_edges", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => + { + b.Property("InputId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("InputId")); + + b.Property("FromOutputId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyPerBatch") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StageId") + .HasColumnType("integer"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("InputId"); + + b.HasIndex("FromOutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("StageId"); + + b.HasIndex("UomId"); + + b.ToTable("stage_inputs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => + { + b.Property("OutputId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("OutputId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("QtyPerBatch") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("StageId") + .HasColumnType("integer"); + + b.Property("UomId") + .HasColumnType("integer"); + + b.HasKey("OutputId"); + + b.HasIndex("ItemId"); + + b.HasIndex("StageId"); + + b.HasIndex("UomId"); + + b.ToTable("stage_outputs", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => { b.Property("AdjustmentId") @@ -2007,6 +3988,90 @@ namespace ERPCore.Infra.Persistence.Migrations }); }); + modelBuilder.Entity("ERPCore.Domain.Entities.TaxSlab", b => + { + b.Property("TaxSlabId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TaxSlabId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone"); + + b.Property("LowerBound") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Rate") + .HasPrecision(6, 4) + .HasColumnType("numeric(6,4)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("UpperBound") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("TaxSlabId"); + + b.HasIndex("EffectiveFrom"); + + b.ToTable("hr_tax_slabs", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.Property("StageId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("StageId")); + + b.Property("EstimatedMinutes") + .HasColumnType("integer"); + + b.Property("FieldDefs") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("PosX") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("PosY") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RoleLabel") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("TemplateId") + .HasColumnType("integer"); + + b.HasKey("StageId"); + + b.HasIndex("TemplateId"); + + b.ToTable("template_stages", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => { b.Property("UomId") @@ -2078,6 +4143,10 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + b.Property("RoleId") .HasColumnType("integer"); @@ -2096,6 +4165,9 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("AuthUserId") .IsUnique(); + b.HasIndex("Email") + .IsUnique(); + b.HasIndex("RoleId"); b.HasIndex("Username") @@ -2258,6 +4330,104 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("warehouses", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.WorkShift", b => + { + b.Property("WorkShiftId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WorkShiftId")); + + b.Property("BreakMinutes") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndTime") + .HasColumnType("interval"); + + b.Property("GraceMinutes") + .HasColumnType("integer"); + + b.Property("IsOvernight") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OtMultiplier") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("StandardWorkingMinutes") + .HasColumnType("integer"); + + b.Property("StartTime") + .HasColumnType("interval"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("WorkingDaysMask") + .HasColumnType("integer"); + + b.HasKey("WorkShiftId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("hr_work_shifts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AttendanceRecord", b => + { + b.HasOne("ERPCore.Domain.Entities.AttendanceUploadBatch", "AttendanceUploadBatch") + .WithMany() + .HasForeignKey("AttendanceUploadBatchId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceUploadBatch"); + + b.Navigation("Employee"); + + b.Navigation("WorkShift"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => { b.HasOne("ERPCore.Domain.Entities.User", null) @@ -2289,6 +4459,157 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Department", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Employee", "HeadEmployee") + .WithMany() + .HasForeignKey("HeadEmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Department", "ParentDepartment") + .WithMany() + .HasForeignKey("ParentDepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + + b.Navigation("HeadEmployee"); + + b.Navigation("ParentDepartment"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Employee", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Department", "Department") + .WithMany() + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Designation", "Designation") + .WithMany() + .HasForeignKey("DesignationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.EmploymentType", "EmploymentType") + .WithMany() + .HasForeignKey("EmploymentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Employee", "ReportingManager") + .WithMany() + .HasForeignKey("ReportingManagerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Employee", "UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.WorkShift", "WorkShift") + .WithMany() + .HasForeignKey("WorkShiftId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Branch"); + + b.Navigation("Department"); + + b.Navigation("Designation"); + + b.Navigation("EmploymentType"); + + b.Navigation("ReportingManager"); + + b.Navigation("User"); + + b.Navigation("WorkShift"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeBankDetail", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeDocument", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.HrDocumentType", "HrDocumentType") + .WithMany() + .HasForeignKey("HrDocumentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("HrDocumentType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructureLine", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeSalaryStructure", "EmployeeSalaryStructure") + .WithMany("Lines") + .HasForeignKey("EmployeeSalaryStructureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EmployeeSalaryStructure"); + + b.Navigation("SalaryComponent"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -2430,6 +4751,120 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveBalance", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LeaveRequest", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.LeaveType", "LeaveType") + .WithMany() + .HasForeignKey("LeaveTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("LeaveType"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.LoanInstallment", b => + { + b.HasOne("ERPCore.Domain.Entities.EmployeeLoan", "EmployeeLoan") + .WithMany("Installments") + .HasForeignKey("EmployeeLoanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany() + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EmployeeLoan"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PayrollRun", "PayrollRun") + .WithMany("Lines") + .HasForeignKey("PayrollRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("PayrollRun"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLineComponent", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithMany("Components") + .HasForeignKey("PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.SalaryComponent", "SalaryComponent") + .WithMany() + .HasForeignKey("SalaryComponentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("PayrollLine"); + + b.Navigation("SalaryComponent"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.HasOne("ERPCore.Domain.Entities.Branch", "Branch") + .WithMany() + .HasForeignKey("BranchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Branch"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Payslip", b => + { + b.HasOne("ERPCore.Domain.Entities.PayrollLine", "PayrollLine") + .WithOne() + .HasForeignKey("ERPCore.Domain.Entities.Payslip", "PayrollLineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PayrollLine"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => { b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") @@ -2492,6 +4927,58 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("UpdatedByUser"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "CancelReason") + .WithMany() + .HasForeignKey("CancelReasonCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Bin", "OutputBin") + .WithMany() + .HasForeignKey("OutputBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Runs") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CancelReason"); + + b.Navigation("Creator"); + + b.Navigation("OutputBin"); + + b.Navigation("Template"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -2658,6 +5145,143 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Role"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.RunEdge", b => + { + b.HasOne("ERPCore.Domain.Entities.RunStage", "ChildRunStage") + .WithMany() + .HasForeignKey("ChildRunStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "ParentRunStage") + .WithMany() + .HasForeignKey("ParentRunStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Edges") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildRunStage"); + + b.Navigation("ParentRunStage"); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Stages") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "TemplateStage") + .WithMany() + .HasForeignKey("TemplateStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Run"); + + b.Navigation("TemplateStage"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageEvent", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionRun", "Run") + .WithMany("Events") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Events") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ERPCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("RunStage"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageInput", b => + { + b.HasOne("ERPCore.Domain.Entities.RunStageOutput", "FromRunOutput") + .WithMany() + .HasForeignKey("FromRunOutputId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Inputs") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromRunOutput"); + + b.Navigation("Item"); + + b.Navigation("RunStage"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RunStageOutput", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.RunStage", "RunStage") + .WithMany("Outputs") + .HasForeignKey("RunStageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ScrapReason") + .WithMany() + .HasForeignKey("ScrapReasonCodeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("RunStage"); + + b.Navigation("ScrapReason"); + + b.Navigation("Uom"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { b.HasOne("ERPCore.Domain.Entities.Item", "Item") @@ -2669,6 +5293,92 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Item"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.StageEdge", b => + { + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ChildStage") + .WithMany() + .HasForeignKey("ChildStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "ParentStage") + .WithMany() + .HasForeignKey("ParentStageId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Edges") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChildStage"); + + b.Navigation("ParentStage"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageInput", b => + { + b.HasOne("ERPCore.Domain.Entities.StageOutput", "FromOutput") + .WithMany() + .HasForeignKey("FromOutputId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") + .WithMany("Inputs") + .HasForeignKey("StageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromOutput"); + + b.Navigation("Item"); + + b.Navigation("Stage"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StageOutput", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.TemplateStage", "Stage") + .WithMany("Outputs") + .HasForeignKey("StageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Stage"); + + b.Navigation("Uom"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => { b.HasOne("ERPCore.Domain.Entities.User", "Creator") @@ -2937,6 +5647,17 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("NavItem"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.HasOne("ERPCore.Domain.Entities.ProductionTemplate", "Template") + .WithMany("Stages") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Template"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") @@ -3017,6 +5738,16 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("SubCategories"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeLoan", b => + { + b.Navigation("Installments"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.EmployeeSalaryStructure", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => { b.Navigation("Lines"); @@ -3034,6 +5765,34 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Children"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollLine", b => + { + b.Navigation("Components"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PayrollRun", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionRun", b => + { + b.Navigation("Edges"); + + b.Navigation("Events"); + + b.Navigation("Stages"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductionTemplate", b => + { + b.Navigation("Edges"); + + b.Navigation("Runs"); + + b.Navigation("Stages"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.Navigation("Lines"); @@ -3056,6 +5815,15 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Quotations"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.RunStage", b => + { + b.Navigation("Events"); + + b.Navigation("Inputs"); + + b.Navigation("Outputs"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => { b.Navigation("Lines"); @@ -3071,6 +5839,13 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Lines"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.TemplateStage", b => + { + b.Navigation("Inputs"); + + b.Navigation("Outputs"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => { b.Navigation("Lines"); diff --git a/Backend/ERPCore/Infra/Storage/IFileStorageService.cs b/Backend/ERPCore/Infra/Storage/IFileStorageService.cs new file mode 100644 index 0000000..f14d386 --- /dev/null +++ b/Backend/ERPCore/Infra/Storage/IFileStorageService.cs @@ -0,0 +1,22 @@ +namespace ERPCore.Infra.Storage; + +/// +/// File storage abstraction (docs/12-BACKEND-HRM.md A.1, C.10) — the first +/// attachment mechanism in this codebase. is +/// the only implementation today; swapping to cloud blob storage later means +/// adding a new implementation + one DI registration change, no controller/service +/// change. Never exposes a path the client can dictate — callers pass only a +/// suggested filename, and get back a server-generated one. +/// +public interface IFileStorageService +{ + /// Saves the stream under a server-generated name; returns the stored name, its relative path, and size. + Task<(string StoredFileName, string RelativePath, long SizeBytes)> SaveAsync( + Stream content, string suggestedFileName, string contentType, CancellationToken ct = default); + + Task OpenReadAsync(string relativePath, CancellationToken ct = default); + + Task DeleteAsync(string relativePath, CancellationToken ct = default); + + Task ExistsAsync(string relativePath, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Infra/Storage/LocalFileStorageService.cs b/Backend/ERPCore/Infra/Storage/LocalFileStorageService.cs new file mode 100644 index 0000000..5b7a44b --- /dev/null +++ b/Backend/ERPCore/Infra/Storage/LocalFileStorageService.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Hosting; + +namespace ERPCore.Infra.Storage; + +/// +/// Disk-backed . Writes under a configured root +/// OUTSIDE wwwroot (FileStorage:RootPath, default App_Data/hr-documents +/// relative to the content root) so files are never reachable via a static-file URL — +/// the only way to read one back is through an authenticated controller action that +/// streams via (docs/12-BACKEND-HRM.md A.1, §4). +/// +public sealed class LocalFileStorageService : IFileStorageService +{ + private readonly string _root; + + public LocalFileStorageService(IHostEnvironment env, IConfiguration configuration) + { + var configuredRoot = configuration["FileStorage:RootPath"] ?? "App_Data/hr-documents"; + _root = Path.IsPathRooted(configuredRoot) ? configuredRoot : Path.Combine(env.ContentRootPath, configuredRoot); + Directory.CreateDirectory(_root); + } + + public async Task<(string StoredFileName, string RelativePath, long SizeBytes)> SaveAsync( + Stream content, string suggestedFileName, string contentType, CancellationToken ct = default) + { + var extension = Path.GetExtension(suggestedFileName); + var storedFileName = $"{Guid.NewGuid():N}{extension}"; + + // Bucket by year/month so a single directory never grows unbounded. + var subDir = Path.Combine(DateTime.UtcNow.Year.ToString(), DateTime.UtcNow.Month.ToString("00")); + var absoluteDir = Path.Combine(_root, subDir); + Directory.CreateDirectory(absoluteDir); + + var relativePath = Path.Combine(subDir, storedFileName).Replace('\\', '/'); + var absolutePath = Path.Combine(_root, relativePath); + + await using (var fileStream = new FileStream(absolutePath, FileMode.CreateNew, FileAccess.Write)) + { + await content.CopyToAsync(fileStream, ct); + } + + var sizeBytes = new FileInfo(absolutePath).Length; + return (storedFileName, relativePath, sizeBytes); + } + + public Task OpenReadAsync(string relativePath, CancellationToken ct = default) + { + var absolutePath = ResolveSafe(relativePath); + Stream stream = new FileStream(absolutePath, FileMode.Open, FileAccess.Read); + return Task.FromResult(stream); + } + + public Task DeleteAsync(string relativePath, CancellationToken ct = default) + { + var absolutePath = ResolveSafe(relativePath); + if (File.Exists(absolutePath)) File.Delete(absolutePath); + return Task.CompletedTask; + } + + public Task ExistsAsync(string relativePath, CancellationToken ct = default) + { + var absolutePath = ResolveSafe(relativePath); + return Task.FromResult(File.Exists(absolutePath)); + } + + /// Resolves a stored relative path and rejects any attempt to escape the storage root. + private string ResolveSafe(string relativePath) + { + var absolutePath = Path.GetFullPath(Path.Combine(_root, relativePath)); + var rootFull = Path.GetFullPath(_root); + if (!absolutePath.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase)) + throw new UnauthorizedAccessException("Resolved path escapes the file storage root."); + return absolutePath; + } +} diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 5fcf63c..1a40914 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -2,12 +2,15 @@ using System.Text.Json.Serialization; using ERPCore.Infra.Auth; using ERPCore.Infra.Auth.AuthHex; using ERPCore.Infra.Persistence; +using ERPCore.Infra.Storage; using ERPCore.Infra.UoW; using ERPCore.Repositories; using ERPCore.Repositories.Interfaces; using ERPCore.Services; using ERPCore.Services.Auth; +using ERPCore.Services.Hrm; using ERPCore.Services.Interfaces; +using ERPCore.Services.Production; using ERPCore.Services.Stock; using ERPCore.System.Errors; using Microsoft.AspNetCore.Authentication; @@ -79,6 +82,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); // Stock core + goods receipt (docs/11 §4–5) +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -95,12 +99,61 @@ builder.Services.AddScoped(); // Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13) builder.Services.AddScoped(); +// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement) +builder.Services.AddScoped(); + +// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HRM: Leave (docs/13-BACKEND-HRM-API.md §5) — LeaveType/LeaveBalance built before +// LeaveRequest since approval increments balances; Attendance depends on LeaveRequest. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HRM: Attendance (docs/13-BACKEND-HRM-API.md §4) +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HRM: Payroll (docs/13-BACKEND-HRM-API.md §6) — masters/settings before the +// calculation service, which composes them; PayrollRunService orchestrates last. +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities +builder.Services.AddScoped(); + +// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md Part A). Templates stand +// alone; runs consume FifoCostingService for all stock movement. ProductionGraphValidator +// is deliberately unregistered — it is a pure static algorithm, not an injected service. +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Health checks (EF Core DB) builder.Services.AddHealthChecks().AddDbContextCheck(); // Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1) builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" })); +builder.Services.AddSwaggerGen(o => +{ + o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" }); + o.CustomSchemaIds(t => t.FullName!.Replace("+", ".")); +}); var app = builder.Build(); diff --git a/Backend/ERPCore/Services/DashboardService.cs b/Backend/ERPCore/Services/DashboardService.cs new file mode 100644 index 0000000..1951559 --- /dev/null +++ b/Backend/ERPCore/Services/DashboardService.cs @@ -0,0 +1,79 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Dashboard; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +/// +/// Aggregate counts pulled straight from each domain's repository — no PagedResponse +/// overhead, since the dashboard only needs totals. Low-stock reuses +/// rather than re-deriving the FIFO-available-vs-reorder-point comparison (docs/11 §5.7). +/// On-hand summary and stock valuation sum (and +/// QtyRemaining × UnitCost for valuation) directly in SQL — cheap, unlike reorder alerts, +/// because they need no per-item live lookup. +/// +public sealed class DashboardService : IDashboardService +{ + private readonly IRepository _layers; + private readonly IRepository _grns; + private readonly IRepository _pos; + private readonly IRepository _requisitions; + private readonly IRepository _counts; + private readonly IRepository _rfqs; + private readonly IReorderService _reorder; + + public DashboardService( + IRepository layers, IRepository grns, IRepository pos, + IRepository requisitions, IRepository counts, IRepository rfqs, + IReorderService reorder) + { + _layers = layers; + _grns = grns; + _pos = pos; + _requisitions = requisitions; + _counts = counts; + _rfqs = rfqs; + _reorder = reorder; + } + + public async Task GetStatsAsync(CancellationToken ct = default) + { + // Sequential, not Task.WhenAll: these repositories share one scoped DbContext, + // which cannot run concurrent operations. + var onHandTotal = await _layers.Query().AsNoTracking().SumAsync(l => (decimal?)l.QtyRemaining, ct) ?? 0m; + var onHandWarehouses = await _layers.Query().AsNoTracking() + .Select(l => l.WarehouseId).Distinct().CountAsync(ct); + var stockValuationTotal = await _layers.Query().AsNoTracking() + .SumAsync(l => (decimal?)(l.QtyRemaining * l.UnitCost), ct) ?? 0m; + // EF can't translate constructing WarehouseValuationDto directly inside the GroupBy + // Select — project to an anonymous type first, then materialize into the record. + var stockValuationByWarehouseRaw = await _layers.Query().AsNoTracking() + .GroupBy(l => l.WarehouseId) + .Select(g => new { WarehouseId = g.Key, Total = g.Sum(x => x.QtyRemaining * x.UnitCost) }) + .OrderByDescending(w => w.Total) + .ToListAsync(ct); + var stockValuationByWarehouse = stockValuationByWarehouseRaw + .Select(w => new WarehouseValuationDto(w.WarehouseId, w.Total)) + .ToList(); + var pendingGrns = await _grns.Query().AsNoTracking().CountAsync(g => g.Status == GrnStatus.Draft, ct); + var pendingApprovalPOs = await _pos.Query().AsNoTracking() + .CountAsync(p => p.Status == PurchaseOrderStatus.PendingApproval, ct); + var openRequisitions = await _requisitions.Query().AsNoTracking() + .CountAsync(r => r.Status == RequisitionStatus.Submitted, ct); + var pendingCounts = await _counts.Query().AsNoTracking() + .CountAsync(c => c.Status == CountStatus.Counted, ct); + var openRfqs = await _rfqs.Query().AsNoTracking().CountAsync(r => r.Status == RfqStatus.Open, ct); + + // PageSize:1 is enough — GetAlertsAsync computes the full alert count before paging. + var lowStockAlerts = (await _reorder.GetAlertsAsync(null, new PageQuery { Page = 1, PageSize = 1 }, ct)) + .Pagination.TotalItems; + + return new DashboardStatsDto( + lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse, + pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs); + } +} diff --git a/Backend/ERPCore/Services/GrnService.cs b/Backend/ERPCore/Services/GrnService.cs index 3da76b1..41b57ad 100644 --- a/Backend/ERPCore/Services/GrnService.cs +++ b/Backend/ERPCore/Services/GrnService.cs @@ -33,9 +33,9 @@ public sealed class GrnService : IGrnService private readonly IRepository _bins; private readonly IRepository _vendors; private readonly IRepository _batches; - private readonly IRepository _conversions; private readonly IRepository _layers; private readonly IRepository _ledger; + private readonly IUomConverter _uomConverter; private readonly IFifoCostingService _fifo; private readonly INumberSequenceService _numbers; private readonly ICurrentUser _currentUser; @@ -45,7 +45,7 @@ public sealed class GrnService : IGrnService IRepository grns, IRepository pos, IRepository poLines, IRepository items, IRepository uoms, IRepository warehouses, IRepository bins, IRepository vendors, IRepository batches, - IRepository conversions, IRepository layers, IRepository ledger, + IRepository layers, IRepository ledger, IUomConverter uomConverter, IFifoCostingService fifo, INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow) { _grns = grns; @@ -57,9 +57,9 @@ public sealed class GrnService : IGrnService _bins = bins; _vendors = vendors; _batches = batches; - _conversions = conversions; _layers = layers; _ledger = ledger; + _uomConverter = uomConverter; _fifo = fifo; _numbers = numbers; _currentUser = currentUser; @@ -345,19 +345,14 @@ public sealed class GrnService : IGrnService return created; // linked via GrnLine.Batch navigation; FK fixed up on SaveChanges } - private async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync( + /// + /// Delegates to the shared . This was a private method here + /// until manufacturing needed the same conversion for stage stock inputs; behaviour is + /// identical, so receive costing is unchanged. + /// + private Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync( Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct) - { - if (uomId == item.BaseUomId) - return (qty, unitCostPerUom); - - var conv = await _conversions.Query().AsNoTracking() - .FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct) - ?? throw new DomainException(ErrorCodes.Validation, - $"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422); - - return (qty * conv.Factor, unitCostPerUom / conv.Factor); - } + => _uomConverter.ToBaseAsync(item, uomId, qty, unitCostPerUom, ct); private async Task UpdatePoStatusAsync(int? poId, CancellationToken ct) { diff --git a/Backend/ERPCore/Services/Hrm/AttendanceComputationService.cs b/Backend/ERPCore/Services/Hrm/AttendanceComputationService.cs new file mode 100644 index 0000000..2f6cfb4 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/AttendanceComputationService.cs @@ -0,0 +1,46 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Services.Interfaces; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class AttendanceComputationService : IAttendanceComputationService +{ + public void Compute(AttendanceRecord record, WorkShift shift, bool hasApprovedLeave, bool isHoliday, bool isWeekOff) + { + if (record.CheckIn is null || record.CheckOut is null) + { + record.WorkingMinutes = 0; + record.LateMinutes = 0; + record.EarlyLeaveMinutes = 0; + record.OvertimeMinutes = 0; + record.AttendanceStatus = isHoliday ? AttendanceStatus.Holiday + : isWeekOff ? AttendanceStatus.WeekOff + : hasApprovedLeave ? AttendanceStatus.OnLeave + : AttendanceStatus.Absent; + return; + } + + var checkIn = record.CheckIn.Value; + var checkOut = record.CheckOut.Value; + // Overnight shift: checkout numerically earlier than checkin means it rolled past midnight. + if (shift.IsOvernight && checkOut < checkIn) checkOut = checkOut.Add(TimeSpan.FromHours(24)); + + var grossWorkedMinutes = (int)(checkOut - checkIn).TotalMinutes; + var workingMinutes = Math.Max(0, grossWorkedMinutes - shift.BreakMinutes); + + var shiftStart = shift.StartTime; + var shiftEnd = shift.IsOvernight ? shift.EndTime.Add(TimeSpan.FromHours(24)) : shift.EndTime; + + var lateMinutes = Math.Max(0, (int)(checkIn - shiftStart).TotalMinutes - shift.GraceMinutes); + var earlyLeaveMinutes = Math.Max(0, (int)(shiftEnd - checkOut).TotalMinutes); + var overtimeMinutes = Math.Max(0, workingMinutes - shift.StandardWorkingMinutes); + + record.WorkingMinutes = workingMinutes; + record.LateMinutes = lateMinutes; + record.EarlyLeaveMinutes = earlyLeaveMinutes; + record.OvertimeMinutes = overtimeMinutes; + record.AttendanceStatus = workingMinutes < shift.StandardWorkingMinutes / 2 ? AttendanceStatus.HalfDay : AttendanceStatus.Present; + } +} diff --git a/Backend/ERPCore/Services/Hrm/AttendanceUploadService.cs b/Backend/ERPCore/Services/Hrm/AttendanceUploadService.cs new file mode 100644 index 0000000..0797a28 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/AttendanceUploadService.cs @@ -0,0 +1,407 @@ +using System.Globalization; +using System.Text; +using ClosedXML.Excel; +using CsvHelper; +using CsvHelper.Configuration; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Attendance upload/validate/confirm pipeline (FR-HR-ATT, docs/12-BACKEND-HRM.md §B.3.4). +/// Status flow is exactly Draft → Validated → Confirmed → UsedInPayroll; template and +/// parser share so they can never silently drift apart. +/// +public sealed class AttendanceUploadService : IAttendanceUploadService +{ + /// Employee Code | Date | Check In | Check Out — shared by the parser and the template generator. + public static readonly string[] ColumnNames = { "Employee Code", "Date", "Check In", "Check Out" }; + + private readonly IRepository _batches; + private readonly IRepository _records; + private readonly IRepository _employees; + private readonly IRepository _workShifts; + private readonly INumberSequenceService _numberSequence; + private readonly ILeaveRequestService _leaveRequests; + private readonly IAttendanceComputationService _computation; + private readonly IUnitOfWork _uow; + + public AttendanceUploadService( + IRepository batches, IRepository records, + IRepository employees, IRepository workShifts, + INumberSequenceService numberSequence, ILeaveRequestService leaveRequests, + IAttendanceComputationService computation, IUnitOfWork uow) + { + _batches = batches; + _records = records; + _employees = employees; + _workShifts = workShifts; + _numberSequence = numberSequence; + _leaveRequests = leaveRequests; + _computation = computation; + _uow = uow; + } + + public async Task> ListBatchesAsync( + PageQuery query, AttendanceBatchStatus? status, int? periodYear, int? periodMonth, CancellationToken ct = default) + { + var q = _batches.Query().AsNoTracking(); + if (status is not null) q = q.Where(b => b.Status == status); + if (periodYear is not null) q = q.Where(b => b.PeriodStart.Year == periodYear); + if (periodMonth is not null) q = q.Where(b => b.PeriodStart.Month == periodMonth); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(b => b.UploadedAt) + .Skip(query.Skip).Take(query.PageSize) + .Select(b => Map(b)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetBatchAsync(int batchId, CancellationToken ct = default) + { + var batch = await _batches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.AttendanceUploadBatchId == batchId, ct); + return batch is null ? null : Map(batch); + } + + public async Task UploadAsync( + Stream fileContent, string fileName, DateTime periodStart, DateTime periodEnd, int actorUserId, CancellationToken ct = default) + { + if (periodEnd < periodStart) + throw new DomainException(ErrorCodes.Validation, "Period end cannot be before period start.", 422); + + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + var sourceType = extension switch + { + ".xlsx" => AttendanceSourceType.Excel, + ".csv" => AttendanceSourceType.Csv, + _ => throw new DomainException(ErrorCodes.FileTypeNotAllowed, $"Unsupported attendance file type '{extension}'.", 422) + }; + + var rows = extension == ".xlsx" ? ParseExcel(fileContent) : ParseCsv(fileContent); + + var docNo = await _numberSequence.NextAsync("ATT", ct); + var batch = new AttendanceUploadBatch + { + DocNo = docNo, + PeriodStart = periodStart.Date, + PeriodEnd = periodEnd.Date, + SourceType = sourceType, + OriginalFileName = fileName, + UploadedBy = actorUserId, + UploadedAt = DateTime.UtcNow, + Status = AttendanceBatchStatus.Draft + }; + await _batches.AddAsync(batch, ct); + await _uow.SaveChangesAsync(ct); // flush to get batch.AttendanceUploadBatchId + + var employeesByCode = await _employees.Query().AsNoTracking() + .Where(e => e.Status == EmployeeStatus.Active) + .ToDictionaryAsync(e => e.EmployeeCode, StringComparer.OrdinalIgnoreCase, ct); + var shifts = await _workShifts.Query().AsNoTracking().ToDictionaryAsync(s => s.WorkShiftId, ct); + + var seenInBatch = new Dictionary<(int EmployeeId, DateTime Date), AttendanceRecord>(); + var created = new List(); + + foreach (var row in rows) + { + var record = new AttendanceRecord { AttendanceUploadBatchId = batch.AttendanceUploadBatchId }; + + if (!employeesByCode.TryGetValue(row.EmployeeCode, out var employee)) + { + record.RowValidationStatus = RowValidationStatus.EmployeeNotFound; + record.AttendanceDate = row.Date ?? periodStart; + record.WorkShiftId = shifts.Values.FirstOrDefault()?.WorkShiftId ?? 0; + created.Add(record); + continue; + } + + record.EmployeeId = employee.EmployeeId; + record.WorkShiftId = employee.WorkShiftId; + + if (row.Date is null) + { + record.RowValidationStatus = RowValidationStatus.InvalidDateTime; + record.AttendanceDate = periodStart; + created.Add(record); + continue; + } + record.AttendanceDate = row.Date.Value; + + if (row.HasCheckInText && row.CheckIn is null || row.HasCheckOutText && row.CheckOut is null) + { + record.RowValidationStatus = RowValidationStatus.InvalidDateTime; + created.Add(record); + continue; + } + + record.CheckIn = row.CheckIn; + record.CheckOut = row.CheckOut; + + if (row.CheckIn is not null && row.CheckOut is null) + { + record.RowValidationStatus = RowValidationStatus.Error; // forgot to punch out + created.Add(record); + continue; + } + + var key = (record.EmployeeId, record.AttendanceDate); + if (seenInBatch.TryGetValue(key, out var firstOccurrence)) + { + record.RowValidationStatus = RowValidationStatus.DuplicateWithinBatch; + record.DuplicateOfAttendanceRecordId = null; // linked by (EmployeeId, Date) until both are persisted + firstOccurrence.RowValidationStatus = RowValidationStatus.DuplicateWithinBatch; + created.Add(record); + continue; + } + + var alreadyConfirmed = await _records.Query().AsNoTracking() + .Include(r => r.AttendanceUploadBatch) + .AnyAsync(r => r.EmployeeId == record.EmployeeId && r.AttendanceDate == record.AttendanceDate + && r.AttendanceUploadBatch != null + && (r.AttendanceUploadBatch.Status == AttendanceBatchStatus.Confirmed || r.AttendanceUploadBatch.Status == AttendanceBatchStatus.UsedInPayroll), ct); + if (alreadyConfirmed) + { + record.RowValidationStatus = RowValidationStatus.DuplicateConfirmed; + created.Add(record); + continue; + } + + seenInBatch[key] = record; + record.RowValidationStatus = RowValidationStatus.Valid; + created.Add(record); + } + + foreach (var record in created) + { + if (record.RowValidationStatus == RowValidationStatus.Valid && shifts.TryGetValue(record.WorkShiftId, out var shift)) + { + var leave = await _leaveRequests.FindApprovedLeaveCoveringAsync(record.EmployeeId, record.AttendanceDate, ct); + var dayIndex = ((int)record.AttendanceDate.DayOfWeek + 6) % 7; // Monday=0..Sunday=6 + var isWeekOff = (shift.WorkingDaysMask & (1 << dayIndex)) == 0; + _computation.Compute(record, shift, leave is not null, isHoliday: false, isWeekOff: isWeekOff); + } + await _records.AddAsync(record, ct); + } + + batch.RowCountTotal = created.Count; + batch.RowCountDuplicate = created.Count(r => r.RowValidationStatus is RowValidationStatus.DuplicateWithinBatch or RowValidationStatus.DuplicateConfirmed); + batch.RowCountError = created.Count(r => r.RowValidationStatus is RowValidationStatus.EmployeeNotFound or RowValidationStatus.InvalidDateTime or RowValidationStatus.Error); + + await _uow.SaveChangesAsync(ct); + + return Map(batch); + } + + public async Task> ListRecordsAsync(int batchId, RowValidationStatus? status, CancellationToken ct = default) + { + var q = _records.Query().AsNoTracking().Include(r => r.Employee) + .Where(r => r.AttendanceUploadBatchId == batchId); + if (status is not null) q = q.Where(r => r.RowValidationStatus == status); + + return await q.OrderBy(r => r.Employee!.FullName).ThenBy(r => r.AttendanceDate) + .Select(r => Map(r)) + .ToListAsync(ct); + } + + public async Task UpdateRecordAsync( + int batchId, int recordId, UpdateAttendanceRecordRequest request, int actorUserId, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status is AttendanceBatchStatus.Confirmed or AttendanceBatchStatus.UsedInPayroll) + throw new DomainException(ErrorCodes.AttendanceBatchLocked, "This attendance batch is locked and cannot be edited.", 409); + + var record = await _records.Query().Include(r => r.Employee).Include(r => r.WorkShift) + .FirstOrDefaultAsync(r => r.AttendanceRecordId == recordId && r.AttendanceUploadBatchId == batchId, ct) + ?? throw new NotFoundException($"Attendance record {recordId} was not found in batch {batchId}."); + + if (request.CheckIn is not null) record.CheckIn = request.CheckIn; + if (request.CheckOut is not null) record.CheckOut = request.CheckOut; + if (request.Notes is not null) record.Notes = request.Notes.Trim(); + + if (record.WorkShift is not null && record.RowValidationStatus == RowValidationStatus.Valid) + { + var leave = await _leaveRequests.FindApprovedLeaveCoveringAsync(record.EmployeeId, record.AttendanceDate, ct); + var dayIndex = ((int)record.AttendanceDate.DayOfWeek + 6) % 7; + var isWeekOff = (record.WorkShift.WorkingDaysMask & (1 << dayIndex)) == 0; + _computation.Compute(record, record.WorkShift, leave is not null, isHoliday: false, isWeekOff: isWeekOff); + } + if (request.AttendanceStatus is not null) record.AttendanceStatus = request.AttendanceStatus.Value; + + record.IsManualOverride = true; + record.EditedBy = actorUserId; + record.EditedAt = DateTime.UtcNow; + + await _uow.SaveChangesAsync(ct); + return Map(record); + } + + public async Task ResolveDuplicateAsync(int batchId, ResolveDuplicateRequest request, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status is AttendanceBatchStatus.Confirmed or AttendanceBatchStatus.UsedInPayroll) + throw new DomainException(ErrorCodes.AttendanceBatchLocked, "This attendance batch is locked and cannot be edited.", 409); + + var record = await _records.GetByIdAsync(request.RecordId, ct) + ?? throw new NotFoundException($"Attendance record {request.RecordId} was not found."); + + switch (request.Action) + { + case "discard": + _records.Remove(record); + break; + case "keep": + record.RowValidationStatus = RowValidationStatus.Valid; + break; + case "supersede": + // Authorized cross-batch override: accept this row as the new source of truth. + record.RowValidationStatus = RowValidationStatus.Valid; + break; + } + + await _uow.SaveChangesAsync(ct); + } + + public async Task ValidateAsync(int batchId, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status != AttendanceBatchStatus.Draft) + throw new DomainException(ErrorCodes.Conflict, "Only a Draft batch can be validated.", 409); + + var unresolved = await _records.Query() + .CountAsync(r => r.AttendanceUploadBatchId == batchId && r.RowValidationStatus != RowValidationStatus.Valid, ct); + if (unresolved > 0) + throw new DomainException(ErrorCodes.AttendanceDuplicateUnresolved, + $"{unresolved} record(s) have unresolved errors/duplicates.", 422); + + batch.Status = AttendanceBatchStatus.Validated; + await _uow.SaveChangesAsync(ct); + return Map(batch); + } + + public async Task ConfirmAsync(int batchId, int actorUserId, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status != AttendanceBatchStatus.Validated) + throw new DomainException(ErrorCodes.Conflict, "Only a Validated batch can be confirmed.", 409); + + batch.Status = AttendanceBatchStatus.Confirmed; + batch.ConfirmedBy = actorUserId; + batch.ConfirmedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return Map(batch); + } + + public async Task UnlockAsync(int batchId, string reason, int actorUserId, CancellationToken ct = default) + { + var batch = await _batches.GetByIdAsync(batchId, ct) + ?? throw new NotFoundException($"Attendance batch {batchId} was not found."); + if (batch.Status == AttendanceBatchStatus.UsedInPayroll) + throw new DomainException(ErrorCodes.AttendanceBatchLocked, + "This batch has already been used in payroll; unlock/regenerate the payroll run first.", 409); + if (batch.Status != AttendanceBatchStatus.Confirmed) + throw new DomainException(ErrorCodes.Conflict, "Only a Confirmed batch can be unlocked.", 409); + + batch.Status = AttendanceBatchStatus.Validated; + await _uow.SaveChangesAsync(ct); + return Map(batch); + } + + public (byte[] Content, string ContentType, string FileName) GenerateTemplate(bool asCsv) + { + if (asCsv) + { + var csv = string.Join(",", ColumnNames) + "\r\n" + "EMP001,2026-07-01,08:00,17:00\r\n"; + return (Encoding.UTF8.GetBytes(csv), "text/csv", "attendance-template.csv"); + } + + using var workbook = new XLWorkbook(); + var sheet = workbook.Worksheets.Add("Attendance"); + for (var i = 0; i < ColumnNames.Length; i++) sheet.Cell(1, i + 1).Value = ColumnNames[i]; + sheet.Cell(2, 1).Value = "EMP001"; + sheet.Cell(2, 2).Value = "2026-07-01"; + sheet.Cell(2, 3).Value = "08:00"; + sheet.Cell(2, 4).Value = "17:00"; + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return (stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "attendance-template.xlsx"); + } + + private sealed record ParsedRow(string EmployeeCode, DateTime? Date, TimeSpan? CheckIn, TimeSpan? CheckOut, bool HasCheckInText, bool HasCheckOutText); + + private static List ParseExcel(Stream content) + { + using var workbook = new XLWorkbook(content); + var sheet = workbook.Worksheets.First(); + var rows = new List(); + + var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1; + for (var r = 2; r <= lastRow; r++) + { + var employeeCode = sheet.Cell(r, 1).GetString().Trim(); + if (string.IsNullOrWhiteSpace(employeeCode)) continue; + + var dateText = sheet.Cell(r, 2).GetString().Trim(); + var checkInText = sheet.Cell(r, 3).GetString().Trim(); + var checkOutText = sheet.Cell(r, 4).GetString().Trim(); + + rows.Add(new ParsedRow( + employeeCode, + DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d.Date : null, + TimeSpan.TryParse(checkInText, CultureInfo.InvariantCulture, out var ci) ? ci : null, + TimeSpan.TryParse(checkOutText, CultureInfo.InvariantCulture, out var co) ? co : null, + !string.IsNullOrWhiteSpace(checkInText), !string.IsNullOrWhiteSpace(checkOutText))); + } + return rows; + } + + private static List ParseCsv(Stream content) + { + using var reader = new StreamReader(content); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) { HeaderValidated = null, MissingFieldFound = null }); + csv.Read(); + csv.ReadHeader(); + + var rows = new List(); + while (csv.Read()) + { + var employeeCode = csv.GetField("Employee Code")?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(employeeCode)) continue; + + var dateText = csv.GetField("Date")?.Trim() ?? string.Empty; + var checkInText = csv.GetField("Check In")?.Trim() ?? string.Empty; + var checkOutText = csv.GetField("Check Out")?.Trim() ?? string.Empty; + + rows.Add(new ParsedRow( + employeeCode, + DateTime.TryParse(dateText, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) ? d.Date : null, + TimeSpan.TryParse(checkInText, CultureInfo.InvariantCulture, out var ci) ? ci : null, + TimeSpan.TryParse(checkOutText, CultureInfo.InvariantCulture, out var co) ? co : null, + !string.IsNullOrWhiteSpace(checkInText), !string.IsNullOrWhiteSpace(checkOutText))); + } + return rows; + } + + private static AttendanceUploadBatchDto Map(AttendanceUploadBatch b) => new( + b.AttendanceUploadBatchId, b.DocNo, b.PeriodStart, b.PeriodEnd, b.SourceType, b.OriginalFileName, + b.UploadedBy, b.UploadedAt, b.Status, b.ConfirmedBy, b.ConfirmedAt, b.RowCountTotal, b.RowCountDuplicate, b.RowCountError); + + private static AttendanceRecordDto Map(AttendanceRecord r) => new( + r.AttendanceRecordId, r.AttendanceUploadBatchId, r.EmployeeId, r.Employee?.EmployeeCode, r.Employee?.FullName, + r.AttendanceDate, r.CheckIn, r.CheckOut, r.WorkingMinutes, r.LateMinutes, r.EarlyLeaveMinutes, r.OvertimeMinutes, + r.AttendanceStatus, r.RowValidationStatus, r.DuplicateOfAttendanceRecordId, r.Notes); +} diff --git a/Backend/ERPCore/Services/Hrm/BranchService.cs b/Backend/ERPCore/Services/Hrm/BranchService.cs new file mode 100644 index 0000000..f147e22 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/BranchService.cs @@ -0,0 +1,107 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Branch master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2). +public sealed class BranchService : IBranchService +{ + private readonly IRepository _branches; + private readonly IUnitOfWork _uow; + + public BranchService(IRepository branches, IUnitOfWork uow) + { + _branches = branches; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _branches.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%") || EF.Functions.ILike(b.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(b => b.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(b => b.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(b => new BranchDto(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int branchId, CancellationToken ct = default) + { + var branch = await _branches.Query().AsNoTracking().FirstOrDefaultAsync(b => b.BranchId == branchId, ct); + return branch is null ? null : new ETagged(Map(branch), branch.RowVersion); + } + + public async Task> CreateAsync(CreateBranchRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _branches.Query().AnyAsync(b => b.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A branch with code '{code}' already exists."); + + var branch = new Branch + { + Code = code, + Name = request.Name.Trim(), + Address = request.Address?.Trim(), + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _branches.AddAsync(branch, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(branch), branch.RowVersion); + } + + public async Task> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var branch = await _branches.GetByIdAsync(branchId, ct) + ?? throw new NotFoundException($"Branch {branchId} was not found."); + + if (branch.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The branch was modified by another request.", 412); + + branch.Name = request.Name.Trim(); + branch.Address = request.Address?.Trim(); + branch.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The branch was modified by another request.", 412); + } + + return new ETagged(Map(branch), branch.RowVersion); + } + + public async Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default) + { + var branch = await _branches.GetByIdAsync(branchId, ct) + ?? throw new NotFoundException($"Branch {branchId} was not found."); + + branch.Status = status; + branch.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.Status, b.CreatedAt, b.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/DepartmentService.cs b/Backend/ERPCore/Services/Hrm/DepartmentService.cs new file mode 100644 index 0000000..5d79041 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/DepartmentService.cs @@ -0,0 +1,149 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Department master service (FR-HR-MD-01) — unlimited self-nesting for a real org +/// chart (unlike the two-level-capped Category); is +/// the service-level guard since there is no DB-level constraint for this +/// (docs/12-BACKEND-HRM.md A.1/C.1). +/// +public sealed class DepartmentService : IDepartmentService +{ + private readonly IRepository _departments; + private readonly IUnitOfWork _uow; + + public DepartmentService(IRepository departments, IUnitOfWork uow) + { + _departments = departments; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _departments.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(d => EF.Functions.ILike(d.Name, $"%{term}%") || EF.Functions.ILike(d.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(d => d.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(d => d.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(d => Map(d)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int departmentId, CancellationToken ct = default) + { + var dept = await _departments.Query().AsNoTracking().FirstOrDefaultAsync(d => d.DepartmentId == departmentId, ct); + return dept is null ? null : new ETagged(Map(dept), dept.RowVersion); + } + + public async Task> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _departments.Query().AnyAsync(d => d.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A department with code '{code}' already exists."); + + if (request.ParentDepartmentId is not null + && !await _departments.Query().AnyAsync(d => d.DepartmentId == request.ParentDepartmentId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Parent department {request.ParentDepartmentId} was not found.", 422); + + var dept = new Department + { + Code = code, + Name = request.Name.Trim(), + ParentDepartmentId = request.ParentDepartmentId, + HeadEmployeeId = request.HeadEmployeeId, + BranchId = request.BranchId, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _departments.AddAsync(dept, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(dept), dept.RowVersion); + } + + public async Task> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var dept = await _departments.GetByIdAsync(departmentId, ct) + ?? throw new NotFoundException($"Department {departmentId} was not found."); + + if (dept.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The department was modified by another request.", 412); + + if (request.ParentDepartmentId is not null) + { + if (request.ParentDepartmentId == departmentId) + throw new DomainException(ErrorCodes.DepartmentCycleDetected, "A department cannot be its own parent.", 422); + + if (!await _departments.Query().AnyAsync(d => d.DepartmentId == request.ParentDepartmentId, ct)) + throw new DomainException(ErrorCodes.Validation, $"Parent department {request.ParentDepartmentId} was not found.", 422); + + await EnsureNoCycleAsync(departmentId, request.ParentDepartmentId.Value, ct); + } + + dept.Name = request.Name.Trim(); + dept.ParentDepartmentId = request.ParentDepartmentId; + dept.HeadEmployeeId = request.HeadEmployeeId; + dept.BranchId = request.BranchId; + dept.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The department was modified by another request.", 412); + } + + return new ETagged(Map(dept), dept.RowVersion); + } + + public async Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default) + { + var dept = await _departments.GetByIdAsync(departmentId, ct) + ?? throw new NotFoundException($"Department {departmentId} was not found."); + + dept.Status = status; + dept.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + /// Walks up from ; throws if it ever reaches . + private async Task EnsureNoCycleAsync(int departmentId, int newParentId, CancellationToken ct) + { + var currentId = (int?)newParentId; + var guard = 0; + while (currentId is not null && guard++ < 1000) + { + if (currentId == departmentId) + throw new DomainException(ErrorCodes.DepartmentCycleDetected, "Setting this parent would create a department cycle.", 422); + + currentId = await _departments.Query().AsNoTracking() + .Where(d => d.DepartmentId == currentId) + .Select(d => d.ParentDepartmentId) + .FirstOrDefaultAsync(ct); + } + } + + private static DepartmentDto Map(Department d) => new( + d.DepartmentId, d.Code, d.Name, d.ParentDepartmentId, d.HeadEmployeeId, d.BranchId, d.Status, d.CreatedAt, d.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/DesignationService.cs b/Backend/ERPCore/Services/Hrm/DesignationService.cs new file mode 100644 index 0000000..9ca1fd5 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/DesignationService.cs @@ -0,0 +1,105 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Designation (job title) master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2). +public sealed class DesignationService : IDesignationService +{ + private readonly IRepository _designations; + private readonly IUnitOfWork _uow; + + public DesignationService(IRepository designations, IUnitOfWork uow) + { + _designations = designations; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _designations.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(d => EF.Functions.ILike(d.Name, $"%{term}%") || EF.Functions.ILike(d.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(d => d.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(d => d.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(d => new DesignationDto(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int designationId, CancellationToken ct = default) + { + var designation = await _designations.Query().AsNoTracking().FirstOrDefaultAsync(d => d.DesignationId == designationId, ct); + return designation is null ? null : new ETagged(Map(designation), designation.RowVersion); + } + + public async Task> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _designations.Query().AnyAsync(d => d.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A designation with code '{code}' already exists."); + + var designation = new Designation + { + Code = code, + Name = request.Name.Trim(), + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _designations.AddAsync(designation, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(designation), designation.RowVersion); + } + + public async Task> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var designation = await _designations.GetByIdAsync(designationId, ct) + ?? throw new NotFoundException($"Designation {designationId} was not found."); + + if (designation.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The designation was modified by another request.", 412); + + designation.Name = request.Name.Trim(); + designation.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The designation was modified by another request.", 412); + } + + return new ETagged(Map(designation), designation.RowVersion); + } + + public async Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default) + { + var designation = await _designations.GetByIdAsync(designationId, ct) + ?? throw new NotFoundException($"Designation {designationId} was not found."); + + designation.Status = status; + designation.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static DesignationDto Map(Designation d) => new(d.DesignationId, d.Code, d.Name, d.Status, d.CreatedAt, d.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeDocumentService.cs b/Backend/ERPCore/Services/Hrm/EmployeeDocumentService.cs new file mode 100644 index 0000000..19f5d06 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeDocumentService.cs @@ -0,0 +1,129 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.Storage; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace ERPCore.Services.Hrm; + +/// +/// Uploaded staff document service (FR-HR-DOC-02..04). Extension allowlist + declared +/// content-type cross-check + size cap are enforced here, server-authoritative +/// (docs/12-BACKEND-HRM.md §B.5, 02-SECURITY C.8) — magic-byte sniffing / antivirus +/// scanning are an explicitly deferred accepted risk, not silently skipped. +/// +public sealed class EmployeeDocumentService : Services.Interfaces.IEmployeeDocumentService +{ + private static readonly Dictionary AllowedExtensionContentTypes = new(StringComparer.OrdinalIgnoreCase) + { + [".pdf"] = "application/pdf", + [".jpg"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".png"] = "image/png", + [".docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }; + + private readonly IRepository _documents; + private readonly IRepository _employees; + private readonly IRepository _documentTypes; + private readonly IFileStorageService _storage; + private readonly IUnitOfWork _uow; + private readonly long _maxSizeBytes; + + public EmployeeDocumentService( + IRepository documents, IRepository employees, IRepository documentTypes, + IFileStorageService storage, IUnitOfWork uow, IConfiguration configuration) + { + _documents = documents; + _employees = employees; + _documentTypes = documentTypes; + _storage = storage; + _uow = uow; + _maxSizeBytes = configuration.GetValue("FileStorage:MaxSizeBytes") ?? 10 * 1024 * 1024; + } + + public async Task> ListAsync(int employeeId, CancellationToken ct = default) + { + return await _documents.Query().AsNoTracking() + .Include(d => d.HrDocumentType) + .Where(d => d.EmployeeId == employeeId) + .Select(d => Map(d)) + .ToListAsync(ct); + } + + public async Task UploadAsync( + int employeeId, UploadEmployeeDocumentRequest request, Stream fileContent, string fileName, string contentType, + int actorUserId, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + var docType = await _documentTypes.GetByIdAsync(request.HrDocumentTypeId, ct) + ?? throw new NotFoundException($"Document type {request.HrDocumentTypeId} was not found."); + + var extension = Path.GetExtension(fileName); + if (!AllowedExtensionContentTypes.TryGetValue(extension, out var expectedContentType) + || !string.Equals(expectedContentType, contentType, StringComparison.OrdinalIgnoreCase)) + throw new DomainException(ErrorCodes.FileTypeNotAllowed, + $"File type '{extension}'/'{contentType}' is not allowed.", 422); + + if (fileContent.Length > _maxSizeBytes) + throw new DomainException(ErrorCodes.FileTooLarge, + $"File exceeds the maximum allowed size of {_maxSizeBytes} bytes.", 413); + + var (storedFileName, relativePath, sizeBytes) = await _storage.SaveAsync(fileContent, fileName, contentType, ct); + + var document = new EmployeeDocument + { + EmployeeId = employeeId, + HrDocumentTypeId = docType.HrDocumentTypeId, + OriginalFileName = fileName, + StoredFileName = storedFileName, + RelativePath = relativePath, + ContentType = contentType, + SizeBytes = sizeBytes, + IssueDate = request.IssueDate, + ExpiryDate = request.ExpiryDate, + Notes = request.Notes?.Trim(), + UploadedBy = actorUserId, + UploadedAt = DateTime.UtcNow, + Status = EmployeeDocumentStatus.Active + }; + + await _documents.AddAsync(document, ct); + await _uow.SaveChangesAsync(ct); + + document.HrDocumentType = docType; + return Map(document); + } + + public async Task<(Stream Content, string FileName, string ContentType)> DownloadAsync( + int employeeId, int documentId, CancellationToken ct = default) + { + var document = await _documents.Query().AsNoTracking() + .FirstOrDefaultAsync(d => d.EmployeeDocumentId == documentId && d.EmployeeId == employeeId, ct) + ?? throw new NotFoundException($"Document {documentId} was not found for employee {employeeId}."); + + var stream = await _storage.OpenReadAsync(document.RelativePath, ct); + return (stream, document.OriginalFileName, document.ContentType); + } + + public async Task SetStatusAsync(int employeeId, int documentId, EmployeeDocumentStatus status, CancellationToken ct = default) + { + var document = await _documents.Query() + .FirstOrDefaultAsync(d => d.EmployeeDocumentId == documentId && d.EmployeeId == employeeId, ct) + ?? throw new NotFoundException($"Document {documentId} was not found for employee {employeeId}."); + + document.Status = status; + await _uow.SaveChangesAsync(ct); + } + + private static EmployeeDocumentDto Map(EmployeeDocument d) => new( + d.EmployeeDocumentId, d.EmployeeId, d.HrDocumentTypeId, d.HrDocumentType?.Name, + d.OriginalFileName, d.ContentType, d.SizeBytes, d.IssueDate, d.ExpiryDate, d.Notes, + d.UploadedBy, d.UploadedAt, d.Status); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeLoanService.cs b/Backend/ERPCore/Services/Hrm/EmployeeLoanService.cs new file mode 100644 index 0000000..087bb23 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeLoanService.cs @@ -0,0 +1,114 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Loan/Advance service (FR-HR-PAY-03). Creating a loan generates its full +/// installment schedule up front; installments flip Pending→Deducted only when +/// their consuming PayrollRun reaches Locked (docs/12-BACKEND-HRM.md A.4), handled +/// by , not here. +/// +public sealed class EmployeeLoanService : IEmployeeLoanService +{ + private readonly IRepository _loans; + private readonly IRepository _employees; + private readonly INumberSequenceService _numberSequence; + private readonly IUnitOfWork _uow; + + public EmployeeLoanService( + IRepository loans, IRepository employees, INumberSequenceService numberSequence, IUnitOfWork uow) + { + _loans = loans; + _employees = employees; + _numberSequence = numberSequence; + _uow = uow; + } + + public async Task> ListAsync(int employeeId, CancellationToken ct = default) + { + var rows = await _loans.Query().AsNoTracking() + .Include(l => l.Installments) + .Where(l => l.EmployeeId == employeeId) + .OrderByDescending(l => l.CreatedAt) + .ToListAsync(ct); + return rows.Select(Map).ToList(); + } + + public async Task GetAsync(int employeeId, int loanId, CancellationToken ct = default) + { + var loan = await _loans.Query().AsNoTracking() + .Include(l => l.Installments) + .FirstOrDefaultAsync(l => l.EmployeeLoanId == loanId && l.EmployeeId == employeeId, ct); + return loan is null ? null : Map(loan); + } + + public async Task CreateAsync(int employeeId, CreateEmployeeLoanRequest request, int actorUserId, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + var docNo = await _numberSequence.NextAsync("LOAN", ct); + var loan = new EmployeeLoan + { + DocNo = docNo, + EmployeeId = employeeId, + LoanKind = request.LoanKind, + PrincipalAmount = request.PrincipalAmount, + InterestRate = request.InterestRate, + InstallmentAmount = request.InstallmentAmount, + NumberOfInstallments = request.NumberOfInstallments, + StartYear = request.StartYear, + StartMonth = request.StartMonth, + OutstandingBalance = request.PrincipalAmount, + Status = LoanStatus.Active, + ApprovedBy = actorUserId, + ApprovedAt = DateTime.UtcNow, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow + }; + + var year = request.StartYear; + var month = request.StartMonth; + for (var i = 1; i <= request.NumberOfInstallments; i++) + { + loan.Installments.Add(new LoanInstallment + { + InstallmentNumber = i, + DueYear = year, + DueMonth = month, + ScheduledAmount = request.InstallmentAmount, + Status = LoanInstallmentStatus.Pending + }); + month++; + if (month > 12) { month = 1; year++; } + } + + await _loans.AddAsync(loan, ct); + await _uow.SaveChangesAsync(ct); + + return Map(loan); + } + + public async Task> GetDueInstallmentsAsync(int employeeId, int periodYear, int periodMonth, CancellationToken ct = default) + { + return await _loans.Query() + .Where(l => l.EmployeeId == employeeId && l.Status == LoanStatus.Active) + .SelectMany(l => l.Installments) + .Where(i => i.DueYear == periodYear && i.DueMonth == periodMonth && i.Status == LoanInstallmentStatus.Pending) + .ToListAsync(ct); + } + + private static EmployeeLoanDto Map(EmployeeLoan l) => new( + l.EmployeeLoanId, l.DocNo, l.EmployeeId, l.LoanKind, l.PrincipalAmount, l.InterestRate, l.InstallmentAmount, + l.NumberOfInstallments, l.StartYear, l.StartMonth, l.OutstandingBalance, l.Status, + l.Installments.OrderBy(i => i.InstallmentNumber).Select(i => new LoanInstallmentDto( + i.LoanInstallmentId, i.InstallmentNumber, i.DueYear, i.DueMonth, i.ScheduledAmount, i.PaidAmount, i.PayrollRunId, i.Status)).ToList(), + l.CreatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeSalaryStructureService.cs b/Backend/ERPCore/Services/Hrm/EmployeeSalaryStructureService.cs new file mode 100644 index 0000000..91e2d67 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeSalaryStructureService.cs @@ -0,0 +1,104 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Effective-dated salary structure service (FR-HR-PAY-02) — creating a new +/// structure supersedes the previous open-ended one, preserving history for audits +/// (docs/12-BACKEND-HRM.md §13) rather than overwriting it. +/// +public sealed class EmployeeSalaryStructureService : IEmployeeSalaryStructureService +{ + private readonly IRepository _structures; + private readonly IRepository _employees; + private readonly IRepository _components; + private readonly IUnitOfWork _uow; + + public EmployeeSalaryStructureService( + IRepository structures, IRepository employees, + IRepository components, IUnitOfWork uow) + { + _structures = structures; + _employees = employees; + _components = components; + _uow = uow; + } + + public async Task> ListHistoryAsync(int employeeId, CancellationToken ct = default) + { + var rows = await _structures.Query().AsNoTracking() + .Include(s => s.Lines).ThenInclude(l => l.SalaryComponent) + .Where(s => s.EmployeeId == employeeId) + .OrderByDescending(s => s.EffectiveFrom) + .ToListAsync(ct); + + return rows.Select(Map).ToList(); + } + + public async Task GetCurrentAsync(int employeeId, CancellationToken ct = default) + { + var current = await _structures.Query().AsNoTracking() + .Include(s => s.Lines).ThenInclude(l => l.SalaryComponent) + .Where(s => s.EmployeeId == employeeId && s.EffectiveTo == null) + .FirstOrDefaultAsync(ct); + return current is null ? null : Map(current); + } + + public async Task CreateAsync( + int employeeId, CreateSalaryStructureRequest request, int actorUserId, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + var current = await _structures.Query() + .FirstOrDefaultAsync(s => s.EmployeeId == employeeId && s.EffectiveTo == null, ct); + if (current is not null) + { + if (request.EffectiveFrom.Date <= current.EffectiveFrom.Date) + throw new DomainException(ErrorCodes.SalaryStructureOverlap, + "The new effective date must be after the current structure's effective date.", 409); + + current.EffectiveTo = request.EffectiveFrom.Date.AddDays(-1); + current.Status = SalaryStructureStatus.Superseded; + } + + var componentIds = request.Lines.Select(l => l.SalaryComponentId).ToList(); + var validComponentCount = await _components.Query().CountAsync(c => componentIds.Contains(c.SalaryComponentId), ct); + if (validComponentCount != componentIds.Distinct().Count()) + throw new DomainException(ErrorCodes.Validation, "One or more salary components were not found.", 422); + + var structure = new EmployeeSalaryStructure + { + EmployeeId = employeeId, + EffectiveFrom = request.EffectiveFrom.Date, + BasicSalary = request.BasicSalary, + Status = SalaryStructureStatus.Active, + ApprovedBy = actorUserId, + ApprovedAt = DateTime.UtcNow, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow, + Lines = request.Lines.Select(l => new EmployeeSalaryStructureLine + { + SalaryComponentId = l.SalaryComponentId, + Amount = l.Amount + }).ToList() + }; + + await _structures.AddAsync(structure, ct); + await _uow.SaveChangesAsync(ct); + + return await GetCurrentAsync(employeeId, ct) ?? Map(structure); + } + + private static EmployeeSalaryStructureDto Map(EmployeeSalaryStructure s) => new( + s.EmployeeSalaryStructureId, s.EmployeeId, s.EffectiveFrom, s.EffectiveTo, s.BasicSalary, s.Currency, s.Status, + s.Lines.Select(l => new EmployeeSalaryStructureLineDto(l.SalaryComponentId, l.SalaryComponent?.Name, l.Amount)).ToList(), + s.CreatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeService.cs b/Backend/ERPCore/Services/Hrm/EmployeeService.cs new file mode 100644 index 0000000..3dc0a4b --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeService.cs @@ -0,0 +1,244 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Employee (staff) service (FR-HR-MD-02/03, docs/13-BACKEND-HRM-API.md §3). Distinct +/// from (system login accounts) — the two are +/// linked only via the optional, explicit (docs/12-BACKEND-HRM.md A.5). +/// +public sealed class EmployeeService : IEmployeeService +{ + private readonly IRepository _employees; + private readonly IRepository _users; + private readonly IRepository _bankDetails; + private readonly IUnitOfWork _uow; + + public EmployeeService( + IRepository employees, IRepository users, IRepository bankDetails, IUnitOfWork uow) + { + _employees = employees; + _users = users; + _bankDetails = bankDetails; + _uow = uow; + } + + public async Task> ListAsync( + PageQuery query, EmployeeStatus? status, int? departmentId, int? designationId, int? branchId, CancellationToken ct = default) + { + var q = _employees.Query().AsNoTracking() + .Include(e => e.Department).Include(e => e.Designation) + .Include(e => e.EmploymentType).Include(e => e.Branch) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(e => EF.Functions.ILike(e.FullName, $"%{term}%") || EF.Functions.ILike(e.EmployeeCode, $"%{term}%")); + } + if (status is not null) q = q.Where(e => e.Status == status); + if (departmentId is not null) q = q.Where(e => e.DepartmentId == departmentId); + if (designationId is not null) q = q.Where(e => e.DesignationId == designationId); + if (branchId is not null) q = q.Where(e => e.BranchId == branchId); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(e => e.FullName) + .Skip(query.Skip).Take(query.PageSize) + .Select(e => new EmployeeListItemDto( + e.EmployeeId, e.EmployeeCode, e.FullName, e.Email, + e.DepartmentId, e.Department!.Name, e.DesignationId, e.Designation!.Name, + e.EmploymentTypeId, e.EmploymentType!.Name, e.BranchId, e.Branch != null ? e.Branch.Name : null, + e.Status, e.UserId != null, e.HireDate)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int employeeId, CancellationToken ct = default) + { + var employee = await _employees.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmployeeId == employeeId, ct); + return employee is null ? null : new ETagged(Map(employee), employee.RowVersion); + } + + public async Task> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default) + { + var code = request.EmployeeCode.Trim(); + if (await _employees.Query().AnyAsync(e => e.EmployeeCode.ToLower() == code.ToLower(), ct)) + throw new DomainException(ErrorCodes.EmployeeCodeDuplicate, $"An employee with code '{code}' already exists.", 400); + + int? linkedUserId = null; + if (request.LinkUserId is not null) + { + var user = await _users.GetByIdAsync(request.LinkUserId.Value, ct) + ?? throw new NotFoundException($"User {request.LinkUserId} was not found."); + if (await _employees.Query().AnyAsync(e => e.UserId == user.UserId, ct)) + throw new DomainException(ErrorCodes.UserAlreadyLinked, $"User {user.UserId} already backs a different employee.", 409); + linkedUserId = user.UserId; + } + + var employee = new Employee + { + EmployeeCode = code, + FullName = request.FullName.Trim(), + Nic = request.Nic?.Trim(), + DateOfBirth = request.DateOfBirth, + Gender = request.Gender, + Nationality = request.Nationality?.Trim(), + Email = request.Email?.Trim(), + PersonalMobile = request.PersonalMobile?.Trim(), + AddressLine1 = request.AddressLine1?.Trim(), + AddressLine2 = request.AddressLine2?.Trim(), + City = request.City?.Trim(), + PostalCode = request.PostalCode?.Trim(), + Country = request.Country?.Trim(), + EmergencyContactName = request.EmergencyContactName?.Trim(), + EmergencyContactRelationship = request.EmergencyContactRelationship?.Trim(), + EmergencyContactPhone = request.EmergencyContactPhone?.Trim(), + HireDate = request.HireDate, + DepartmentId = request.DepartmentId, + DesignationId = request.DesignationId, + EmploymentTypeId = request.EmploymentTypeId, + BranchId = request.BranchId, + WorkShiftId = request.WorkShiftId, + ReportingManagerId = request.ReportingManagerId, + EpfNumber = request.EpfNumber?.Trim(), + EtfNumber = request.EtfNumber?.Trim(), + TaxIdentificationNumber = request.TaxIdentificationNumber?.Trim(), + UserId = linkedUserId, + Status = EmployeeStatus.Active, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow + }; + + await _employees.AddAsync(employee, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(employee), employee.RowVersion); + } + + public async Task> UpdateAsync( + int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default) + { + var employee = await _employees.GetByIdAsync(employeeId, ct) + ?? throw new NotFoundException($"Employee {employeeId} was not found."); + + if (employee.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412); + + employee.FullName = request.FullName.Trim(); + employee.Nic = request.Nic?.Trim(); + employee.DateOfBirth = request.DateOfBirth; + employee.Gender = request.Gender; + employee.Nationality = request.Nationality?.Trim(); + employee.Email = request.Email?.Trim(); + employee.PersonalMobile = request.PersonalMobile?.Trim(); + employee.AddressLine1 = request.AddressLine1?.Trim(); + employee.AddressLine2 = request.AddressLine2?.Trim(); + employee.City = request.City?.Trim(); + employee.PostalCode = request.PostalCode?.Trim(); + employee.Country = request.Country?.Trim(); + employee.EmergencyContactName = request.EmergencyContactName?.Trim(); + employee.EmergencyContactRelationship = request.EmergencyContactRelationship?.Trim(); + employee.EmergencyContactPhone = request.EmergencyContactPhone?.Trim(); + employee.ConfirmationDate = request.ConfirmationDate; + employee.LastWorkingDate = request.LastWorkingDate; + employee.DepartmentId = request.DepartmentId; + employee.DesignationId = request.DesignationId; + employee.EmploymentTypeId = request.EmploymentTypeId; + employee.BranchId = request.BranchId; + employee.WorkShiftId = request.WorkShiftId; + employee.ReportingManagerId = request.ReportingManagerId; + employee.EpfNumber = request.EpfNumber?.Trim(); + employee.EtfNumber = request.EtfNumber?.Trim(); + employee.TaxIdentificationNumber = request.TaxIdentificationNumber?.Trim(); + employee.UpdatedBy = actorUserId; + employee.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employee was modified by another request.", 412); + } + + return new ETagged(Map(employee), employee.RowVersion); + } + + public async Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default) + { + var employee = await _employees.GetByIdAsync(employeeId, ct) + ?? throw new NotFoundException($"Employee {employeeId} was not found."); + + employee.Status = status; + employee.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + public async Task> ListBankDetailsAsync(int employeeId, CancellationToken ct = default) + { + return await _bankDetails.Query().AsNoTracking() + .Where(b => b.EmployeeId == employeeId) + .Select(b => new EmployeeBankDetailDto( + b.EmployeeBankDetailId, b.BankName, b.BranchName, b.AccountNumber, b.AccountHolderName, + b.SwiftCode, b.IsPrimary, b.Status)) + .ToListAsync(ct); + } + + public async Task> ReplaceBankDetailsAsync( + int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + if (request.Items.Count(i => i.IsPrimary) > 1) + throw new DomainException(ErrorCodes.Validation, "Only one bank detail row may be marked primary.", 422); + + var existing = await _bankDetails.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct); + foreach (var row in existing) _bankDetails.Remove(row); + + var result = new List(); + foreach (var item in request.Items) + { + var detail = new EmployeeBankDetail + { + EmployeeId = employeeId, + BankName = item.BankName.Trim(), + BranchName = item.BranchName.Trim(), + AccountNumber = item.AccountNumber.Trim(), + AccountHolderName = item.AccountHolderName.Trim(), + SwiftCode = item.SwiftCode?.Trim(), + IsPrimary = item.IsPrimary, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + result.Add(detail); + await _bankDetails.AddAsync(detail, ct); + } + + await _uow.SaveChangesAsync(ct); + + return result.Select(b => new EmployeeBankDetailDto( + b.EmployeeBankDetailId, b.BankName, b.BranchName, b.AccountNumber, b.AccountHolderName, + b.SwiftCode, b.IsPrimary, b.Status)).ToList(); + } + + private static EmployeeDetailDto Map(Employee e) => new( + e.EmployeeId, e.EmployeeCode, e.FullName, e.Nic, e.DateOfBirth, e.Gender, e.Nationality, e.ProfilePhotoPath, + e.Email, e.PersonalMobile, e.AddressLine1, e.AddressLine2, e.City, e.PostalCode, e.Country, + e.EmergencyContactName, e.EmergencyContactRelationship, e.EmergencyContactPhone, + e.HireDate, e.ConfirmationDate, e.LastWorkingDate, + e.DepartmentId, e.DesignationId, e.EmploymentTypeId, e.BranchId, e.WorkShiftId, + e.ReportingManagerId, e.EpfNumber, e.EtfNumber, e.TaxIdentificationNumber, + e.UserId, e.Status, e.CreatedAt, e.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/EmployeeUserLinkService.cs b/Backend/ERPCore/Services/Hrm/EmployeeUserLinkService.cs new file mode 100644 index 0000000..88e7299 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmployeeUserLinkService.cs @@ -0,0 +1,73 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class EmployeeUserLinkService : IEmployeeUserLinkService +{ + private readonly IRepository _employees; + private readonly IRepository _users; + private readonly IUnitOfWork _uow; + + public EmployeeUserLinkService(IRepository employees, IRepository users, IUnitOfWork uow) + { + _employees = employees; + _users = users; + _uow = uow; + } + + public async Task FindStaffCandidateByEmailAsync(string email, CancellationToken ct = default) + { + var term = email.Trim(); + var employee = await _employees.Query().AsNoTracking() + .Where(e => e.UserId == null && e.Email != null && EF.Functions.ILike(e.Email, term)) + .Select(e => new EmployeeMatchDto(e.EmployeeId, e.EmployeeCode, e.FullName, e.Email!)) + .FirstOrDefaultAsync(ct); + return employee; + } + + public async Task FindUserCandidateByEmailAsync(string email, CancellationToken ct = default) + { + var term = email.Trim(); + var linkedUserIds = _employees.Query().Where(e => e.UserId != null).Select(e => e.UserId!.Value); + + var user = await _users.Query().AsNoTracking() + .Where(u => u.Email != null && EF.Functions.ILike(u.Email, term) && !linkedUserIds.Contains(u.UserId)) + .Select(u => new UserMatchDto(u.UserId, u.Username, u.DisplayName, u.Email!)) + .FirstOrDefaultAsync(ct); + return user; + } + + public async Task LinkAsync(int employeeId, int userId, CancellationToken ct = default) + { + var employee = await _employees.GetByIdAsync(employeeId, ct) + ?? throw new NotFoundException($"Employee {employeeId} was not found."); + if (employee.UserId is not null) + throw new DomainException(ErrorCodes.EmployeeAlreadyLinked, $"Employee {employeeId} already has a linked user.", 409); + + var user = await _users.GetByIdAsync(userId, ct) + ?? throw new NotFoundException($"User {userId} was not found."); + if (await _employees.Query().AnyAsync(e => e.UserId == userId, ct)) + throw new DomainException(ErrorCodes.UserAlreadyLinked, $"User {userId} already backs a different employee.", 409); + + employee.UserId = user.UserId; + employee.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + public async Task UnlinkAsync(int employeeId, CancellationToken ct = default) + { + var employee = await _employees.GetByIdAsync(employeeId, ct) + ?? throw new NotFoundException($"Employee {employeeId} was not found."); + + employee.UserId = null; + employee.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } +} diff --git a/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs b/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs new file mode 100644 index 0000000..6fbc80e --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/EmploymentTypeService.cs @@ -0,0 +1,105 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// EmploymentType master service (FR-HR-MD-01, docs/13-BACKEND-HRM-API.md §2). +public sealed class EmploymentTypeService : IEmploymentTypeService +{ + private readonly IRepository _employmentTypes; + private readonly IUnitOfWork _uow; + + public EmploymentTypeService(IRepository employmentTypes, IUnitOfWork uow) + { + _employmentTypes = employmentTypes; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _employmentTypes.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(e => EF.Functions.ILike(e.Name, $"%{term}%") || EF.Functions.ILike(e.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(e => e.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(e => e.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(e => new EmploymentTypeDto(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int employmentTypeId, CancellationToken ct = default) + { + var et = await _employmentTypes.Query().AsNoTracking().FirstOrDefaultAsync(e => e.EmploymentTypeId == employmentTypeId, ct); + return et is null ? null : new ETagged(Map(et), et.RowVersion); + } + + public async Task> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _employmentTypes.Query().AnyAsync(e => e.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"An employment type with code '{code}' already exists."); + + var et = new EmploymentType + { + Code = code, + Name = request.Name.Trim(), + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _employmentTypes.AddAsync(et, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(et), et.RowVersion); + } + + public async Task> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct) + ?? throw new NotFoundException($"Employment type {employmentTypeId} was not found."); + + if (et.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employment type was modified by another request.", 412); + + et.Name = request.Name.Trim(); + et.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The employment type was modified by another request.", 412); + } + + return new ETagged(Map(et), et.RowVersion); + } + + public async Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default) + { + var et = await _employmentTypes.GetByIdAsync(employmentTypeId, ct) + ?? throw new NotFoundException($"Employment type {employmentTypeId} was not found."); + + et.Status = status; + et.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static EmploymentTypeDto Map(EmploymentType e) => new(e.EmploymentTypeId, e.Code, e.Name, e.Status, e.CreatedAt, e.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs b/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs new file mode 100644 index 0000000..1f488d1 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/HrDocumentTypeService.cs @@ -0,0 +1,113 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Staff document-type catalog service (FR-HR-DOC-01, docs/13-BACKEND-HRM-API.md §2). +public sealed class HrDocumentTypeService : IHrDocumentTypeService +{ + private readonly IRepository _types; + private readonly IUnitOfWork _uow; + + public HrDocumentTypeService(IRepository types, IUnitOfWork uow) + { + _types = types; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _types.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%") || EF.Functions.ILike(t.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(t => t.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => Map(t)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int hrDocumentTypeId, CancellationToken ct = default) + { + var type = await _types.Query().AsNoTracking().FirstOrDefaultAsync(t => t.HrDocumentTypeId == hrDocumentTypeId, ct); + return type is null ? null : new ETagged(Map(type), type.RowVersion); + } + + public async Task> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _types.Query().AnyAsync(t => t.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A document type with code '{code}' already exists."); + + var type = new HrDocumentType + { + Code = code, + Name = request.Name.Trim(), + Category = request.Category, + RequiredAtOnboarding = request.RequiredAtOnboarding, + ExpiryTracked = request.ExpiryTracked, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _types.AddAsync(type, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(type), type.RowVersion); + } + + public async Task> UpdateAsync( + int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(hrDocumentTypeId, ct) + ?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found."); + + if (type.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The document type was modified by another request.", 412); + + type.Name = request.Name.Trim(); + type.Category = request.Category; + type.RequiredAtOnboarding = request.RequiredAtOnboarding; + type.ExpiryTracked = request.ExpiryTracked; + type.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The document type was modified by another request.", 412); + } + + return new ETagged(Map(type), type.RowVersion); + } + + public async Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(hrDocumentTypeId, ct) + ?? throw new NotFoundException($"Document type {hrDocumentTypeId} was not found."); + + type.Status = status; + type.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static HrDocumentTypeDto Map(HrDocumentType t) => new( + t.HrDocumentTypeId, t.Code, t.Name, t.Category, t.RequiredAtOnboarding, t.ExpiryTracked, t.Status, t.CreatedAt, t.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/HrReportService.cs b/Backend/ERPCore/Services/Hrm/HrReportService.cs new file mode 100644 index 0000000..b027a46 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/HrReportService.cs @@ -0,0 +1,133 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class HrReportService : IHrReportService +{ + private readonly IRepository _attendance; + private readonly IRepository _payrollLines; + private readonly IRepository _salaryStructures; + private readonly IRepository _leaveBalances; + private readonly IRepository _documents; + + public HrReportService( + IRepository attendance, IRepository payrollLines, + IRepository salaryStructures, IRepository leaveBalances, + IRepository documents) + { + _attendance = attendance; + _payrollLines = payrollLines; + _salaryStructures = salaryStructures; + _leaveBalances = leaveBalances; + _documents = documents; + } + + public async Task> AttendanceSummaryAsync( + int periodYear, int periodMonth, int? departmentId, CancellationToken ct = default) + { + var periodStart = new DateTime(periodYear, periodMonth, 1); + var periodEnd = periodStart.AddMonths(1).AddDays(-1); + + var q = _attendance.Query().AsNoTracking() + .Include(r => r.Employee).ThenInclude(e => e!.Department) + .Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd); + if (departmentId is not null) q = q.Where(r => r.Employee!.DepartmentId == departmentId); + + var rows = await q.ToListAsync(ct); + + return rows.GroupBy(r => r.EmployeeId) + .Select(g => new AttendanceSummaryRowDto( + g.Key, g.First().Employee!.EmployeeCode, g.First().Employee!.FullName, g.First().Employee!.Department?.Name, + g.Count(r => r.AttendanceStatus == AttendanceStatus.Present), + g.Count(r => r.AttendanceStatus == AttendanceStatus.Absent), + g.Count(r => r.AttendanceStatus == AttendanceStatus.OnLeave), + g.Count(r => r.AttendanceStatus == AttendanceStatus.HalfDay), + g.Sum(r => r.OvertimeMinutes), + g.Sum(r => r.LateMinutes))) + .OrderBy(r => r.EmployeeName) + .ToList(); + } + + public async Task> OvertimeReportAsync(int periodYear, int periodMonth, CancellationToken ct = default) + { + var periodStart = new DateTime(periodYear, periodMonth, 1); + var periodEnd = periodStart.AddMonths(1).AddDays(-1); + + return await _attendance.Query().AsNoTracking() + .Include(r => r.Employee) + .Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd && r.OvertimeMinutes > 0) + .OrderByDescending(r => r.OvertimeMinutes) + .Select(r => new OvertimeReportRowDto(r.EmployeeId, r.Employee!.EmployeeCode, r.Employee!.FullName, r.AttendanceDate, r.OvertimeMinutes)) + .ToListAsync(ct); + } + + public async Task> LateArrivalReportAsync(int periodYear, int periodMonth, CancellationToken ct = default) + { + var periodStart = new DateTime(periodYear, periodMonth, 1); + var periodEnd = periodStart.AddMonths(1).AddDays(-1); + + return await _attendance.Query().AsNoTracking() + .Include(r => r.Employee) + .Where(r => r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd && r.LateMinutes > 0) + .OrderByDescending(r => r.LateMinutes) + .Select(r => new LateArrivalReportRowDto(r.EmployeeId, r.Employee!.EmployeeCode, r.Employee!.FullName, r.AttendanceDate, r.LateMinutes)) + .ToListAsync(ct); + } + + public async Task> PayrollRegisterAsync(int payrollRunId, CancellationToken ct = default) + { + return await _payrollLines.Query().AsNoTracking() + .Include(l => l.Employee) + .Where(l => l.PayrollRunId == payrollRunId) + .OrderBy(l => l.Employee!.FullName) + .Select(l => new PayrollRegisterRowDto( + l.PayrollLineId, l.EmployeeId, l.Employee!.EmployeeCode, l.Employee!.FullName, + l.GrossSalary, l.GrossSalary - l.NetSalary, l.NetSalary)) + .ToListAsync(ct); + } + + public async Task> SalaryHistoryAsync(int employeeId, CancellationToken ct = default) + { + return await _salaryStructures.Query().AsNoTracking() + .Where(s => s.EmployeeId == employeeId) + .OrderByDescending(s => s.EffectiveFrom) + .Select(s => new SalaryHistoryRowDto(s.EmployeeSalaryStructureId, s.EffectiveFrom, s.EffectiveTo, s.BasicSalary, s.Status.ToString())) + .ToListAsync(ct); + } + + public async Task> LeaveBalanceReportAsync(int year, CancellationToken ct = default) + { + return await _leaveBalances.Query().AsNoTracking() + .Include(b => b.Employee) + .Include(b => b.LeaveType) + .Where(b => b.Year == year) + .OrderBy(b => b.Employee!.FullName) + .Select(b => new LeaveBalanceReportRowDto( + b.EmployeeId, b.Employee!.EmployeeCode, b.Employee!.FullName, b.LeaveType!.Name, + b.EntitledDays, b.TakenDays, b.EntitledDays + b.CarriedForwardDays + b.AdjustmentDays - b.TakenDays)) + .ToListAsync(ct); + } + + public async Task> DocumentExpiryReportAsync(int withinDays, CancellationToken ct = default) + { + var cutoff = DateTime.UtcNow.Date.AddDays(withinDays); + var today = DateTime.UtcNow.Date; + + var rows = await _documents.Query().AsNoTracking() + .Include(d => d.Employee) + .Include(d => d.HrDocumentType) + .Where(d => d.ExpiryDate != null && d.ExpiryDate <= cutoff) + .OrderBy(d => d.ExpiryDate) + .ToListAsync(ct); + + return rows.Select(d => new DocumentExpiryReportRowDto( + d.EmployeeDocumentId, d.EmployeeId, d.Employee!.EmployeeCode, d.Employee!.FullName, + d.HrDocumentType!.Name, d.ExpiryDate!.Value, (d.ExpiryDate.Value.Date - today).Days)).ToList(); + } +} diff --git a/Backend/ERPCore/Services/Hrm/LeaveBalanceService.cs b/Backend/ERPCore/Services/Hrm/LeaveBalanceService.cs new file mode 100644 index 0000000..51e1e40 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/LeaveBalanceService.cs @@ -0,0 +1,95 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Leave balance service (FR-HR-LV-03, docs/13-BACKEND-HRM-API.md §5). +public sealed class LeaveBalanceService : ILeaveBalanceService +{ + private readonly IRepository _balances; + private readonly IRepository _leaveTypes; + private readonly IRepository _employees; + private readonly IUnitOfWork _uow; + + public LeaveBalanceService( + IRepository balances, IRepository leaveTypes, IRepository employees, IUnitOfWork uow) + { + _balances = balances; + _leaveTypes = leaveTypes; + _employees = employees; + _uow = uow; + } + + public async Task> ListAsync(int employeeId, int? year, CancellationToken ct = default) + { + var effectiveYear = year ?? DateTime.UtcNow.Year; + var rows = await _balances.Query().AsNoTracking() + .Include(b => b.LeaveType) + .Where(b => b.EmployeeId == employeeId && b.Year == effectiveYear) + .ToListAsync(ct); + + return rows.Select(Map).ToList(); + } + + public async Task> ApplyAdjustmentsAsync(int employeeId, UpdateLeaveBalancesRequest request, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == employeeId, ct)) + throw new NotFoundException($"Employee {employeeId} was not found."); + + var result = new List(); + foreach (var item in request.Items) + { + var balance = await GetOrCreateAsync(employeeId, item.LeaveTypeId, request.Year, ct); + balance.AdjustmentDays = item.AdjustmentDays; + balance.UpdatedAt = DateTime.UtcNow; + result.Add(balance); + } + + await _uow.SaveChangesAsync(ct); + + return result.Select(Map).ToList(); + } + + public async Task IncrementTakenDaysAsync(int employeeId, int leaveTypeId, int year, decimal days, CancellationToken ct = default) + { + var balance = await GetOrCreateAsync(employeeId, leaveTypeId, year, ct); + balance.TakenDays += days; + balance.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private async Task GetOrCreateAsync(int employeeId, int leaveTypeId, int year, CancellationToken ct) + { + var leaveType = await _leaveTypes.GetByIdAsync(leaveTypeId, ct) + ?? throw new NotFoundException($"Leave type {leaveTypeId} was not found."); + + var balance = await _balances.Query() + .FirstOrDefaultAsync(b => b.EmployeeId == employeeId && b.LeaveTypeId == leaveTypeId && b.Year == year, ct); + if (balance is not null) + { + balance.LeaveType = leaveType; + return balance; + } + + balance = new LeaveBalance + { + EmployeeId = employeeId, + LeaveTypeId = leaveTypeId, + Year = year, + EntitledDays = leaveType.AccrualPerYear, + LeaveType = leaveType + }; + await _balances.AddAsync(balance, ct); + return balance; + } + + private static LeaveBalanceDto Map(LeaveBalance b) => new( + b.LeaveBalanceId, b.EmployeeId, b.LeaveTypeId, b.LeaveType?.Name, b.Year, + b.EntitledDays, b.TakenDays, b.CarriedForwardDays, b.AdjustmentDays, + b.EntitledDays + b.CarriedForwardDays + b.AdjustmentDays - b.TakenDays); +} diff --git a/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs b/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs new file mode 100644 index 0000000..57e604f --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/LeaveRequestService.cs @@ -0,0 +1,168 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Leave request service (FR-HR-LV-02). Approving a request increments the +/// employee's (docs/12-BACKEND-HRM.md §6) — +/// this is also the event Attendance's OnLeave classification reads back via +/// . +/// +public sealed class LeaveRequestService : ILeaveRequestService +{ + private readonly IRepository _requests; + private readonly IRepository _employees; + private readonly IRepository _leaveTypes; + private readonly ILeaveBalanceService _balances; + private readonly INumberSequenceService _numberSequence; + private readonly IUnitOfWork _uow; + + public LeaveRequestService( + IRepository requests, IRepository employees, IRepository leaveTypes, + ILeaveBalanceService balances, INumberSequenceService numberSequence, IUnitOfWork uow) + { + _requests = requests; + _employees = employees; + _leaveTypes = leaveTypes; + _balances = balances; + _numberSequence = numberSequence; + _uow = uow; + } + + public async Task> ListAsync( + PageQuery query, int? employeeId, LeaveRequestStatus? status, CancellationToken ct = default) + { + var q = _requests.Query().AsNoTracking().Include(r => r.Employee).Include(r => r.LeaveType).AsQueryable(); + if (employeeId is not null) q = q.Where(r => r.EmployeeId == employeeId); + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.CreatedAt) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => Map(r)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task GetAsync(int leaveRequestId, CancellationToken ct = default) + { + var request = await _requests.Query().AsNoTracking() + .Include(r => r.Employee).Include(r => r.LeaveType) + .FirstOrDefaultAsync(r => r.LeaveRequestId == leaveRequestId, ct); + return request is null ? null : Map(request); + } + + public async Task CreateAsync(CreateLeaveRequestRequest request, int actorUserId, CancellationToken ct = default) + { + if (!await _employees.Query().AnyAsync(e => e.EmployeeId == request.EmployeeId, ct)) + throw new NotFoundException($"Employee {request.EmployeeId} was not found."); + if (!await _leaveTypes.Query().AnyAsync(t => t.LeaveTypeId == request.LeaveTypeId, ct)) + throw new NotFoundException($"Leave type {request.LeaveTypeId} was not found."); + if (request.EndDate < request.StartDate) + throw new DomainException(ErrorCodes.Validation, "End date cannot be before start date.", 422); + + var docNo = await _numberSequence.NextAsync("LV", ct); + + // Simplification: calendar-day count (inclusive), not business-day aware — + // flagged for a future improvement, not silently assumed correct for payroll. + var daysCount = (decimal)(request.EndDate.Date - request.StartDate.Date).Days + 1; + + var leaveRequest = new LeaveRequest + { + DocNo = docNo, + EmployeeId = request.EmployeeId, + LeaveTypeId = request.LeaveTypeId, + StartDate = request.StartDate.Date, + EndDate = request.EndDate.Date, + DaysCount = daysCount, + Reason = request.Reason?.Trim(), + Status = LeaveRequestStatus.Draft, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow + }; + + await _requests.AddAsync(leaveRequest, ct); + await _uow.SaveChangesAsync(ct); + + return Map(leaveRequest); + } + + public async Task SubmitAsync(int leaveRequestId, CancellationToken ct = default) + { + var request = await GetTrackedAsync(leaveRequestId, ct); + if (request.Status != LeaveRequestStatus.Draft) + throw new DomainException(ErrorCodes.Conflict, "Only a Draft leave request can be submitted.", 409); + + request.Status = LeaveRequestStatus.Submitted; + await _uow.SaveChangesAsync(ct); + return Map(request); + } + + public async Task ApproveAsync(int leaveRequestId, int actorUserId, CancellationToken ct = default) + { + var request = await GetTrackedAsync(leaveRequestId, ct); + if (request.Status != LeaveRequestStatus.Submitted) + throw new DomainException(ErrorCodes.Conflict, "Only a Submitted leave request can be approved.", 409); + + request.Status = LeaveRequestStatus.Approved; + request.ApprovedBy = actorUserId; + request.ApprovedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + + await _balances.IncrementTakenDaysAsync(request.EmployeeId, request.LeaveTypeId, request.StartDate.Year, request.DaysCount, ct); + + return Map(request); + } + + public async Task RejectAsync(int leaveRequestId, string reason, int actorUserId, CancellationToken ct = default) + { + var request = await GetTrackedAsync(leaveRequestId, ct); + if (request.Status != LeaveRequestStatus.Submitted) + throw new DomainException(ErrorCodes.Conflict, "Only a Submitted leave request can be rejected.", 409); + + request.Status = LeaveRequestStatus.Rejected; + request.ApprovedBy = actorUserId; + request.ApprovedAt = DateTime.UtcNow; + request.RejectionReason = reason.Trim(); + await _uow.SaveChangesAsync(ct); + + return Map(request); + } + + public async Task CancelAsync(int leaveRequestId, CancellationToken ct = default) + { + var request = await GetTrackedAsync(leaveRequestId, ct); + if (request.Status is not (LeaveRequestStatus.Draft or LeaveRequestStatus.Submitted)) + throw new DomainException(ErrorCodes.Conflict, "Only a Draft or Submitted leave request can be cancelled.", 409); + + request.Status = LeaveRequestStatus.Cancelled; + await _uow.SaveChangesAsync(ct); + return Map(request); + } + + public async Task FindApprovedLeaveCoveringAsync(int employeeId, DateTime date, CancellationToken ct = default) + { + var day = date.Date; + return await _requests.Query().AsNoTracking() + .Include(r => r.LeaveType) + .FirstOrDefaultAsync(r => r.EmployeeId == employeeId && r.Status == LeaveRequestStatus.Approved + && r.StartDate <= day && r.EndDate >= day, ct); + } + + private async Task GetTrackedAsync(int leaveRequestId, CancellationToken ct) + => await _requests.GetByIdAsync(leaveRequestId, ct) + ?? throw new NotFoundException($"Leave request {leaveRequestId} was not found."); + + private static LeaveRequestDto Map(LeaveRequest r) => new( + r.LeaveRequestId, r.DocNo, r.EmployeeId, r.Employee?.FullName, r.LeaveTypeId, r.LeaveType?.Name, + r.StartDate, r.EndDate, r.DaysCount, r.Reason, r.Status, r.ApprovedBy, r.ApprovedAt, r.RejectionReason, r.CreatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs b/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs new file mode 100644 index 0000000..d1d63c5 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/LeaveTypeService.cs @@ -0,0 +1,119 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Leave type master service (FR-HR-LV-01, docs/13-BACKEND-HRM-API.md §5). +public sealed class LeaveTypeService : ILeaveTypeService +{ + private readonly IRepository _types; + private readonly IUnitOfWork _uow; + + public LeaveTypeService(IRepository types, IUnitOfWork uow) + { + _types = types; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _types.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.Name, $"%{term}%") || EF.Functions.ILike(t.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(t => t.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(t => t.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => Map(t)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int leaveTypeId, CancellationToken ct = default) + { + var type = await _types.Query().AsNoTracking().FirstOrDefaultAsync(t => t.LeaveTypeId == leaveTypeId, ct); + return type is null ? null : new ETagged(Map(type), type.RowVersion); + } + + public async Task> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _types.Query().AnyAsync(t => t.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A leave type with code '{code}' already exists."); + + var type = new LeaveType + { + Code = code, + Name = request.Name.Trim(), + IsPaid = request.IsPaid, + CountsAsNoPay = request.CountsAsNoPay, + AccrualPerYear = request.AccrualPerYear, + CarryForwardAllowed = request.CarryForwardAllowed, + MaxCarryForwardDays = request.MaxCarryForwardDays, + RequiresApproval = request.RequiresApproval, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _types.AddAsync(type, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(type), type.RowVersion); + } + + public async Task> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(leaveTypeId, ct) + ?? throw new NotFoundException($"Leave type {leaveTypeId} was not found."); + + if (type.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The leave type was modified by another request.", 412); + + type.Name = request.Name.Trim(); + type.IsPaid = request.IsPaid; + type.CountsAsNoPay = request.CountsAsNoPay; + type.AccrualPerYear = request.AccrualPerYear; + type.CarryForwardAllowed = request.CarryForwardAllowed; + type.MaxCarryForwardDays = request.MaxCarryForwardDays; + type.RequiresApproval = request.RequiresApproval; + type.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The leave type was modified by another request.", 412); + } + + return new ETagged(Map(type), type.RowVersion); + } + + public async Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default) + { + var type = await _types.GetByIdAsync(leaveTypeId, ct) + ?? throw new NotFoundException($"Leave type {leaveTypeId} was not found."); + + type.Status = status; + type.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static LeaveTypeDto Map(LeaveType t) => new( + t.LeaveTypeId, t.Code, t.Name, t.IsPaid, t.CountsAsNoPay, t.AccrualPerYear, + t.CarryForwardAllowed, t.MaxCarryForwardDays, t.RequiresApproval, t.Status, t.CreatedAt, t.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/PayrollCalculationService.cs b/Backend/ERPCore/Services/Hrm/PayrollCalculationService.cs new file mode 100644 index 0000000..712414e --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/PayrollCalculationService.cs @@ -0,0 +1,152 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class PayrollCalculationService : IPayrollCalculationService +{ + private readonly IRepository _structures; + private readonly IRepository _attendance; + private readonly IRepository _workShifts; + private readonly IEmployeeLoanService _loans; + private readonly IPayrollStatutorySettingService _statutory; + private readonly ITaxSlabService _taxSlabs; + + public PayrollCalculationService( + IRepository structures, IRepository attendance, + IRepository workShifts, IEmployeeLoanService loans, + IPayrollStatutorySettingService statutory, ITaxSlabService taxSlabs) + { + _structures = structures; + _attendance = attendance; + _workShifts = workShifts; + _loans = loans; + _statutory = statutory; + _taxSlabs = taxSlabs; + } + + public async Task CalculateAsync(Employee employee, int periodYear, int periodMonth, CancellationToken ct = default) + { + var periodStart = new DateTime(periodYear, periodMonth, 1); + var periodEnd = periodStart.AddMonths(1).AddDays(-1); + + var structure = await _structures.Query().AsNoTracking() + .Include(s => s.Lines).ThenInclude(l => l.SalaryComponent) + .Where(s => s.EmployeeId == employee.EmployeeId && s.EffectiveFrom <= periodEnd && (s.EffectiveTo == null || s.EffectiveTo >= periodStart)) + .OrderByDescending(s => s.EffectiveFrom) + .FirstOrDefaultAsync(ct) + ?? throw new NotFoundException($"Employee {employee.EmployeeId} has no salary structure effective for {periodYear}-{periodMonth:00}."); + + var shift = await _workShifts.GetByIdAsync(employee.WorkShiftId, ct) + ?? throw new NotFoundException($"Work shift {employee.WorkShiftId} was not found."); + + var records = await _attendance.Query().AsNoTracking() + .Where(r => r.EmployeeId == employee.EmployeeId && r.AttendanceDate >= periodStart && r.AttendanceDate <= periodEnd) + .ToListAsync(ct); + + var presentDays = records.Count(r => r.AttendanceStatus is AttendanceStatus.Present or AttendanceStatus.HalfDay); + var absentDays = records.Count(r => r.AttendanceStatus == AttendanceStatus.Absent); + // Simplification: all OnLeave days are currently treated as paid — AttendanceRecord doesn't + // carry which LeaveType covered it, so unpaid-leave No-Pay isn't distinguished here yet (docs §8). + var leaveDays = records.Count(r => r.AttendanceStatus == AttendanceStatus.OnLeave); + var otMinutesTotal = records.Sum(r => r.OvertimeMinutes); + var lateMinutesTotal = records.Sum(r => r.LateMinutes); + + var daysInMonth = DateTime.DaysInMonth(periodYear, periodMonth); + var dailyRate = structure.BasicSalary / daysInMonth; + var perMinuteRate = shift.StandardWorkingMinutes > 0 ? dailyRate / shift.StandardWorkingMinutes : 0m; + + var earningLines = structure.Lines.Where(l => l.SalaryComponent!.ComponentType == SalaryComponentType.Earning).ToList(); + var deductionLines = structure.Lines.Where(l => l.SalaryComponent!.ComponentType == SalaryComponentType.Deduction).ToList(); + + var totalAllowances = earningLines.Sum(l => l.Amount); + var otMultiplier = shift.OtMultiplier; + var overtimeAmount = Math.Round(perMinuteRate * otMultiplier * otMinutesTotal, 2); + var grossSalary = structure.BasicSalary + totalAllowances + overtimeAmount; + + var lateDeduction = Math.Round(perMinuteRate * lateMinutesTotal, 2); + var noPayAmount = Math.Round(dailyRate * absentDays, 2); + var otherDeductionsAmount = deductionLines.Sum(l => l.Amount); + + var dueInstallments = await _loans.GetDueInstallmentsAsync(employee.EmployeeId, periodYear, periodMonth, ct); + var loanDeduction = dueInstallments.Sum(i => i.ScheduledAmount); + + var statutory = await _statutory.GetEffectiveAsync(periodStart, ct); + var epfEtfBase = structure.BasicSalary + earningLines.Where(l => l.SalaryComponent!.IsEpfEtfApplicable).Sum(l => l.Amount); + var epfEmployeeAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EpfEmployeeRate, 2); + var epfEmployerAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EpfEmployerRate, 2); + var etfEmployerAmount = statutory is null ? 0m : Math.Round(epfEtfBase * statutory.EtfEmployerRate, 2); + + var taxableIncome = structure.BasicSalary + earningLines.Where(l => l.SalaryComponent!.IsTaxable).Sum(l => l.Amount) + overtimeAmount; + var slabs = await _taxSlabs.GetEffectiveSlabsAsync(periodStart, ct); + var taxAmount = Math.Round(ComputeMarginalTax(taxableIncome, slabs), 2); + + var netSalary = grossSalary - lateDeduction - noPayAmount - loanDeduction - epfEmployeeAmount - taxAmount - otherDeductionsAmount; + + var line = new PayrollLine + { + EmployeeId = employee.EmployeeId, + BasicSalary = structure.BasicSalary, + TotalAllowances = totalAllowances, + OvertimeAmount = overtimeAmount, + GrossSalary = grossSalary, + LateDeductionAmount = lateDeduction, + NoPayAmount = noPayAmount, + LoanDeductionAmount = loanDeduction, + EpfEmployeeAmount = epfEmployeeAmount, + EpfEmployerAmount = epfEmployerAmount, + EtfEmployerAmount = etfEmployerAmount, + TaxAmount = taxAmount, + OtherDeductionsAmount = otherDeductionsAmount, + NetSalary = netSalary, + WorkingDays = presentDays + absentDays + leaveDays, + PresentDays = presentDays, + AbsentDays = absentDays, + LeaveDays = leaveDays, + OtMinutesTotal = otMinutesTotal, + LateMinutesTotal = lateMinutesTotal + }; + + var sort = 0; + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, Label = "Basic Salary", Amount = structure.BasicSalary, SortOrder = sort++ }); + foreach (var l in earningLines) + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, SalaryComponentId = l.SalaryComponentId, Label = l.SalaryComponent!.Name, Amount = l.Amount, SortOrder = sort++ }); + if (overtimeAmount > 0) + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Earning, Label = "Overtime", Amount = overtimeAmount, SortOrder = sort++ }); + + if (lateDeduction > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Late Deduction", Amount = lateDeduction, SortOrder = sort++ }); + if (noPayAmount > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "No Pay", Amount = noPayAmount, SortOrder = sort++ }); + if (loanDeduction > 0) line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Loan", Amount = loanDeduction, SortOrder = sort++ }); + foreach (var l in deductionLines) + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, SalaryComponentId = l.SalaryComponentId, Label = l.SalaryComponent!.Name, Amount = l.Amount, SortOrder = sort++ }); + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "EPF (Employee)", Amount = epfEmployeeAmount, SortOrder = sort++ }); + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.Deduction, Label = "Tax", Amount = taxAmount, SortOrder = sort++ }); + + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.EmployerContribution, Label = "EPF (Employer)", Amount = epfEmployerAmount, SortOrder = sort++ }); + line.Components.Add(new PayrollLineComponent { ComponentCategory = PayrollLineComponentCategory.EmployerContribution, Label = "ETF (Company Contribution)", Amount = etfEmployerAmount, SortOrder = sort++ }); + + return line; + } + + /// Standard ascending marginal-slab computation over taxable income. + private static decimal ComputeMarginalTax(decimal taxableIncome, List slabs) + { + if (taxableIncome <= 0 || slabs.Count == 0) return 0m; + + var tax = 0m; + foreach (var slab in slabs.OrderBy(s => s.LowerBound)) + { + if (taxableIncome <= slab.LowerBound) continue; + var upper = slab.UpperBound ?? taxableIncome; + var taxableInBand = Math.Min(taxableIncome, upper) - slab.LowerBound; + if (taxableInBand <= 0) continue; + tax += taxableInBand * slab.Rate; + } + return tax; + } +} diff --git a/Backend/ERPCore/Services/Hrm/PayrollRunService.cs b/Backend/ERPCore/Services/Hrm/PayrollRunService.cs new file mode 100644 index 0000000..8d94aa7 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/PayrollRunService.cs @@ -0,0 +1,283 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// Payroll run orchestration (FR-HR-PAY-05/06). Maps the business's 5-step flow onto +/// 3 stored states (docs/12-BACKEND-HRM.md A.4): Generate→Draft, Review is a human +/// action, Approve→Approved, Lock→Locked (the point loan installments and attendance +/// batches are stamped consumed — deliberately deferred from Generate so a +/// discarded/regenerated Draft never prematurely consumes them), Generate Payslips is +/// an action gated on Locked. +/// +public sealed class PayrollRunService : IPayrollRunService +{ + private readonly IRepository _runs; + private readonly IRepository _employees; + private readonly IRepository _attendanceBatches; + private readonly IRepository _installments; + private readonly IRepository _loans; + private readonly IRepository _payslips; + private readonly IPayrollCalculationService _calculation; + private readonly IEmployeeLoanService _loanService; + private readonly INumberSequenceService _numberSequence; + private readonly IUnitOfWork _uow; + + public PayrollRunService( + IRepository runs, IRepository employees, IRepository attendanceBatches, + IRepository installments, IRepository loans, IRepository payslips, + IPayrollCalculationService calculation, IEmployeeLoanService loanService, INumberSequenceService numberSequence, IUnitOfWork uow) + { + _runs = runs; + _employees = employees; + _attendanceBatches = attendanceBatches; + _installments = installments; + _loans = loans; + _payslips = payslips; + _calculation = calculation; + _loanService = loanService; + _numberSequence = numberSequence; + _uow = uow; + } + + public async Task> ListAsync( + PageQuery query, int? periodYear, int? periodMonth, PayrollRunStatus? status, CancellationToken ct = default) + { + var q = _runs.Query().AsNoTracking().Include(r => r.Lines).AsQueryable(); + if (periodYear is not null) q = q.Where(r => r.PeriodYear == periodYear); + if (periodMonth is not null) q = q.Where(r => r.PeriodMonth == periodMonth); + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(r => r.GeneratedAt) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); + } + + public async Task GetAsync(int payrollRunId, CancellationToken ct = default) + { + var run = await _runs.Query().AsNoTracking().Include(r => r.Lines) + .FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct); + return run is null ? null : Map(run); + } + + public async Task> ListLinesAsync(int payrollRunId, CancellationToken ct = default) + { + var lines = await _runs.Query().AsNoTracking() + .Where(r => r.PayrollRunId == payrollRunId) + .SelectMany(r => r.Lines) + .Include(l => l.Employee) + .OrderBy(l => l.Employee!.FullName) + .ToListAsync(ct); + return lines.Select(MapLine).ToList(); + } + + public async Task GetLineAsync(int payrollRunId, int lineId, CancellationToken ct = default) + { + var line = await _runs.Query().AsNoTracking() + .Where(r => r.PayrollRunId == payrollRunId) + .SelectMany(r => r.Lines) + .Include(l => l.Employee) + .Include(l => l.Components).ThenInclude(c => c.SalaryComponent) + .FirstOrDefaultAsync(l => l.PayrollLineId == lineId, ct); + return line is null ? null : MapLineDetail(line); + } + + public async Task GenerateAsync(GeneratePayrollRunRequest request, int actorUserId, CancellationToken ct = default) + { + var unconfirmed = await _attendanceBatches.Query().AnyAsync(b => + b.PeriodStart.Year == request.PeriodYear && b.PeriodStart.Month == request.PeriodMonth && + (b.Status == AttendanceBatchStatus.Draft || b.Status == AttendanceBatchStatus.Validated), ct); + if (unconfirmed) + throw new DomainException(ErrorCodes.AttendanceNotConfirmed, + "One or more attendance batches for this period are not yet Confirmed.", 422); + + var employeesQuery = _employees.Query().Where(e => e.Status == EmployeeStatus.Active); + if (request.BranchId is not null) employeesQuery = employeesQuery.Where(e => e.BranchId == request.BranchId); + var employees = await employeesQuery.ToListAsync(ct); + + var docNo = await _numberSequence.NextAsync("PAY", ct); + var run = new PayrollRun + { + DocNo = docNo, + PeriodYear = request.PeriodYear, + PeriodMonth = request.PeriodMonth, + BranchId = request.BranchId, + Status = PayrollRunStatus.Draft, + GeneratedBy = actorUserId, + GeneratedAt = DateTime.UtcNow + }; + + foreach (var employee in employees) + { + try + { + var line = await _calculation.CalculateAsync(employee, request.PeriodYear, request.PeriodMonth, ct); + run.Lines.Add(line); + } + catch (NotFoundException) + { + // No effective salary structure for this employee this period — skip rather than fail the whole run. + } + } + + await _runs.AddAsync(run, ct); + await _uow.SaveChangesAsync(ct); + + return Map(run); + } + + public async Task ApproveAsync(int payrollRunId, int actorUserId, CancellationToken ct = default) + { + var run = await GetTrackedAsync(payrollRunId, ct); + if (run.Status != PayrollRunStatus.Draft) + throw new DomainException(ErrorCodes.Conflict, "Only a Draft payroll run can be approved.", 409); + + run.Status = PayrollRunStatus.Approved; + run.ApprovedBy = actorUserId; + run.ApprovedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + return Map(run); + } + + public async Task LockAsync(int payrollRunId, int actorUserId, CancellationToken ct = default) + { + var run = await GetTrackedAsync(payrollRunId, ct); + if (run.Status != PayrollRunStatus.Approved) + throw new DomainException(ErrorCodes.Conflict, "Only an Approved payroll run can be locked.", 409); + + await _uow.ExecuteInTransactionAsync(async innerCt => + { + // Stamp due loan installments as Deducted, decrementing outstanding balance. + var employeeIds = run.Lines.Select(l => l.EmployeeId).ToList(); + var loans = await _loans.Query().Include(l => l.Installments) + .Where(l => employeeIds.Contains(l.EmployeeId) && l.Status == LoanStatus.Active) + .ToListAsync(innerCt); + foreach (var loan in loans) + { + foreach (var installment in loan.Installments.Where(i => + i.DueYear == run.PeriodYear && i.DueMonth == run.PeriodMonth && i.Status == LoanInstallmentStatus.Pending)) + { + installment.Status = LoanInstallmentStatus.Deducted; + installment.PaidAmount = installment.ScheduledAmount; + installment.PayrollRunId = run.PayrollRunId; + loan.OutstandingBalance = Math.Max(0, loan.OutstandingBalance - installment.ScheduledAmount); + if (loan.OutstandingBalance == 0) loan.Status = LoanStatus.Closed; + } + } + + // Flip consumed attendance batches Confirmed -> UsedInPayroll. + var batches = await _attendanceBatches.Query() + .Where(b => b.PeriodStart.Year == run.PeriodYear && b.PeriodStart.Month == run.PeriodMonth && b.Status == AttendanceBatchStatus.Confirmed) + .ToListAsync(innerCt); + foreach (var batch in batches) batch.Status = AttendanceBatchStatus.UsedInPayroll; + + run.Status = PayrollRunStatus.Locked; + run.LockedBy = actorUserId; + run.LockedAt = DateTime.UtcNow; + + await _uow.SaveChangesAsync(innerCt); + }, ct); + + return Map(run); + } + + public async Task UnlockAsync(int payrollRunId, string reason, int actorUserId, CancellationToken ct = default) + { + var run = await GetTrackedAsync(payrollRunId, ct); + if (run.Status != PayrollRunStatus.Locked) + throw new DomainException(ErrorCodes.PayrollPeriodLocked, "Only a Locked payroll run can be unlocked.", 409); + + await _uow.ExecuteInTransactionAsync(async innerCt => + { + var installments = await _installments.Query() + .Where(i => i.PayrollRunId == run.PayrollRunId) + .Include(i => i.EmployeeLoan) + .ToListAsync(innerCt); + foreach (var installment in installments) + { + installment.Status = LoanInstallmentStatus.Pending; + installment.PaidAmount = null; + installment.PayrollRunId = null; + if (installment.EmployeeLoan is not null) + { + installment.EmployeeLoan.OutstandingBalance += installment.ScheduledAmount; + installment.EmployeeLoan.Status = LoanStatus.Active; + } + } + + var batches = await _attendanceBatches.Query() + .Where(b => b.PeriodStart.Year == run.PeriodYear && b.PeriodStart.Month == run.PeriodMonth && b.Status == AttendanceBatchStatus.UsedInPayroll) + .ToListAsync(innerCt); + foreach (var batch in batches) batch.Status = AttendanceBatchStatus.Confirmed; + + run.Status = PayrollRunStatus.Approved; + run.UnlockedBy = actorUserId; + run.UnlockedAt = DateTime.UtcNow; + run.UnlockReason = reason.Trim(); + + await _uow.SaveChangesAsync(innerCt); + }, ct); + + return Map(run); + } + + public async Task> GeneratePayslipsAsync(int payrollRunId, CancellationToken ct = default) + { + var run = await _runs.Query().Include(r => r.Lines).FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct) + ?? throw new NotFoundException($"Payroll run {payrollRunId} was not found."); + if (run.Status != PayrollRunStatus.Locked) + throw new DomainException(ErrorCodes.Conflict, "Payslips can only be generated for a Locked payroll run.", 409); + + var existingLineIds = await _payslips.Query() + .Where(p => run.Lines.Select(l => l.PayrollLineId).Contains(p.PayrollLineId)) + .Select(p => p.PayrollLineId) + .ToListAsync(ct); + + var created = new List(); + foreach (var line in run.Lines.Where(l => !existingLineIds.Contains(l.PayrollLineId))) + { + var payslip = new Payslip { PayrollLineId = line.PayrollLineId, GeneratedAt = DateTime.UtcNow }; + created.Add(payslip); + await _payslips.AddAsync(payslip, ct); + } + await _uow.SaveChangesAsync(ct); + + var all = await _payslips.Query().AsNoTracking() + .Where(p => run.Lines.Select(l => l.PayrollLineId).Contains(p.PayrollLineId)) + .ToListAsync(ct); + return all.Select(p => new PayslipDto(p.PayslipId, p.PayrollLineId, p.GeneratedAt, p.ReleasedAt, p.ReleasedBy)).ToList(); + } + + private async Task GetTrackedAsync(int payrollRunId, CancellationToken ct) + => await _runs.Query().Include(r => r.Lines).FirstOrDefaultAsync(r => r.PayrollRunId == payrollRunId, ct) + ?? throw new NotFoundException($"Payroll run {payrollRunId} was not found."); + + private static PayrollRunDto Map(PayrollRun r) => new( + r.PayrollRunId, r.DocNo, r.PeriodYear, r.PeriodMonth, r.BranchId, r.Status, + r.GeneratedBy, r.GeneratedAt, r.ApprovedBy, r.ApprovedAt, r.LockedBy, r.LockedAt, + r.UnlockedBy, r.UnlockedAt, r.UnlockReason, + r.Lines.Sum(l => l.GrossSalary), r.Lines.Sum(l => l.NetSalary), r.Lines.Count); + + private static PayrollLineDto MapLine(PayrollLine l) => new( + l.PayrollLineId, l.PayrollRunId, l.EmployeeId, l.Employee?.EmployeeCode, l.Employee?.FullName, + l.BasicSalary, l.TotalAllowances, l.OvertimeAmount, l.GrossSalary, + l.LateDeductionAmount, l.NoPayAmount, l.LoanDeductionAmount, + l.EpfEmployeeAmount, l.EpfEmployerAmount, l.EtfEmployerAmount, l.TaxAmount, l.OtherDeductionsAmount, l.NetSalary, + l.WorkingDays, l.PresentDays, l.AbsentDays, l.LeaveDays, l.OtMinutesTotal, l.LateMinutesTotal); + + private static PayrollLineDetailDto MapLineDetail(PayrollLine l) => new( + MapLine(l), + l.Components.OrderBy(c => c.SortOrder).Select(c => new PayrollLineComponentDto( + c.ComponentCategory, c.SalaryComponentId, c.Label, c.Amount, c.SortOrder)).ToList()); +} diff --git a/Backend/ERPCore/Services/Hrm/PayrollStatutorySettingService.cs b/Backend/ERPCore/Services/Hrm/PayrollStatutorySettingService.cs new file mode 100644 index 0000000..250c75d --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/PayrollStatutorySettingService.cs @@ -0,0 +1,63 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Effective-dated EPF/ETF settings (FR-HR-PAY-04, docs/13-BACKEND-HRM-API.md §6). +public sealed class PayrollStatutorySettingService : IPayrollStatutorySettingService +{ + private readonly IRepository _settings; + private readonly IUnitOfWork _uow; + + public PayrollStatutorySettingService(IRepository settings, IUnitOfWork uow) + { + _settings = settings; + _uow = uow; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + var rows = await _settings.Query().AsNoTracking().OrderByDescending(s => s.EffectiveFrom).ToListAsync(ct); + return rows.Select(Map).ToList(); + } + + public async Task CreateAsync(UpsertPayrollStatutorySettingRequest request, int actorUserId, CancellationToken ct = default) + { + var previous = await _settings.Query() + .Where(s => s.EffectiveTo == null) + .OrderByDescending(s => s.EffectiveFrom) + .FirstOrDefaultAsync(ct); + if (previous is not null) previous.EffectiveTo = request.EffectiveFrom.Date.AddDays(-1); + + var setting = new PayrollStatutorySetting + { + EpfEmployeeRate = request.EpfEmployeeRate, + EpfEmployerRate = request.EpfEmployerRate, + EtfEmployerRate = request.EtfEmployerRate, + OtMultiplierDefault = request.OtMultiplierDefault, + EffectiveFrom = request.EffectiveFrom.Date, + CreatedBy = actorUserId, + CreatedAt = DateTime.UtcNow + }; + + await _settings.AddAsync(setting, ct); + await _uow.SaveChangesAsync(ct); + + return Map(setting); + } + + public async Task GetEffectiveAsync(DateTime asOf, CancellationToken ct = default) + { + return await _settings.Query().AsNoTracking() + .Where(s => s.EffectiveFrom <= asOf && (s.EffectiveTo == null || s.EffectiveTo >= asOf)) + .OrderByDescending(s => s.EffectiveFrom) + .FirstOrDefaultAsync(ct); + } + + private static PayrollStatutorySettingDto Map(PayrollStatutorySetting s) => new( + s.PayrollStatutorySettingId, s.EpfEmployeeRate, s.EpfEmployerRate, s.EtfEmployerRate, s.OtMultiplierDefault, s.EffectiveFrom, s.EffectiveTo); +} diff --git a/Backend/ERPCore/Services/Hrm/PayslipService.cs b/Backend/ERPCore/Services/Hrm/PayslipService.cs new file mode 100644 index 0000000..ddc9f8b --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/PayslipService.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Text; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +public sealed class PayslipService : IPayslipService +{ + private readonly IRepository _payslips; + + public PayslipService(IRepository payslips) => _payslips = payslips; + + public async Task GetAsync(int payslipId, CancellationToken ct = default) + { + var payslip = await _payslips.Query().AsNoTracking().FirstOrDefaultAsync(p => p.PayslipId == payslipId, ct); + return payslip is null ? null : new PayslipDto(payslip.PayslipId, payslip.PayrollLineId, payslip.GeneratedAt, payslip.ReleasedAt, payslip.ReleasedBy); + } + + public async Task RenderHtmlAsync(int payslipId, CancellationToken ct = default) + { + var payslip = await _payslips.Query().AsNoTracking() + .Include(p => p.PayrollLine!).ThenInclude(l => l.Employee) + .Include(p => p.PayrollLine!).ThenInclude(l => l.Components) + .Include(p => p.PayrollLine!).ThenInclude(l => l.PayrollRun) + .FirstOrDefaultAsync(p => p.PayslipId == payslipId, ct); + if (payslip?.PayrollLine is null) return null; + + var line = payslip.PayrollLine; + var employee = line.Employee; + var run = line.PayrollRun; + + var rows = new StringBuilder(); + foreach (var group in line.Components.GroupBy(c => c.ComponentCategory)) + { + var title = group.Key switch + { + PayrollLineComponentCategory.Earning => "Earnings", + PayrollLineComponentCategory.Deduction => "Deductions", + _ => "Employer Contributions (informational, not deducted)" + }; + rows.Append($"{WebUtility.HtmlEncode(title)}"); + foreach (var c in group.OrderBy(c => c.SortOrder)) + rows.Append($"{WebUtility.HtmlEncode(c.Label)}{c.Amount:N2}"); + } + + const string style = "body{font-family:Arial,sans-serif;font-size:14px;color:#111;}" + + "table{width:100%;border-collapse:collapse;} td{padding:4px 8px;}" + + ".totals td{font-weight:bold;border-top:2px solid #333;}" + + "h2{margin-bottom:0;} .sub{color:#555;margin-top:2px;}"; + + var html = new StringBuilder(); + html.Append("Payslip") + .Append("

Payslip

") + .Append("
Employee: ").Append(WebUtility.HtmlEncode(employee?.FullName ?? string.Empty)) + .Append(" (").Append(WebUtility.HtmlEncode(employee?.EmployeeCode ?? string.Empty)).Append(")
") + .Append("
Period: ").Append(run?.PeriodMonth.ToString("00")).Append('/').Append(run?.PeriodYear) + .Append(" · Run ").Append(WebUtility.HtmlEncode(run?.DocNo ?? string.Empty)).Append("
") + .Append("").Append(rows) + .Append("") + .Append("") + .Append("
Gross Salary").Append(line.GrossSalary.ToString("N2")).Append("
Net Salary").Append(line.NetSalary.ToString("N2")).Append("
") + .Append("

Generated ").Append(payslip.GeneratedAt.ToString("yyyy-MM-dd HH:mm")).Append(" UTC

") + .Append(""); + + return html.ToString(); + } +} diff --git a/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs b/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs new file mode 100644 index 0000000..85150a6 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/SalaryComponentService.cs @@ -0,0 +1,112 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// SalaryComponent master service (FR-HR-PAY-01, docs/13-BACKEND-HRM-API.md §6). +public sealed class SalaryComponentService : ISalaryComponentService +{ + private readonly IRepository _components; + private readonly IUnitOfWork _uow; + + public SalaryComponentService(IRepository components, IUnitOfWork uow) + { + _components = components; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _components.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%") || EF.Functions.ILike(c.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(c => c.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(c => c.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(c => Map(c)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int salaryComponentId, CancellationToken ct = default) + { + var component = await _components.Query().AsNoTracking().FirstOrDefaultAsync(c => c.SalaryComponentId == salaryComponentId, ct); + return component is null ? null : new ETagged(Map(component), component.RowVersion); + } + + public async Task> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _components.Query().AnyAsync(c => c.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A salary component with code '{code}' already exists."); + + var component = new SalaryComponent + { + Code = code, + Name = request.Name.Trim(), + ComponentType = request.ComponentType, + IsTaxable = request.IsTaxable, + IsEpfEtfApplicable = request.IsEpfEtfApplicable, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _components.AddAsync(component, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(component), component.RowVersion); + } + + public async Task> UpdateAsync( + int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var component = await _components.GetByIdAsync(salaryComponentId, ct) + ?? throw new NotFoundException($"Salary component {salaryComponentId} was not found."); + + if (component.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The salary component was modified by another request.", 412); + + component.Name = request.Name.Trim(); + component.IsTaxable = request.IsTaxable; + component.IsEpfEtfApplicable = request.IsEpfEtfApplicable; + component.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The salary component was modified by another request.", 412); + } + + return new ETagged(Map(component), component.RowVersion); + } + + public async Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default) + { + var component = await _components.GetByIdAsync(salaryComponentId, ct) + ?? throw new NotFoundException($"Salary component {salaryComponentId} was not found."); + + component.Status = status; + component.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static SalaryComponentDto Map(SalaryComponent c) => new( + c.SalaryComponentId, c.Code, c.Name, c.ComponentType, c.IsTaxable, c.IsEpfEtfApplicable, c.Status, c.CreatedAt, c.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Hrm/TaxSlabService.cs b/Backend/ERPCore/Services/Hrm/TaxSlabService.cs new file mode 100644 index 0000000..eab0339 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/TaxSlabService.cs @@ -0,0 +1,67 @@ +using ERPCore.Domain.Entities; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// Configurable marginal tax slabs (FR-HR-PAY-04, docs/13-BACKEND-HRM-API.md §6). +public sealed class TaxSlabService : ITaxSlabService +{ + private readonly IRepository _slabs; + private readonly IUnitOfWork _uow; + + public TaxSlabService(IRepository slabs, IUnitOfWork uow) + { + _slabs = slabs; + _uow = uow; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + var rows = await _slabs.Query().AsNoTracking() + .OrderByDescending(s => s.EffectiveFrom).ThenBy(s => s.LowerBound) + .ToListAsync(ct); + return rows.Select(Map).ToList(); + } + + public async Task CreateAsync(CreateTaxSlabRequest request, CancellationToken ct = default) + { + if (request.UpperBound is not null && request.UpperBound <= request.LowerBound) + throw new DomainException(ErrorCodes.TaxSlabGapInvalid, "Upper bound must be greater than the lower bound.", 422); + + var overlapping = await _slabs.Query().AnyAsync(s => + s.EffectiveFrom.Date == request.EffectiveFrom.Date && + s.LowerBound < (request.UpperBound ?? decimal.MaxValue) && + (s.UpperBound ?? decimal.MaxValue) > request.LowerBound, ct); + if (overlapping) + throw new DomainException(ErrorCodes.TaxSlabGapInvalid, "This slab overlaps an existing slab for the same effective date.", 422); + + var slab = new TaxSlab + { + EffectiveFrom = request.EffectiveFrom.Date, + LowerBound = request.LowerBound, + UpperBound = request.UpperBound, + Rate = request.Rate, + CreatedAt = DateTime.UtcNow + }; + + await _slabs.AddAsync(slab, ct); + await _uow.SaveChangesAsync(ct); + + return Map(slab); + } + + public async Task> GetEffectiveSlabsAsync(DateTime asOf, CancellationToken ct = default) + { + return await _slabs.Query().AsNoTracking() + .Where(s => s.EffectiveFrom <= asOf && (s.EffectiveTo == null || s.EffectiveTo >= asOf)) + .OrderBy(s => s.LowerBound) + .ToListAsync(ct); + } + + private static TaxSlabDto Map(TaxSlab s) => new(s.TaxSlabId, s.EffectiveFrom, s.EffectiveTo, s.LowerBound, s.UpperBound, s.Rate); +} diff --git a/Backend/ERPCore/Services/Hrm/WorkShiftService.cs b/Backend/ERPCore/Services/Hrm/WorkShiftService.cs new file mode 100644 index 0000000..15d45b1 --- /dev/null +++ b/Backend/ERPCore/Services/Hrm/WorkShiftService.cs @@ -0,0 +1,127 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Hrm; + +/// +/// WorkShift master service (FR-HR-MD-01) — the attendance baseline Late/Early/OT +/// figures are computed against (docs/12-BACKEND-HRM.md A.3). +/// +public sealed class WorkShiftService : IWorkShiftService +{ + private readonly IRepository _shifts; + private readonly IUnitOfWork _uow; + + public WorkShiftService(IRepository shifts, IUnitOfWork uow) + { + _shifts = shifts; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _shifts.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(w => EF.Functions.ILike(w.Name, $"%{term}%") || EF.Functions.ILike(w.Code, $"%{term}%")); + } + if (status is not null) q = q.Where(w => w.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(w => w.Name) + .Skip(query.Skip).Take(query.PageSize) + .Select(w => Map(w)) + .ToListAsync(ct); + + return PagedResponse.Create(rows, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int workShiftId, CancellationToken ct = default) + { + var shift = await _shifts.Query().AsNoTracking().FirstOrDefaultAsync(w => w.WorkShiftId == workShiftId, ct); + return shift is null ? null : new ETagged(Map(shift), shift.RowVersion); + } + + public async Task> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _shifts.Query().AnyAsync(w => w.Code.ToLower() == code.ToLower(), ct)) + throw new ConflictException($"A work shift with code '{code}' already exists."); + + var shift = new WorkShift + { + Code = code, + Name = request.Name.Trim(), + StartTime = request.StartTime, + EndTime = request.EndTime, + IsOvernight = request.IsOvernight, + GraceMinutes = request.GraceMinutes, + BreakMinutes = request.BreakMinutes, + StandardWorkingMinutes = request.StandardWorkingMinutes, + OtMultiplier = request.OtMultiplier, + WorkingDaysMask = request.WorkingDaysMask, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _shifts.AddAsync(shift, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(shift), shift.RowVersion); + } + + public async Task> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var shift = await _shifts.GetByIdAsync(workShiftId, ct) + ?? throw new NotFoundException($"Work shift {workShiftId} was not found."); + + if (shift.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The work shift was modified by another request.", 412); + + shift.Name = request.Name.Trim(); + shift.StartTime = request.StartTime; + shift.EndTime = request.EndTime; + shift.IsOvernight = request.IsOvernight; + shift.GraceMinutes = request.GraceMinutes; + shift.BreakMinutes = request.BreakMinutes; + shift.StandardWorkingMinutes = request.StandardWorkingMinutes; + shift.OtMultiplier = request.OtMultiplier; + shift.WorkingDaysMask = request.WorkingDaysMask; + shift.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The work shift was modified by another request.", 412); + } + + return new ETagged(Map(shift), shift.RowVersion); + } + + public async Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default) + { + var shift = await _shifts.GetByIdAsync(workShiftId, ct) + ?? throw new NotFoundException($"Work shift {workShiftId} was not found."); + + shift.Status = status; + shift.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + private static WorkShiftDto Map(WorkShift w) => new( + w.WorkShiftId, w.Code, w.Name, w.StartTime, w.EndTime, w.IsOvernight, + w.GraceMinutes, w.BreakMinutes, w.StandardWorkingMinutes, w.OtMultiplier, w.WorkingDaysMask, + w.Status, w.CreatedAt, w.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAttendanceComputationService.cs b/Backend/ERPCore/Services/Interfaces/IAttendanceComputationService.cs new file mode 100644 index 0000000..27431c3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAttendanceComputationService.cs @@ -0,0 +1,19 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Services.Interfaces; + +/// +/// Pure attendance-figure computation against a baseline +/// (docs/12-BACKEND-HRM.md A.3) — the direct analog of +/// : invoked from +/// , never from a controller. +/// +public interface IAttendanceComputationService +{ + /// + /// Computes WorkingMinutes/LateMinutes/EarlyLeaveMinutes/OvertimeMinutes and the + /// derived AttendanceStatus for one record's CheckIn/CheckOut against its shift, + /// mutating the record in place. + /// + void Compute(AttendanceRecord record, WorkShift shift, bool hasApprovedLeave, bool isHoliday, bool isWeekOff); +} diff --git a/Backend/ERPCore/Services/Interfaces/IAttendanceUploadService.cs b/Backend/ERPCore/Services/Interfaces/IAttendanceUploadService.cs new file mode 100644 index 0000000..4d6044f --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IAttendanceUploadService.cs @@ -0,0 +1,26 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Attendance upload/validate/confirm pipeline (FR-HR-ATT, docs/13-BACKEND-HRM-API.md §4). +public interface IAttendanceUploadService +{ + Task> ListBatchesAsync( + PageQuery query, AttendanceBatchStatus? status, int? periodYear, int? periodMonth, CancellationToken ct = default); + Task GetBatchAsync(int batchId, CancellationToken ct = default); + Task UploadAsync( + Stream fileContent, string fileName, DateTime periodStart, DateTime periodEnd, int actorUserId, CancellationToken ct = default); + + Task> ListRecordsAsync(int batchId, RowValidationStatus? status, CancellationToken ct = default); + Task UpdateRecordAsync(int batchId, int recordId, UpdateAttendanceRecordRequest request, int actorUserId, CancellationToken ct = default); + Task ResolveDuplicateAsync(int batchId, ResolveDuplicateRequest request, CancellationToken ct = default); + + Task ValidateAsync(int batchId, CancellationToken ct = default); + Task ConfirmAsync(int batchId, int actorUserId, CancellationToken ct = default); + Task UnlockAsync(int batchId, string reason, int actorUserId, CancellationToken ct = default); + + /// Generates the upload template in the same column shape the parser expects. + (byte[] Content, string ContentType, string FileName) GenerateTemplate(bool asCsv); +} diff --git a/Backend/ERPCore/Services/Interfaces/IBranchService.cs b/Backend/ERPCore/Services/Interfaces/IBranchService.cs new file mode 100644 index 0000000..526b207 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IBranchService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Branch master business logic (docs/13-BACKEND-HRM-API.md §2). +public interface IBranchService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int branchId, CancellationToken ct = default); + Task> CreateAsync(CreateBranchRequest request, CancellationToken ct = default); + Task> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IDashboardService.cs b/Backend/ERPCore/Services/Interfaces/IDashboardService.cs new file mode 100644 index 0000000..3d90a88 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IDashboardService.cs @@ -0,0 +1,9 @@ +using ERPCore.Dtos.Dashboard; + +namespace ERPCore.Services.Interfaces; + +/// Cross-domain aggregate stats for the dashboard overview. +public interface IDashboardService +{ + Task GetStatsAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs b/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs new file mode 100644 index 0000000..40227fb --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IDepartmentService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Department master business logic, incl. self-nesting cycle guard (docs/13-BACKEND-HRM-API.md §2). +public interface IDepartmentService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int departmentId, CancellationToken ct = default); + Task> CreateAsync(CreateDepartmentRequest request, CancellationToken ct = default); + Task> UpdateAsync(int departmentId, UpdateDepartmentRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int departmentId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IDesignationService.cs b/Backend/ERPCore/Services/Interfaces/IDesignationService.cs new file mode 100644 index 0000000..78b2eb8 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IDesignationService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface IDesignationService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int designationId, CancellationToken ct = default); + Task> CreateAsync(CreateDesignationRequest request, CancellationToken ct = default); + Task> UpdateAsync(int designationId, UpdateDesignationRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int designationId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeDocumentService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeDocumentService.cs new file mode 100644 index 0000000..54fac19 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeDocumentService.cs @@ -0,0 +1,15 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Uploaded staff document ("Doc") business logic (docs/13-BACKEND-HRM-API.md §3). +public interface IEmployeeDocumentService +{ + Task> ListAsync(int employeeId, CancellationToken ct = default); + Task UploadAsync( + int employeeId, UploadEmployeeDocumentRequest request, Stream fileContent, string fileName, string contentType, + int actorUserId, CancellationToken ct = default); + Task<(Stream Content, string FileName, string ContentType)> DownloadAsync(int employeeId, int documentId, CancellationToken ct = default); + Task SetStatusAsync(int employeeId, int documentId, EmployeeDocumentStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeLoanService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeLoanService.cs new file mode 100644 index 0000000..c708fba --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeLoanService.cs @@ -0,0 +1,14 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Loan/Advance business logic (FR-HR-PAY-03, docs/13-BACKEND-HRM-API.md §6). +public interface IEmployeeLoanService +{ + Task> ListAsync(int employeeId, CancellationToken ct = default); + Task GetAsync(int employeeId, int loanId, CancellationToken ct = default); + Task CreateAsync(int employeeId, CreateEmployeeLoanRequest request, int actorUserId, CancellationToken ct = default); + + /// Due, not-yet-deducted installments for an employee in a given period — consumed by PayrollCalculationService. + Task> GetDueInstallmentsAsync(int employeeId, int periodYear, int periodMonth, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeSalaryStructureService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeSalaryStructureService.cs new file mode 100644 index 0000000..2285207 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeSalaryStructureService.cs @@ -0,0 +1,11 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Effective-dated salary structure business logic (FR-HR-PAY-02, docs/13-BACKEND-HRM-API.md §6). +public interface IEmployeeSalaryStructureService +{ + Task> ListHistoryAsync(int employeeId, CancellationToken ct = default); + Task GetCurrentAsync(int employeeId, CancellationToken ct = default); + Task CreateAsync(int employeeId, CreateSalaryStructureRequest request, int actorUserId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeService.cs new file mode 100644 index 0000000..ebd5d3e --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeService.cs @@ -0,0 +1,20 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Employee (staff) business logic (docs/13-BACKEND-HRM-API.md §3). +public interface IEmployeeService +{ + Task> ListAsync( + PageQuery query, EmployeeStatus? status, int? departmentId, int? designationId, int? branchId, CancellationToken ct = default); + Task?> GetAsync(int employeeId, CancellationToken ct = default); + Task> CreateAsync(CreateEmployeeRequest request, int actorUserId, CancellationToken ct = default); + Task> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default); + Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default); + + Task> ListBankDetailsAsync(int employeeId, CancellationToken ct = default); + Task> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmployeeUserLinkService.cs b/Backend/ERPCore/Services/Interfaces/IEmployeeUserLinkService.cs new file mode 100644 index 0000000..f43c0b4 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmployeeUserLinkService.cs @@ -0,0 +1,22 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// +/// Bidirectional Employee<->User soft-match/link logic (docs/12-BACKEND-HRM.md A.5, +/// Part B.3.2). Lookups are advisory only; linking is always an explicit, human-confirmed +/// action — never automatic, even on an exact email match. +/// +public interface IEmployeeUserLinkService +{ + /// Given an email (typically entered on a Create-User form), find an unlinked Staff record match. + Task FindStaffCandidateByEmailAsync(string email, CancellationToken ct = default); + + /// Given an email (typically entered on a Create-Employee form), find an unlinked User account match. + Task FindUserCandidateByEmailAsync(string email, CancellationToken ct = default); + + /// Links an existing Employee to an existing User. Throws EMPLOYEE_ALREADY_LINKED/USER_ALREADY_LINKED if either side is already linked to someone else. + Task LinkAsync(int employeeId, int userId, CancellationToken ct = default); + + Task UnlinkAsync(int employeeId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs b/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs new file mode 100644 index 0000000..de766c3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IEmploymentTypeService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface IEmploymentTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int employmentTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateEmploymentTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int employmentTypeId, UpdateEmploymentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int employmentTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs b/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs index 0f85c37..ac6a715 100644 --- a/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs +++ b/Backend/ERPCore/Services/Interfaces/IFifoCostingService.cs @@ -29,11 +29,22 @@ public interface IFifoCostingService Task> ConsumeAsync( int itemId, int warehouseId, int? batchId, decimal qtyBase, CancellationToken ct = default); - /// Append an immutable ledger entry (value = qtyBase × unitCost). + /// + /// Append an immutable ledger entry. Value defaults to round(qtyBase × unitCost, 4). + /// + /// + /// Posts this exact value instead of deriving it from qty × unit cost. Needed when the + /// authoritative figure is a total rather than a rate: a production receipt must carry the + /// run's cost pool exactly, but unitCost = pool / goodQty rounds to 6 dp, and at + /// 100+ units that rounding error exceeds the ledger's 4 dp tick — so the derived value + /// would drift from the pool (FR-MFG-13). Also used by leftover and cancel returns, whose + /// value is the exact consumed residual. Omit for every rate-driven movement. + /// Task PostLedgerAsync( int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId, Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, - string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default); + string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default, + decimal? valueOverride = null); /// Current on-hand (Σ open-layer qtyRemaining) for an item at a warehouse. Task GetOnHandAsync(int itemId, int warehouseId, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs b/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs new file mode 100644 index 0000000..a58dcb2 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IHrDocumentTypeService.cs @@ -0,0 +1,16 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Staff document-type catalog ("DocType") business logic (docs/13-BACKEND-HRM-API.md §2). +public interface IHrDocumentTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int hrDocumentTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateHrDocumentTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int hrDocumentTypeId, UpdateHrDocumentTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int hrDocumentTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IHrReportService.cs b/Backend/ERPCore/Services/Interfaces/IHrReportService.cs new file mode 100644 index 0000000..b308d26 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IHrReportService.cs @@ -0,0 +1,19 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// +/// Read-only HRM aggregation reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6) — no +/// new entities, queries over Attendance/Payroll/Leave/Document data that already +/// exists, mirroring how StockController answers on-hand/ledger queries today. +/// +public interface IHrReportService +{ + Task> AttendanceSummaryAsync(int periodYear, int periodMonth, int? departmentId, CancellationToken ct = default); + Task> OvertimeReportAsync(int periodYear, int periodMonth, CancellationToken ct = default); + Task> LateArrivalReportAsync(int periodYear, int periodMonth, CancellationToken ct = default); + Task> PayrollRegisterAsync(int payrollRunId, CancellationToken ct = default); + Task> SalaryHistoryAsync(int employeeId, CancellationToken ct = default); + Task> LeaveBalanceReportAsync(int year, CancellationToken ct = default); + Task> DocumentExpiryReportAsync(int withinDays, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ILeaveBalanceService.cs b/Backend/ERPCore/Services/Interfaces/ILeaveBalanceService.cs new file mode 100644 index 0000000..3d597a9 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ILeaveBalanceService.cs @@ -0,0 +1,12 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface ILeaveBalanceService +{ + Task> ListAsync(int employeeId, int? year, CancellationToken ct = default); + Task> ApplyAdjustmentsAsync(int employeeId, UpdateLeaveBalancesRequest request, CancellationToken ct = default); + + /// Increments TakenDays for an approved leave request; called by ILeaveRequestService.ApproveAsync. + Task IncrementTakenDaysAsync(int employeeId, int leaveTypeId, int year, decimal days, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ILeaveRequestService.cs b/Backend/ERPCore/Services/Interfaces/ILeaveRequestService.cs new file mode 100644 index 0000000..cbff7fd --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ILeaveRequestService.cs @@ -0,0 +1,20 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; +using ERPCore.Domain.Enums; + +namespace ERPCore.Services.Interfaces; + +/// Leave request business logic (FR-HR-LV-02, docs/13-BACKEND-HRM-API.md §5). +public interface ILeaveRequestService +{ + Task> ListAsync(PageQuery query, int? employeeId, LeaveRequestStatus? status, CancellationToken ct = default); + Task GetAsync(int leaveRequestId, CancellationToken ct = default); + Task CreateAsync(CreateLeaveRequestRequest request, int actorUserId, CancellationToken ct = default); + Task SubmitAsync(int leaveRequestId, CancellationToken ct = default); + Task ApproveAsync(int leaveRequestId, int actorUserId, CancellationToken ct = default); + Task RejectAsync(int leaveRequestId, string reason, int actorUserId, CancellationToken ct = default); + Task CancelAsync(int leaveRequestId, CancellationToken ct = default); + + /// True if the employee has an Approved leave request covering the given date (used by Attendance's OnLeave classification). + Task FindApprovedLeaveCoveringAsync(int employeeId, DateTime date, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs b/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs new file mode 100644 index 0000000..a9bbccd --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ILeaveTypeService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface ILeaveTypeService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int leaveTypeId, CancellationToken ct = default); + Task> CreateAsync(CreateLeaveTypeRequest request, CancellationToken ct = default); + Task> UpdateAsync(int leaveTypeId, UpdateLeaveTypeRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int leaveTypeId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPayrollCalculationService.cs b/Backend/ERPCore/Services/Interfaces/IPayrollCalculationService.cs new file mode 100644 index 0000000..0d41631 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPayrollCalculationService.cs @@ -0,0 +1,14 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Services.Interfaces; + +/// +/// Payroll calculation domain service (FR-HR-PAY-05) — the payroll analog of +/// . Computes one +/// (with its breakdown) per employee per period, +/// per the formula in docs/12-BACKEND-HRM.md B.4. Never invoked from a controller directly. +/// +public interface IPayrollCalculationService +{ + Task CalculateAsync(Employee employee, int periodYear, int periodMonth, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPayrollRunService.cs b/Backend/ERPCore/Services/Interfaces/IPayrollRunService.cs new file mode 100644 index 0000000..7557f4a --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPayrollRunService.cs @@ -0,0 +1,20 @@ +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Payroll run approval workflow (FR-HR-PAY-05/06, docs/13-BACKEND-HRM-API.md §6). +public interface IPayrollRunService +{ + Task> ListAsync(PageQuery query, int? periodYear, int? periodMonth, PayrollRunStatus? status, CancellationToken ct = default); + Task GetAsync(int payrollRunId, CancellationToken ct = default); + Task> ListLinesAsync(int payrollRunId, CancellationToken ct = default); + Task GetLineAsync(int payrollRunId, int lineId, CancellationToken ct = default); + + Task GenerateAsync(GeneratePayrollRunRequest request, int actorUserId, CancellationToken ct = default); + Task ApproveAsync(int payrollRunId, int actorUserId, CancellationToken ct = default); + Task LockAsync(int payrollRunId, int actorUserId, CancellationToken ct = default); + Task UnlockAsync(int payrollRunId, string reason, int actorUserId, CancellationToken ct = default); + Task> GeneratePayslipsAsync(int payrollRunId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPayrollStatutorySettingService.cs b/Backend/ERPCore/Services/Interfaces/IPayrollStatutorySettingService.cs new file mode 100644 index 0000000..1128abf --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPayrollStatutorySettingService.cs @@ -0,0 +1,10 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface IPayrollStatutorySettingService +{ + Task> ListAsync(CancellationToken ct = default); + Task CreateAsync(UpsertPayrollStatutorySettingRequest request, int actorUserId, CancellationToken ct = default); + Task GetEffectiveAsync(DateTime asOf, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IPayslipService.cs b/Backend/ERPCore/Services/Interfaces/IPayslipService.cs new file mode 100644 index 0000000..57b2e41 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IPayslipService.cs @@ -0,0 +1,10 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +/// Payslip retrieval + HTML print view (FR-HR-PAY-07, docs/13-BACKEND-HRM-API.md §6). No PDF dependency in this phase. +public interface IPayslipService +{ + Task GetAsync(int payslipId, CancellationToken ct = default); + Task RenderHtmlAsync(int payslipId, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IProductionRunService.cs b/Backend/ERPCore/Services/Interfaces/IProductionRunService.cs new file mode 100644 index 0000000..865edb3 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IProductionRunService.cs @@ -0,0 +1,99 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Production; + +namespace ERPCore.Services.Interfaces; + +/// +/// Production run lifecycle (docs/30 §D.2–D.3, FR-MFG-08..19). Every stock-affecting +/// action runs inside a single ExecuteInTransactionAsync scope and consumes stock +/// only through IFifoCostingService (NFR-02/NFR-05). +/// +public interface IProductionRunService +{ + Task> ListAsync( + PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId, + CancellationToken ct = default); + + Task?> GetAsync(int runId, CancellationToken ct = default); + + /// + /// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge, + /// scales all quantities by targetQty / terminalOutputQtyPerBatch, issues a + /// PRD-… document number, and leaves entry stages Ready with the rest Waiting. + /// + Task> CreateAsync(CreateRunRequest request, CancellationToken ct = default); + + /// + /// Per-run quantity override on a stage that has not started + /// (409 STAGE_NOT_EDITABLE once it has). Re-evaluates the stage's readiness, + /// since raising an upstream input's planned quantity can un-ready it. + /// + Task UpdateStageQuantitiesAsync( + int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default); + + /// + /// Ready → InProgress (FR-MFG-10). FIFO-consumes every Stock input from the run + /// warehouse in one transaction and stamps actualStartAt. + /// Consumes max(0, plannedBase − consumedQty) per input, 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). + /// + Task StartStageAsync(int runId, int runStageId, CancellationToken ct = default); + + /// + /// InProgress → Done (FR-MFG-11). Records produced and scrapped quantities per + /// output plus the custom field values. A re-complete after a rework overwrites + /// the previous figures rather than adding to them. + /// + Task CompleteStageAsync( + int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default); + + /// + /// Done → Approved (FR-MFG-12/13). Non-terminal: hands WIP to the children, + /// defaulting to the full available quantity. Terminal: posts the production receipt — + /// a finished-goods layer costed at costPool / goodQty — and completes the run. + /// + Task ApproveStageAsync( + int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default); + + /// + /// Later partial transfer of a remainder held on an already-Approved stage (FR-MFG-12), + /// never exceeding produced − scrapped − already transferred. + /// + Task TransferAsync( + int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default); + + /// + /// Returns unconsumed material to stock before the receipt closes the pool (FR-MFG-14). + /// The inbound layer is created at the weighted cost actually consumed for that + /// input, so the move is cost-preserving and the pool reduces by exactly what leaves it. + /// 409 RUN_COST_CLOSED once the run has completed. + /// + Task ReturnLeftoverAsync( + int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default); + + /// + /// Downstream reject (FR-MFG-15): this stage rejects the work it received, its delivering + /// parents revert Approved → InProgress with their transferred quantities pulled + /// back, and this stage returns to Waiting. Consumed stock stays consumed. + /// + Task RejectIntakeAsync( + int runId, int runStageId, RejectRequest request, CancellationToken ct = default); + + /// + /// Terminal reject (FR-MFG-16): resets the whole run to its starting stages, increments + /// reworkCount and snapshots the discarded figures into the event history. + /// Already-consumed material remains in the cost pool. + /// + Task RejectTerminalAsync( + int runId, int runStageId, RejectRequest request, CancellationToken ct = default); + + /// + /// Cancels an in-progress run (FR-MFG-17). Net consumed-and-not-returned stock goes back + /// at its consumed weighted cost; scrapped output quantities are written off on the event. + /// 409 RUN_NOT_CANCELLABLE for a completed run. + /// + Task CancelAsync(int runId, CancelRunRequest request, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IProductionTemplateService.cs b/Backend/ERPCore/Services/Interfaces/IProductionTemplateService.cs new file mode 100644 index 0000000..94667a6 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IProductionTemplateService.cs @@ -0,0 +1,33 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Production; + +namespace ERPCore.Services.Interfaces; + +/// +/// Production template CRUD and graph validation (docs/30 §D.1, FR-MFG-01..07). +/// +public interface IProductionTemplateService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + + Task?> GetAsync(int templateId, CancellationToken ct = default); + + Task> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default); + + /// + /// Replaces the whole graph. Requires the current row version (If-Match) and is + /// refused with 409 TEMPLATE_IN_USE while any run of this template is + /// InProgress — edit-lock stands in for versioning (FR-MFG-06). + /// + Task> UpdateAsync( + int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default); + + /// + /// Activate/deactivate (FR-MFG-01). Deliberately not edit-locked: deactivating is + /// the "never delete a referenced master" path (FR-MD-08) and only stops new runs + /// being started, so it must stay available while runs are in flight. + /// + Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs b/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs new file mode 100644 index 0000000..97c6a0e --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ISalaryComponentService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface ISalaryComponentService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int salaryComponentId, CancellationToken ct = default); + Task> CreateAsync(CreateSalaryComponentRequest request, CancellationToken ct = default); + Task> UpdateAsync(int salaryComponentId, UpdateSalaryComponentRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int salaryComponentId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/ITaxSlabService.cs b/Backend/ERPCore/Services/Interfaces/ITaxSlabService.cs new file mode 100644 index 0000000..7ac7111 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/ITaxSlabService.cs @@ -0,0 +1,10 @@ +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface ITaxSlabService +{ + Task> ListAsync(CancellationToken ct = default); + Task CreateAsync(CreateTaxSlabRequest request, CancellationToken ct = default); + Task> GetEffectiveSlabsAsync(DateTime asOf, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IUomConverter.cs b/Backend/ERPCore/Services/Interfaces/IUomConverter.cs new file mode 100644 index 0000000..14f1a1a --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IUomConverter.cs @@ -0,0 +1,35 @@ +using ERPCore.Domain.Entities; + +namespace ERPCore.Services.Interfaces; + +/// +/// Converts a quantity and its per-UOM cost into the item's base UOM. +/// +/// +/// Everything in the FIFO engine — StockLayer, StockLedger, +/// IFifoCostingService.ConsumeAsync — works exclusively in base UOM, while +/// documents let a user enter a line in any UOM the item has a conversion for. This is the +/// one place that bridges the two. +/// Extracted from GrnService's private ToBaseAsync when manufacturing +/// needed the same conversion for stage stock inputs (docs/30 never mentions UOM +/// conversion, but STAGE_INPUT.uom_id is a free FK — without this, an input +/// specified in "Box of 12" would consume 1 base unit instead of 12 and silently +/// mis-cost the run). +/// +public interface IUomConverter +{ + /// + /// Returns the quantity and unit cost restated in 's base UOM. + /// A no-op when already is the base UOM. Throws 422 when no + /// conversion is defined for the item from that UOM to its base. + /// + Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync( + Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default); + + /// + /// Quantity-only conversion, for callers that have no per-UOM cost to restate (a + /// production stage input declares a quantity; its cost comes from the FIFO layers it + /// consumes, not from the document). + /// + Task ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs b/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs new file mode 100644 index 0000000..8534189 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IWorkShiftService.cs @@ -0,0 +1,15 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Hrm; + +namespace ERPCore.Services.Interfaces; + +public interface IWorkShiftService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int workShiftId, CancellationToken ct = default); + Task> CreateAsync(CreateWorkShiftRequest request, CancellationToken ct = default); + Task> UpdateAsync(int workShiftId, UpdateWorkShiftRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int workShiftId, EntityStatus status, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/ItemService.cs b/Backend/ERPCore/Services/ItemService.cs index c28808c..79a24c5 100644 --- a/Backend/ERPCore/Services/ItemService.cs +++ b/Backend/ERPCore/Services/ItemService.cs @@ -79,7 +79,7 @@ public sealed class ItemService : IItemService .Select(i => new ItemListItemDto( i.ItemId, i.Sku, i.Name, i.CategoryId, i.SubCategoryId, i.BrandId, i.BaseUomId, i.DefaultVendorId, - i.StockNature, i.TrackingMode, i.TaxClass, i.Status)) + i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status)) .ToListAsync(ct); return PagedResponse.Create(rows, query.Page, query.PageSize, total); @@ -117,6 +117,7 @@ public sealed class ItemService : IItemService StockNature = request.StockNature, TrackingMode = request.TrackingMode, TaxClass = request.TaxClass, + SalePrice = request.SalePrice, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow }; @@ -158,6 +159,7 @@ public sealed class ItemService : IItemService item.StockNature = request.StockNature; item.TrackingMode = request.TrackingMode; item.TaxClass = request.TaxClass; + item.SalePrice = request.SalePrice; item.UpdatedAt = DateTime.UtcNow; await SaveGuardingConcurrencyAsync(ct); @@ -348,7 +350,7 @@ public sealed class ItemService : IItemService private static ItemDetailDto ToDetail(Item i) => new( i.ItemId, i.Sku, i.Name, i.Description, i.CategoryId, i.SubCategoryId, i.BrandId, i.BaseUomId, i.DefaultVendorId, - i.StockNature, i.TrackingMode, i.TaxClass, i.Status, + i.StockNature, i.TrackingMode, i.TaxClass, i.SalePrice, i.Status, i.ReorderSettings .OrderBy(r => r.WarehouseId) .Select(r => new ItemReorderDto(r.WarehouseId, r.ReorderPoint, r.ReorderQty)) diff --git a/Backend/ERPCore/Services/Production/ProductionGraphValidator.cs b/Backend/ERPCore/Services/Production/ProductionGraphValidator.cs new file mode 100644 index 0000000..bc09891 --- /dev/null +++ b/Backend/ERPCore/Services/Production/ProductionGraphValidator.cs @@ -0,0 +1,211 @@ +using ERPCore.Domain.Enums; +using ERPCore.System.Errors; + +namespace ERPCore.Services.Production; + +/// +/// Validates a template stage graph (FR-MFG-02, FR-MFG-04, FR-MFG-05) and throws the +/// matching 422 GRAPH_* domain error on the first violation. +/// +/// +/// This is an algorithm, not a service — pure, synchronous, no database and +/// no DI, in the spirit of FifoCostingService being the one place FIFO lives. It +/// deliberately has no interface: Services/Interfaces/ exists for things that get +/// injected, and registering this would buy nothing. +/// It works entirely in keys, never database ids, so the identical code path +/// runs for a POST (where nothing has an id yet) and a PUT (where most things do). All +/// database-dependent checks — does this item exist, is it Active, does this UOM exist — +/// stay in ProductionTemplateService so this stays free of I/O. +/// Checks run cheapest-first and fail fast, and every message names the offending +/// stage or edge so the canvas can focus it (docs/21 §2). +/// +public static class ProductionGraphValidator +{ + public sealed record InputDraft(int Index, StageInputSource Source, int? ItemId, string? FromOutputKey); + + public sealed record OutputDraft(string Key, string Name, int? ItemId); + + public sealed record StageDraft( + string Key, string Name, IReadOnlyList Inputs, IReadOnlyList Outputs); + + public readonly record struct EdgeDraft(string ParentKey, string ChildKey); + + /// + /// Throws on the first rule violation; returns silently for a valid graph. + /// + public static void Validate(IReadOnlyList stages, IReadOnlyList edges) + { + // 1 — structural hygiene. These are all blocked in the canvas at draw time + // (docs/21 §2); this is the server backstop for a hand-rolled request. + if (stages.Count == 0) + throw Invalid("A template must have at least one stage."); + + var stageByKey = new Dictionary(StringComparer.Ordinal); + foreach (var s in stages) + { + if (string.IsNullOrWhiteSpace(s.Key)) + throw Invalid("Every stage requires a key."); + if (!stageByKey.TryAdd(s.Key, s)) + throw Invalid($"Duplicate stage key '{s.Key}'."); + } + + // Output keys are unique across the whole template, not just within a stage — + // an Upstream input names one by key alone, so a collision would be ambiguous. + var outputOwner = new Dictionary(StringComparer.Ordinal); + foreach (var s in stages) + foreach (var o in s.Outputs) + { + if (string.IsNullOrWhiteSpace(o.Key)) + throw Invalid($"Every output of stage '{s.Name}' requires a key."); + if (!outputOwner.TryAdd(o.Key, s.Key)) + throw Invalid($"Duplicate output key '{o.Key}' (stage '{s.Name}')."); + } + + var edgeSet = new HashSet<(string, string)>(); + foreach (var e in edges) + { + if (!stageByKey.ContainsKey(e.ParentKey) || !stageByKey.ContainsKey(e.ChildKey)) + throw Invalid($"Edge '{e.ParentKey}' → '{e.ChildKey}' references a stage that is not in the payload."); + if (string.Equals(e.ParentKey, e.ChildKey, StringComparison.Ordinal)) + throw Invalid($"Stage '{stageByKey[e.ParentKey].Name}' cannot connect to itself."); + if (!edgeSet.Add((e.ParentKey, e.ChildKey))) + throw Invalid($"Duplicate edge '{stageByKey[e.ParentKey].Name}' → '{stageByKey[e.ChildKey].Name}'."); + } + + var children = stages.ToDictionary(s => s.Key, _ => new List(), StringComparer.Ordinal); + var parents = stages.ToDictionary(s => s.Key, _ => new List(), StringComparer.Ordinal); + foreach (var (parent, child) in edgeSet) + { + children[parent].Add(child); + parents[child].Add(parent); + } + + // 2 — cycle detection by Kahn's algorithm. If the toposort can't reach every + // stage, the unreached set is exactly the stages trapped in (or downstream of) + // a cycle, which is what the client highlights. + var indegree = stages.ToDictionary(s => s.Key, s => parents[s.Key].Count, StringComparer.Ordinal); + var queue = new Queue(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)); + var sorted = 0; + while (queue.Count > 0) + { + var key = queue.Dequeue(); + sorted++; + foreach (var child in children[key]) + if (--indegree[child] == 0) + queue.Enqueue(child); + } + + if (sorted != stages.Count) + { + var trapped = indegree.Where(kv => kv.Value > 0).Select(kv => stageByKey[kv.Key].Name); + throw new DomainException(ErrorCodes.GraphCycle, + $"The stage graph contains a cycle involving: {string.Join(", ", trapped)}.", 422); + } + + // 3 — exactly one terminal stage (multiple starts may converge, but the line must + // end in one place, because that single stage is what receives the finished good). + var terminals = stages.Where(s => children[s.Key].Count == 0).ToList(); + if (terminals.Count != 1) + throw new DomainException(ErrorCodes.GraphTerminalCount, + terminals.Count == 0 + ? "The stage graph has no final stage." + : $"The stage graph must converge to exactly one final stage, but {terminals.Count} have no outgoing connection: {string.Join(", ", terminals.Select(t => t.Name))}.", + 422); + + var terminal = terminals[0]; + var entries = stages.Where(s => parents[s.Key].Count == 0).ToList(); + + // 4 — connectivity. One traversal each way subsumes all three of "no disconnected + // stages", "every stage reachable from an entry" and "every stage reaches the + // terminal": an isolated stage simply appears in neither set. A lone stage is both + // an entry and the terminal, so it falls out correctly with no special case. + // + // Kept as defence in depth, but note it is unreachable once checks 2 and 3 pass: + // in an acyclic graph every stage is reachable from some source, and if exactly one + // stage lacks an outbound edge then every stage necessarily reaches it. An isolated + // stage therefore surfaces as GRAPH_TERMINAL_COUNT (it is a second terminal), which + // is the better message anyway because it names both offenders. Verified by the M2 + // smoke test, which asserts that behaviour explicitly. + var fromEntry = Reach(entries.Select(s => s.Key), children); + var toTerminal = Reach([terminal.Key], parents); + + var stranded = stages.Where(s => !fromEntry.Contains(s.Key) || !toTerminal.Contains(s.Key)).ToList(); + if (stranded.Count > 0) + throw new DomainException(ErrorCodes.GraphDisconnected, + $"Every stage must sit on a path from a starting stage to '{terminal.Name}', but these do not: {string.Join(", ", stranded.Select(s => s.Name))}.", + 422); + + // 5 — input sources (FR-MFG-04). An Upstream input may only draw from an output of + // a DIRECT parent: allowing a grandparent's output would mean WIP skipping a stage. + foreach (var s in stages) + { + var allowed = parents[s.Key] + .SelectMany(p => stageByKey[p].Outputs.Select(o => o.Key)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var input in s.Inputs) + { + if (input.Source == StageInputSource.Upstream) + { + if (input.ItemId is not null) + throw new DomainException(ErrorCodes.GraphInputSourceInvalid, + $"Input {input.Index + 1} of stage '{s.Name}' is Upstream and cannot also reference an item.", 422); + if (string.IsNullOrWhiteSpace(input.FromOutputKey)) + throw new DomainException(ErrorCodes.GraphInputSourceInvalid, + $"Input {input.Index + 1} of stage '{s.Name}' is Upstream but names no source output.", 422); + if (!allowed.Contains(input.FromOutputKey)) + { + var owner = outputOwner.TryGetValue(input.FromOutputKey, out var ownerKey) + ? $"'{stageByKey[ownerKey].Name}' is not a direct parent of '{s.Name}'" + : $"output '{input.FromOutputKey}' does not exist"; + throw new DomainException(ErrorCodes.GraphInputSourceInvalid, + $"Input {input.Index + 1} of stage '{s.Name}' must draw from a direct parent's output — {owner}.", 422); + } + } + else + { + if (input.ItemId is null) + throw new DomainException(ErrorCodes.GraphInputSourceInvalid, + $"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and requires an item.", 422); + if (!string.IsNullOrWhiteSpace(input.FromOutputKey)) + throw new DomainException(ErrorCodes.GraphInputSourceInvalid, + $"Input {input.Index + 1} of stage '{s.Name}' is a Stock input and cannot reference an upstream output.", 422); + } + } + } + + // 6 — outputs (FR-MFG-05). The terminal stage produces exactly one real Item (the + // finished good the receipt creates a layer for); every other output is internal + // WIP and must stay item-less, or it would imply stock that never exists. + if (terminal.Outputs.Count != 1) + throw new DomainException(ErrorCodes.TerminalOutputItemRequired, + $"The final stage '{terminal.Name}' must have exactly one output, but has {terminal.Outputs.Count}.", 422); + + if (terminal.Outputs[0].ItemId is null) + throw new DomainException(ErrorCodes.TerminalOutputItemRequired, + $"The output of the final stage '{terminal.Name}' must reference the finished item.", 422); + + foreach (var s in stages.Where(s => !ReferenceEquals(s, terminal))) + foreach (var o in s.Outputs.Where(o => o.ItemId is not null)) + throw Invalid( + $"Output '{o.Name}' of stage '{s.Name}' is intermediate work-in-progress and cannot reference an item — only the final stage produces a stocked item."); + } + + /// Set of keys reachable from following . + private static HashSet Reach(IEnumerable roots, Dictionary> next) + { + var seen = new HashSet(roots, StringComparer.Ordinal); + var queue = new Queue(seen); + while (queue.Count > 0) + foreach (var n in next[queue.Dequeue()]) + if (seen.Add(n)) + queue.Enqueue(n); + return seen; + } + + /// + /// Structural violations the client already prevents and docs/30 §D.4 assigns no + /// dedicated code to — still 422, still carrying a message that names the culprit. + /// + private static DomainException Invalid(string message) => new(ErrorCodes.Validation, message, 422); +} diff --git a/Backend/ERPCore/Services/Production/ProductionJson.cs b/Backend/ERPCore/Services/Production/ProductionJson.cs new file mode 100644 index 0000000..1ddba58 --- /dev/null +++ b/Backend/ERPCore/Services/Production/ProductionJson.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ERPCore.Services.Production; + +/// +/// The one serializer for every jsonb column in the manufacturing module — +/// template_stages.field_defs, run_stages.field_defs/field_values +/// and run_stage_events.payload. +/// +/// +/// Those columns hold a pre-serialized string rather than a mapped POCO, +/// following the AuditLog.ChangeSet precedent. Beyond consistency this keeps +/// AuditScribe honest: an owned/typed jsonb mapping would surface the nested +/// objects as their own change-tracker entries and scatter spurious audit rows across +/// the new tables. +/// Everything is written through here so a column can only ever contain canonical +/// JSON — the raw client string is never stored, which means a malformed +/// fieldDefs can't reach the database and can't later break a run created from +/// that template. +/// +public static class ProductionJson +{ + /// Matches the API's own wire format (docs/11 §1.3): camelCase, enums as strings. + public static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + public static string Serialize(T value) => JsonSerializer.Serialize(value, Options); + + /// + /// Reads a stored column back. Returns for null/blank so a + /// caller never has to null-check; a parse failure is a genuine data-integrity problem + /// and is allowed to throw. + /// + public static T Deserialize(string? json, T fallback) + => string.IsNullOrWhiteSpace(json) ? fallback : JsonSerializer.Deserialize(json, Options) ?? fallback; +} diff --git a/Backend/ERPCore/Services/Production/ProductionRunService.cs b/Backend/ERPCore/Services/Production/ProductionRunService.cs new file mode 100644 index 0000000..2d25829 --- /dev/null +++ b/Backend/ERPCore/Services/Production/ProductionRunService.cs @@ -0,0 +1,1258 @@ +using System.Text.Json; +using ERPCore.Common.Http; +using ERPCore.Domain; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Production; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Production; + +/// +/// Production run lifecycle (docs/30 §D.2–D.3, FR-MFG-08..19). +/// +public sealed class ProductionRunService : IProductionRunService +{ + private readonly IRepository _runs; + private readonly IRepository _templates; + private readonly IRepository _warehouses; + private readonly IRepository _bins; + private readonly IRepository _items; + private readonly IRepository _reasonCodes; + private readonly IFifoCostingService _fifo; + private readonly IUomConverter _uomConverter; + private readonly INumberSequenceService _numbers; + private readonly IUnitOfWork _uow; + private readonly ICurrentUser _currentUser; + + public ProductionRunService( + IRepository runs, IRepository templates, + IRepository warehouses, IRepository bins, + IRepository items, IRepository reasonCodes, + IFifoCostingService fifo, IUomConverter uomConverter, + INumberSequenceService numbers, IUnitOfWork uow, ICurrentUser currentUser) + { + _runs = runs; + _templates = templates; + _warehouses = warehouses; + _bins = bins; + _items = items; + _reasonCodes = reasonCodes; + _fifo = fifo; + _uomConverter = uomConverter; + _numbers = numbers; + _uow = uow; + _currentUser = currentUser; + } + + // --- reads --------------------------------------------------------------- + + public async Task> ListAsync( + PageQuery query, ProductionRunStatus? status, int? templateId, int? warehouseId, + CancellationToken ct = default) + { + var q = _runs.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.DocNo, $"%{term}%")); + } + + if (status is not null) q = q.Where(r => r.Status == status); + if (templateId is not null) q = q.Where(r => r.TemplateId == templateId); + if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId); + + var total = await q.CountAsync(ct); + + // Projected to an anonymous type first, then mapped client-side. EF Core 10 cannot + // translate a record constructor call that sits inside a projection alongside + // aggregates — the same failure mode already recorded for WarehouseValuationDto in + // Backend/PROGRESS.md (2026-07-28). + var rows = await q + .OrderByDescending(r => r.RunId) // newest first (docs/30 §D.2) + .Skip(query.Skip).Take(query.PageSize) + .Select(r => new + { + r.RunId, + r.DocNo, + r.TemplateId, + TemplateName = r.Template!.Name, + r.TargetQty, + r.Status, + r.ReworkCount, + r.WarehouseId, + Waiting = r.Stages.Count(s => s.Status == ProductionStageStatus.Waiting), + Ready = r.Stages.Count(s => s.Status == ProductionStageStatus.Ready), + InProgress = r.Stages.Count(s => s.Status == ProductionStageStatus.InProgress), + Done = r.Stages.Count(s => s.Status == ProductionStageStatus.Done), + Approved = r.Stages.Count(s => s.Status == ProductionStageStatus.Approved), + // Only the terminal output carries an item, so this is unambiguous. + Finished = r.Stages.SelectMany(s => s.Outputs) + .Where(o => o.ItemId != null) + .Select(o => new { o.ItemId, ItemName = o.Item!.Name }) + .FirstOrDefault(), + r.CreatedBy, + r.CreatedAt, + r.CompletedAt + }) + .ToListAsync(ct); + + var items = rows.Select(r => new RunSummaryDto( + r.RunId, r.DocNo, r.TemplateId, r.TemplateName, r.TargetQty, r.Status, r.ReworkCount, + new StageSummaryDto(r.Waiting, r.Ready, r.InProgress, r.Done, r.Approved), + r.WarehouseId, r.Finished?.ItemId, r.Finished?.ItemName, + r.CreatedBy, r.CreatedAt, r.CompletedAt)).ToList(); + + return PagedResponse.Create(items, query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int runId, CancellationToken ct = default) + { + var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct); + return run is null ? null : new ETagged(ToGraphDto(run), run.RowVersion); + } + + // --- creation ------------------------------------------------------------ + + public async Task> CreateAsync(CreateRunRequest request, CancellationToken ct = default) + { + var template = await _templates.Query().AsNoTracking() + .Include(t => t.Stages).ThenInclude(s => s.Inputs) + .Include(t => t.Stages).ThenInclude(s => s.Outputs) + .Include(t => t.Edges) + .FirstOrDefaultAsync(t => t.TemplateId == request.TemplateId, ct) + ?? throw new NotFoundException($"Production template {request.TemplateId} was not found."); + + if (template.Status != EntityStatus.Active) + throw new DomainException(ErrorCodes.TemplateInactive, + $"Template '{template.Code}' is inactive and cannot start new runs.", 422); + + if (!await _warehouses.Query().AsNoTracking().AnyAsync(w => w.WarehouseId == request.WarehouseId, ct)) + throw new DomainException(ErrorCodes.Validation, + $"Warehouse {request.WarehouseId} does not exist.", 422); + + if (request.OutputBinId is not null) + { + var binWarehouse = await _bins.Query().AsNoTracking() + .Where(b => b.BinId == request.OutputBinId) + .Select(b => (int?)b.WarehouseId).FirstOrDefaultAsync(ct); + + if (binWarehouse is null) + throw new DomainException(ErrorCodes.Validation, $"Bin {request.OutputBinId} does not exist.", 422); + if (binWarehouse != request.WarehouseId) + throw new DomainException(ErrorCodes.Validation, + $"Bin {request.OutputBinId} belongs to warehouse {binWarehouse}, not {request.WarehouseId}.", 422); + } + + // Re-derive the terminal defensively. The graph was validated when the template was + // saved, but a run is a long-lived copy and starting from a malformed graph would + // produce a run that can never complete. + var hasOutbound = template.Edges.Select(e => e.ParentStageId).ToHashSet(); + var hasInbound = template.Edges.Select(e => e.ChildStageId).ToHashSet(); + var terminals = template.Stages.Where(s => !hasOutbound.Contains(s.StageId)).ToList(); + + if (terminals.Count != 1) + throw new DomainException(ErrorCodes.GraphTerminalCount, + $"Template '{template.Code}' does not have exactly one final stage and cannot be run.", 422); + + var terminalOutput = terminals[0].Outputs.SingleOrDefault() + ?? throw new DomainException(ErrorCodes.TerminalOutputItemRequired, + $"The final stage of template '{template.Code}' must have exactly one output.", 422); + + if (terminalOutput.ItemId is null) + throw new DomainException(ErrorCodes.TerminalOutputItemRequired, + $"The final stage of template '{template.Code}' must produce a real item.", 422); + + if (terminalOutput.QtyPerBatch <= 0) + throw new DomainException(ErrorCodes.Validation, + "The final stage's output quantity per batch must be greater than zero.", 422); + + // Scale from the UNROUNDED ratio and round each quantity once, so a repeating + // scale factor (e.g. 50/3) does not compound its error across every stage. The + // stored ScaleFactor is the same ratio rounded to the column's 6 dp, for display. + var ratio = request.TargetQty / terminalOutput.QtyPerBatch; + + var run = new ProductionRun + { + TemplateId = template.TemplateId, + WarehouseId = request.WarehouseId, + OutputBinId = request.OutputBinId, + TargetQty = request.TargetQty, + ScaleFactor = Math.Round(ratio, 6, MidpointRounding.AwayFromZero), + Status = ProductionRunStatus.InProgress, + ReworkCount = 0, + CreatedBy = _currentUser.AuditUserId, + CreatedAt = DateTime.UtcNow + }; + + // Wired through navigation properties so EF resolves every generated key itself on a + // single SaveChanges — no intermediate saves to materialise ids. + var stageByTemplateStageId = new Dictionary(); + var outputByTemplateOutputId = new Dictionary(); + + // Pass 1 — stages and outputs. + foreach (var ts in template.Stages.OrderBy(s => s.StageId)) + { + var isEntry = !hasInbound.Contains(ts.StageId); + var rs = new RunStage + { + Run = run, + TemplateStageId = ts.StageId, + Name = ts.Name, + RoleLabel = ts.RoleLabel, + EstimatedMinutes = ts.EstimatedMinutes, + PosX = ts.PosX, + PosY = ts.PosY, + // FR-MFG-09: entry stages have nothing to wait for. + Status = isEntry ? ProductionStageStatus.Ready : ProductionStageStatus.Waiting, + FieldDefs = ts.FieldDefs, + FieldValues = null + }; + run.Stages.Add(rs); + stageByTemplateStageId[ts.StageId] = rs; + + foreach (var o in ts.Outputs.OrderBy(o => o.OutputId)) + { + var ro = new RunStageOutput + { + RunStage = rs, + ItemId = o.ItemId, + Name = o.Name, + UomId = o.UomId, + PlannedQty = Scale(o.QtyPerBatch, ratio) + }; + rs.Outputs.Add(ro); + outputByTemplateOutputId[o.OutputId] = ro; + } + } + + // Pass 2 — inputs, which may point at any output built above. + foreach (var ts in template.Stages.OrderBy(s => s.StageId)) + { + var rs = stageByTemplateStageId[ts.StageId]; + foreach (var i in ts.Inputs.OrderBy(i => i.InputId)) + { + rs.Inputs.Add(new RunStageInput + { + RunStage = rs, + Source = i.Source, + ItemId = i.ItemId, + FromRunOutput = i.FromOutputId is null ? null : outputByTemplateOutputId[i.FromOutputId.Value], + UomId = i.UomId, + PlannedQty = Scale(i.QtyPerBatch, ratio) + }); + } + } + + // Pass 3 — edges, copied so a later template edit can never rewrite this run's shape. + foreach (var e in template.Edges) + { + run.Edges.Add(new RunEdge + { + Run = run, + ParentRunStage = stageByTemplateStageId[e.ParentStageId], + ChildRunStage = stageByTemplateStageId[e.ChildStageId] + }); + } + + await _uow.ExecuteInTransactionAsync(async token => + { + // Must be inside the transaction — NumberSequenceService enlists in it. + run.DocNo = await _numbers.NextAsync(DocumentTypes.Production, token); + await _runs.AddAsync(run, token); + }, ct); + + return await GetAsync(run.RunId, ct) + ?? throw new NotFoundException($"Production run {run.RunId} was not found after creation."); + } + + private static decimal Scale(decimal qtyPerBatch, decimal ratio) + => Math.Round(qtyPerBatch * ratio, 4, MidpointRounding.AwayFromZero); + + // --- quantity override --------------------------------------------------- + + public async Task UpdateStageQuantitiesAsync( + int runId, int runStageId, UpdateStageQuantitiesRequest request, CancellationToken ct = default) + { + RunStage? stage = null; + + await _uow.ExecuteInTransactionAsync(async token => + { + var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token) + ?? throw new NotFoundException($"Production run {runId} was not found."); + + if (run.Status != ProductionRunStatus.InProgress) + throw new ConflictException($"Run {run.DocNo} is {run.Status}; quantities can only be edited while it is in progress."); + + stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId) + ?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}."); + + // FR-MFG-08: editable until the stage starts. Once started, consumption has + // already happened against the planned figures. + if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready)) + throw new DomainException(ErrorCodes.StageNotEditable, + $"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409); + + foreach (var line in request.Inputs) + { + var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id) + ?? throw new DomainException(ErrorCodes.Validation, + $"Input {line.Id} is not on stage '{stage.Name}'.", 422); + input.PlannedQty = line.PlannedQty; + } + + foreach (var line in request.Outputs) + { + var output = stage.Outputs.FirstOrDefault(o => o.RunOutputId == line.Id) + ?? throw new DomainException(ErrorCodes.Validation, + $"Output {line.Id} is not on stage '{stage.Name}'.", 422); + output.PlannedQty = line.PlannedQty; + } + + // Raising an upstream input's planned quantity means the deliveries that made + // this stage Ready no longer cover it, so it must fall back to Waiting. docs/30 + // does not state this; it is the only behaviour consistent with FR-MFG-09. + RecomputeReadiness(stage); + + AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new + { + inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }), + outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty }) + }); + }, ct); + + var refreshed = await GetAsync(runId, ct) + ?? throw new NotFoundException($"Production run {runId} was not found."); + return refreshed.Value.Stages.First(s => s.RunStageId == runStageId); + } + + // --- stage actions ------------------------------------------------------- + + public async Task StartStageAsync( + int runId, int runStageId, CancellationToken ct = default) + { + var consumed = new List(); + var ledgerRefs = new List(); + + await _uow.ExecuteInTransactionAsync(async token => + { + var (run, stage) = await LoadForActionAsync(runId, runStageId, token); + + if (stage.Status != ProductionStageStatus.Ready) + throw new DomainException(ErrorCodes.StageNotReady, + $"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409); + + var now = DateTime.UtcNow; + var actor = _currentUser.AuditUserId; + + // Running balances accumulated per item across the loop: layers written in this + // transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of + // the same item would otherwise both post the same RunningBalance. Same pattern + // as TransferService.DispatchAsync. + var balances = new Dictionary(); + + foreach (var input in stage.Inputs + .Where(i => i.Source == StageInputSource.Stock) + .OrderBy(i => i.RunInputId)) + { + var item = await _items.Query().AsNoTracking() + .FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token) + ?? throw new DomainException(ErrorCodes.Validation, + $"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422); + + // PlannedQty is in the input's declared UOM; ConsumedQty is in the item's + // base UOM (the only unit FIFO and the ledger speak). Compare in base. + var plannedBase = await _uomConverter.ToBaseQtyAsync(item, input.UomId, input.PlannedQty, token); + var delta = plannedBase - input.ConsumedQty; + if (delta <= 0) continue; // rework restart with no increase — nothing to draw + + var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token); + var value = segments.Sum(s => s.Qty * s.UnitCost); + var unitCost = delta == 0 ? 0m : value / delta; + + if (!balances.TryGetValue(item.ItemId, out var balance)) + balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token); + balance -= delta; + balances[item.ItemId] = balance; + + var entry = await _fifo.PostLedgerAsync( + item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor, + Direction.Out, delta, unitCost, balance, + LedgerSourceTypes.ProductionIssue, run.RunId, now, token); + ledgerRefs.Add(entry); + + input.ConsumedQty += delta; + input.ConsumedValue += value; + + consumed.Add(new ConsumedInputDto( + input.RunInputId, item.ItemId, delta, value, + segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList())); + } + + stage.Status = ProductionStageStatus.InProgress; + stage.ActualStartAt = now; + stage.ActualEndAt = null; + + AddEvent(run, stage, RunStageEventType.Start, null, new + { + consumed = consumed.Select(c => new + { + c.RunInputId, c.ItemId, c.Qty, c.Value, + layers = c.ConsumedLayers.Select(l => new { l.LayerId, l.Qty, l.UnitCost }) + }) + }); + }, ct); + + var stageDto = await RequireStageAsync(runId, runStageId, ct); + return new StartStageResultDto( + runStageId, stageDto.Status, stageDto.ActualStartAt, + consumed, ledgerRefs.Select(l => l.LedgerId).ToList(), stageDto); + } + + public async Task CompleteStageAsync( + int runId, int runStageId, CompleteStageRequest request, CancellationToken ct = default) + { + await _uow.ExecuteInTransactionAsync(async token => + { + var (run, stage) = await LoadForActionAsync(runId, runStageId, token); + + if (stage.Status != ProductionStageStatus.InProgress) + throw new DomainException(ErrorCodes.StageNotInProgress, + $"Stage '{stage.Name}' is {stage.Status}; only an in-progress stage can be completed.", 409); + + ValidateRequiredFields(stage, request.FieldValues); + + foreach (var line in request.Outputs) + { + var output = stage.Outputs.FirstOrDefault(o => o.RunOutputId == line.RunOutputId) + ?? throw new DomainException(ErrorCodes.Validation, + $"Output {line.RunOutputId} is not on stage '{stage.Name}'.", 422); + + if (line.ScrappedQty > 0) + { + if (line.ScrapReasonCodeId is null) + throw new DomainException(ErrorCodes.ReasonCodeRequired, + $"A scrap reason code is required for output '{output.Name}'.", 400); + await EnsureProductionReasonAsync(line.ScrapReasonCodeId.Value, token); + } + + var good = line.ProducedQty - line.ScrappedQty; + if (good < 0) + throw new DomainException(ErrorCodes.Validation, + $"Scrapped {line.ScrappedQty} exceeds produced {line.ProducedQty} on output '{output.Name}'.", 422); + + // A re-complete after a rework OVERWRITES rather than accumulates — adding + // would double the produced quantity on every rework pass. Not stated in + // docs/30; recorded as a decision. + if (good < output.TransferredQty) + throw new DomainException(ErrorCodes.TransferExceedsAvailable, + $"Output '{output.Name}' has already transferred {output.TransferredQty}; " + + $"the new good quantity {good} cannot be lower.", 422); + + output.ProducedQty = line.ProducedQty; + output.ScrappedQty = line.ScrappedQty; + output.ScrapReasonCodeId = line.ScrappedQty > 0 ? line.ScrapReasonCodeId : null; + } + + stage.Status = ProductionStageStatus.Done; + stage.ActualEndAt = DateTime.UtcNow; + stage.FieldValues = request.FieldValues is null + ? null + : ProductionJson.Serialize(request.FieldValues); + + AddEvent(run, stage, RunStageEventType.Complete, null, new + { + outputs = request.Outputs.Select(o => new + { + o.RunOutputId, o.ProducedQty, o.ScrappedQty, o.ScrapReasonCodeId + }), + fieldValues = request.FieldValues + }); + }, ct); + + return await RequireStageAsync(runId, runStageId, ct); + } + + public async Task ApproveStageAsync( + int runId, int runStageId, ApproveStageRequest request, CancellationToken ct = default) + { + var transfers = new List(); + var ledgerRefs = new List(); + Receipt? pending = null; + CostPoolDto? costPool = null; + var runStatus = ProductionRunStatus.InProgress; + + await _uow.ExecuteInTransactionAsync(async token => + { + var (run, stage) = await LoadForActionAsync(runId, runStageId, token); + + if (stage.Status != ProductionStageStatus.Done) + throw new DomainException(ErrorCodes.StageNotDone, + $"Stage '{stage.Name}' is {stage.Status}; only a completed stage can be approved.", 409); + + var now = DateTime.UtcNow; + + if (IsTerminal(run, stage)) + { + (pending, costPool, var entry) = await PostReceiptAsync(run, stage, now, token); + ledgerRefs.Add(entry); + + stage.Status = ProductionStageStatus.Approved; + run.Status = ProductionRunStatus.Completed; + run.CompletedAt = now; + runStatus = run.Status; + + // Layer/ledger ids are still 0 here — they are database-generated and only + // populated once this transaction's SaveChanges runs. The event payload and + // the response DTO are therefore both built after the commit, below. + AddEvent(run, stage, RunStageEventType.Approve, null, new + { + itemId = pending.ItemId, + qtyReceived = pending.QtyBase, + unitCost = pending.UnitCost, + value = pending.LedgerValue, + costPool + }); + return; + } + + foreach (var output in stage.Outputs.OrderBy(o => o.RunOutputId)) + { + var available = output.ProducedQty - output.ScrappedQty - output.TransferredQty; + var line = request.Transfers.FirstOrDefault(t => t.RunOutputId == output.RunOutputId); + + // Default is "transfer everything available" (FR-MFG-12); an explicit line + // makes it a partial and leaves the remainder on the stage. + var qty = line?.Qty ?? available; + if (qty <= 0) continue; + + transfers.AddRange(Deliver(run, output, qty, line?.RunInputId, available)); + } + + stage.Status = ProductionStageStatus.Approved; + AddEvent(run, stage, RunStageEventType.Approve, null, new { transfers }); + }, ct); + + var stageDto = await RequireStageAsync(runId, runStageId, ct); + + // Mapped after the commit so the generated layer id is populated (mirrors the + // "map ledger ids after commit" fix recorded for Count/Return in Backend/PROGRESS.md). + var receipt = pending is null + ? null + : new ReceiptDto( + pending.Layer.LayerId, pending.ItemId, pending.WarehouseId, pending.BinId, + pending.QtyBase, pending.UnitCost, pending.LedgerValue); + + return new ApproveStageResultDto( + runStageId, stageDto.Status, runStatus, transfers, + receipt, costPool, ledgerRefs.Select(l => l.LedgerId).ToList(), stageDto); + } + + /// + /// A posted receipt whose layer id is not yet known. Held across the transaction + /// boundary so can be built once EF has assigned the id. + /// + private sealed record Receipt( + StockLayer Layer, int ItemId, int WarehouseId, int? BinId, + decimal QtyBase, decimal UnitCost, decimal LedgerValue); + + public async Task TransferAsync( + int runId, int runStageId, TransferRemainderRequest request, CancellationToken ct = default) + { + var transfers = new List(); + + await _uow.ExecuteInTransactionAsync(async token => + { + var (run, stage) = await LoadForActionAsync(runId, runStageId, token); + + if (stage.Status != ProductionStageStatus.Approved) + throw new ConflictException( + $"Stage '{stage.Name}' is {stage.Status}; a remainder can only be transferred from an approved stage."); + + var output = stage.Outputs.FirstOrDefault(o => o.RunOutputId == request.RunOutputId) + ?? throw new DomainException(ErrorCodes.Validation, + $"Output {request.RunOutputId} is not on stage '{stage.Name}'.", 422); + + var available = output.ProducedQty - output.ScrappedQty - output.TransferredQty; + transfers.AddRange(Deliver(run, output, request.Qty, request.RunInputId, available)); + + AddEvent(run, stage, RunStageEventType.Transfer, null, new { transfers }); + }, ct); + + return new TransferResultDto(runStageId, transfers, await RequireStageAsync(runId, runStageId, ct)); + } + + public async Task ReturnLeftoverAsync( + int runId, int runInputId, ReturnLeftoverRequest request, CancellationToken ct = default) + { + StockLayer? layer = null; + StockLedger? entry = null; + decimal unitCost = 0, returnValue = 0, returnedQty = 0; + CostPoolDto pool = new(0, 0, 0); + + await _uow.ExecuteInTransactionAsync(async token => + { + var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token) + ?? throw new NotFoundException($"Production run {runId} was not found."); + + // The cost pool is closed by the terminal receipt — returning afterwards would + // change a figure the finished layer has already been costed from (FR-MFG-13). + if (run.Status == ProductionRunStatus.Completed) + throw new DomainException(ErrorCodes.RunCostClosed, + $"Run {run.DocNo} is complete; its cost pool is closed to further returns.", 409); + if (run.Status != ProductionRunStatus.InProgress) + throw new ConflictException($"Run {run.DocNo} is {run.Status}; leftovers cannot be returned."); + + var input = run.Stages.SelectMany(s => s.Inputs) + .FirstOrDefault(i => i.RunInputId == runInputId) + ?? throw new NotFoundException($"Input {runInputId} is not part of run {runId}."); + + if (input.Source != StageInputSource.Stock || input.ItemId is null) + throw new DomainException(ErrorCodes.Validation, + "Only a Stock input can be returned — upstream work-in-progress never entered stock.", 422); + + if (input.ConsumedQty <= 0) + throw new DomainException(ErrorCodes.Validation, + "Nothing has been consumed on this input yet, so there is nothing to return.", 422); + + if (request.ReasonCodeId is null) + throw new DomainException(ErrorCodes.ReasonCodeRequired, + "A reason code is required to return leftover material.", 400); + await EnsureProductionReasonAsync(request.ReasonCodeId.Value, token); + + var remaining = input.ConsumedQty - input.ReturnedQty; + if (request.Qty > remaining) + throw new DomainException(ErrorCodes.LeftoverExceedsConsumed, + $"Cannot return {request.Qty}: only {remaining} remains " + + $"(consumed {input.ConsumedQty} - already returned {input.ReturnedQty}).", 422); + + var now = DateTime.UtcNow; + var stage = run.Stages.First(s => s.RunStageId == input.RunStageId); + + // Weighted cost actually consumed for THIS input — cost-preserving by + // construction, so what leaves the pool is exactly what entered it. + var weighted = input.ConsumedValue / input.ConsumedQty; + unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero); + returnedQty = request.Qty; + + // Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and + // then multiplying lets successive partial returns accumulate drift until the + // last one can exceed ConsumedValue. + returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty + // Full return: take the exact residual so the input's contribution to the + // pool lands on precisely zero rather than a rounding crumb. + ? input.ConsumedValue - input.ReturnedValue + : Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero); + + layer = await _fifo.CreateInboundLayerAsync( + input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null, + request.Qty, unitCost, now, token); + + var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty; + + // binId stays null: this is raw material going back to general stock, not into the + // run's finished-goods output bin. + entry = await _fifo.PostLedgerAsync( + input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null, + _currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance, + LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue); + + input.ReturnedQty += request.Qty; + input.ReturnedValue += returnValue; + + var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue); + var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue); + pool = new CostPoolDto(consumedTotal, returnedTotal, consumedTotal - returnedTotal); + + // RUN_STAGE_INPUT has no reason-code column (docs/30 Part C), so the reason lives + // in the event payload. Adequate as an audit trail; a per-input reportable reason + // would need its own line table. + AddEvent(run, stage, RunStageEventType.LeftoverReturn, null, new + { + runInputId, + qty = request.Qty, + unitCost, + value = returnValue, + reasonCodeId = request.ReasonCodeId + }); + }, ct); + + return new ReturnLeftoverResultDto( + runInputId, returnedQty, returnValue, + new ProductionCreatedLayerDto(layer!.LayerId, unitCost), + [entry!.LedgerId], pool); + } + + public async Task RejectIntakeAsync( + int runId, int runStageId, RejectRequest request, CancellationToken ct = default) + { + var pulled = new List(); + + await _uow.ExecuteInTransactionAsync(async token => + { + var (run, stage) = await LoadForActionAsync(runId, runStageId, token); + + // docs/30 §D.3 says Ready only; docs/21 §5 offers the action on "a Ready/Waiting + // stage with deliveries". Allowing both is the reconciliation: a partially + // delivered Waiting stage is a real case, and the delivered intake — not the + // status — is what makes the reject meaningful. + if (stage.Status is not (ProductionStageStatus.Ready or ProductionStageStatus.Waiting)) + throw new DomainException(ErrorCodes.StageRejectInvalid, + $"Stage '{stage.Name}' is {stage.Status}; intake can only be rejected before the stage starts.", 409); + + var delivered = stage.Inputs + .Where(i => i.Source == StageInputSource.Upstream && i.DeliveredQty > 0) + .OrderBy(i => i.RunInputId) + .ToList(); + + if (delivered.Count == 0) + throw new DomainException(ErrorCodes.StageRejectInvalid, + $"Stage '{stage.Name}' has received no work, so there is nothing to reject.", 409); + + foreach (var input in delivered) + { + var parentOutput = run.Stages.SelectMany(s => s.Outputs) + .FirstOrDefault(o => o.RunOutputId == input.FromRunOutputId); + if (parentOutput is null) continue; + + var parentStage = run.Stages.First(s => s.RunStageId == parentOutput.RunStageId); + var priorStatus = parentStage.Status; + + // DECREMENT, never zero. The parent may also have fed a different child; on + // its re-approve, available = produced − scrapped − transferred must still + // account for that other delivery. This decrement is what keeps the model + // self-consistent. + parentOutput.TransferredQty = Math.Max(0, parentOutput.TransferredQty - input.DeliveredQty); + + pulled.Add(new PulledBackDto( + input.RunInputId, parentStage.RunStageId, parentOutput.RunOutputId, + input.DeliveredQty, priorStatus, + priorStatus == ProductionStageStatus.Approved + ? ProductionStageStatus.InProgress + : priorStatus)); + + input.DeliveredQty = 0; + + if (parentStage.Status == ProductionStageStatus.Approved) + { + parentStage.Status = ProductionStageStatus.InProgress; + // Cleared because the stage must be completed again; ActualStartAt is + // deliberately PRESERVED so the original start time stands (FR-MFG-19). + parentStage.ActualEndAt = null; + } + } + + stage.Status = ProductionStageStatus.Waiting; + stage.ActualStartAt = null; + stage.ActualEndAt = null; + RecomputeReadiness(stage); + + AddEvent(run, stage, RunStageEventType.RejectIntake, request.Note, new { pulledBack = pulled }); + }, ct); + + var refreshed = await GetAsync(runId, ct) + ?? throw new NotFoundException($"Production run {runId} was not found."); + return new RejectIntakeResultDto( + runStageId, refreshed.Value.Stages.First(s => s.RunStageId == runStageId).Status, + pulled, refreshed.Value); + } + + public async Task RejectTerminalAsync( + int runId, int runStageId, RejectRequest request, CancellationToken ct = default) + { + var reworkNumber = 0; + + await _uow.ExecuteInTransactionAsync(async token => + { + var (run, stage) = await LoadForActionAsync(runId, runStageId, token); + + if (!IsTerminal(run, stage)) + throw new DomainException(ErrorCodes.StageRejectInvalid, + $"Stage '{stage.Name}' is not the final stage; use reject-intake instead.", 409); + + // Only from Done. Approving the terminal completes the run and closes the cost + // pool, so there is no rework path back from Approved. + if (stage.Status != ProductionStageStatus.Done) + throw new DomainException(ErrorCodes.StageNotDone, + $"Stage '{stage.Name}' is {stage.Status}; only a completed final stage can be rejected.", 409); + + var hasInbound = run.Edges.Select(e => e.ChildRunStageId).ToHashSet(); + reworkNumber = run.ReworkCount + 1; + + // Snapshot BEFORE resetting — one event row for the whole pass, not one per + // stage: reconstructing "what did rework #2 look like" from N rows is painful. + var snapshot = new + { + reworkNumber, + note = request.Note, + rejectedAt = DateTime.UtcNow, + stages = run.Stages.OrderBy(s => s.RunStageId).Select(s => new + { + runStageId = s.RunStageId, + name = s.Name, + status = s.Status.ToString(), + actualStartAt = s.ActualStartAt, + actualEndAt = s.ActualEndAt, + fieldValues = ParseJson(s.FieldValues), + outputs = s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new + { + o.RunOutputId, o.ProducedQty, o.ScrappedQty, o.ScrapReasonCodeId, o.TransferredQty + }), + inputs = s.Inputs.OrderBy(i => i.RunInputId).Select(i => new + { + i.RunInputId, i.DeliveredQty, i.ConsumedQty, i.ConsumedValue, + i.ReturnedQty, i.ReturnedValue + }) + }) + }; + + AddEvent(run, stage, RunStageEventType.TerminalReject, request.Note, snapshot); + + run.ReworkCount = reworkNumber; + + foreach (var s in run.Stages) + { + // Entry stages become Ready, everything else Waiting. + s.Status = hasInbound.Contains(s.RunStageId) + ? ProductionStageStatus.Waiting + : ProductionStageStatus.Ready; + + // A fresh pass: timings and captured field values are cleared so required + // fields must be answered again. FieldDefs, PosX/PosY, Name, RoleLabel, + // EstimatedMinutes and TemplateStageId all persist. + s.ActualStartAt = null; + s.ActualEndAt = null; + s.FieldValues = null; + + foreach (var o in s.Outputs) + { + o.ProducedQty = 0; + o.ScrappedQty = 0; + o.ScrapReasonCodeId = null; + o.TransferredQty = 0; + // PlannedQty PERSISTS — per-run edits must survive the rework, and + // FR-MFG-16 explicitly expects planned quantities to be raised for it. + } + + foreach (var i in s.Inputs) + { + // WIP is gone... + i.DeliveredQty = 0; + // ...but consumed and returned figures PERSIST: already-consumed material + // remains in the cost pool (FR-MFG-16). No stock is reversed, no ledger + // entry is written. The delta rule in StartStageAsync is what makes the + // next start consume only any increase. + } + } + }, ct); + + var refreshed = await GetAsync(runId, ct) + ?? throw new NotFoundException($"Production run {runId} was not found."); + return new TerminalRejectResultDto(runStageId, reworkNumber, refreshed.Value); + } + + public async Task CancelAsync( + int runId, CancelRunRequest request, CancellationToken ct = default) + { + var returns = new List<(StockLayer Layer, int ItemId, decimal Qty, decimal UnitCost)>(); + var writeOffs = new List(); + var ledgerRefs = new List(); + + await _uow.ExecuteInTransactionAsync(async token => + { + var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, token) + ?? throw new NotFoundException($"Production run {runId} was not found."); + + if (run.Status != ProductionRunStatus.InProgress) + throw new DomainException(ErrorCodes.RunNotCancellable, + $"Run {run.DocNo} is {run.Status} and cannot be cancelled.", 409); + + if (request.ReasonCodeId is null) + throw new DomainException(ErrorCodes.ReasonCodeRequired, + "A reason code is required to cancel a run.", 400); + await EnsureProductionReasonAsync(request.ReasonCodeId.Value, token); + + var now = DateTime.UtcNow; + var actor = _currentUser.AuditUserId; + + // Same accumulation as TransferService: layers created in this transaction are + // invisible to GetOnHandAsync until SaveChanges, so without this every + // RunningBalance after the first for a repeated item would be wrong. + var balances = new Dictionary(); + + foreach (var input in run.Stages.SelectMany(s => s.Inputs) + .Where(i => i.Source == StageInputSource.Stock && i.ItemId is not null) + .OrderBy(i => i.RunInputId)) + { + var qty = input.ConsumedQty - input.ReturnedQty; + if (qty <= 0) continue; + + var unitCost = Math.Round(input.ConsumedValue / input.ConsumedQty, 6, MidpointRounding.AwayFromZero); + // Exact residual, so the input's pool contribution lands on zero. + var value = input.ConsumedValue - input.ReturnedValue; + + var layer = await _fifo.CreateInboundLayerAsync( + input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null, + qty, unitCost, now, token); + + if (!balances.TryGetValue(input.ItemId.Value, out var balance)) + balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token); + balance += qty; + balances[input.ItemId.Value] = balance; + + var entry = await _fifo.PostLedgerAsync( + input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null, + actor, Direction.In, qty, unitCost, balance, + LedgerSourceTypes.ProductionCancelReturn, run.RunId, now, token, + valueOverride: Math.Round(value, 4, MidpointRounding.AwayFromZero)); + + ledgerRefs.Add(entry); + returns.Add((layer, input.ItemId.Value, qty, unitCost)); + + input.ReturnedQty = input.ConsumedQty; + input.ReturnedValue = input.ConsumedValue; + } + + // FR-MFG-17 says "net consumed − returned − scrapped", but scrap is not + // computable at the input level: ScrappedQty lives on OUTPUTS, in output UOM, + // with no per-input scrap anywhere in the model. Physically the doc's intent is + // already satisfied — scrap is recorded against outputs (finished/intermediate + // WIP) which never entered stock, so there is nothing to deduct from an input + // return. The scrapped quantities are recorded as the written-off figure instead. + writeOffs.AddRange(run.Stages.SelectMany(s => s.Outputs) + .Where(o => o.ScrappedQty > 0) + .Select(o => new ScrapWriteOffDto(o.RunOutputId, o.Name, o.ScrappedQty))); + + run.Status = ProductionRunStatus.Cancelled; + run.CancelReasonCodeId = request.ReasonCodeId; + // CompletedAt stays null — a cancelled run never completed. + + AddEvent(run, null, RunStageEventType.Cancel, request.Note, new + { + reasonCodeId = request.ReasonCodeId, + returns = returns.Select(r => new { r.ItemId, r.Qty, r.UnitCost }), + scrappedWrittenOff = writeOffs + }); + }, ct); + + return new CancelRunResultDto( + runId, ProductionRunStatus.Cancelled, + returns.Select(r => new CancelReturnDto(r.ItemId, r.Qty, r.UnitCost, r.Layer.LayerId)).ToList(), + writeOffs, + ledgerRefs.Select(l => l.LedgerId).ToList()); + } + + // --- action helpers ------------------------------------------------------ + + /// + /// Hands of an output to the child inputs that draw from it, and + /// re-evaluates each child's readiness. + /// + /// + /// Routing is by RunStageInput.FromRunOutputId, not by . + /// FR-MFG-12 describes transfers "per outbound edge", but the entity model connects an + /// output to a specific input — an edge can exist with no input drawing from it, and one + /// output can feed inputs on several children. Recorded as a contract clarification. + /// When several inputs draw from one output and no explicit target is named, they + /// are filled in runInputId order up to each one's outstanding need, with any + /// overflow going to the last. Deterministic, and identical to "give it all to the one + /// target" in the overwhelmingly common 1:1 case. + /// WIP quantities are moved at face value in their declared UOM — no conversion. + /// Intermediate outputs are internal work-in-progress with no item and no ledger entry + /// (FR-MFG-04/05), so there is no base UOM to convert to. + /// + private List Deliver( + ProductionRun run, RunStageOutput output, decimal qty, int? explicitInputId, decimal available) + { + if (qty > available) + throw new DomainException(ErrorCodes.TransferExceedsAvailable, + $"Cannot transfer {qty} from output '{output.Name}': only {available} is available " + + $"(produced {output.ProducedQty} - scrapped {output.ScrappedQty} - transferred {output.TransferredQty}).", + 422); + + var targets = run.Stages + .SelectMany(s => s.Inputs) + .Where(i => i.Source == StageInputSource.Upstream && i.FromRunOutputId == output.RunOutputId) + .OrderBy(i => i.RunInputId) + .ToList(); + + if (explicitInputId is not null) + { + targets = targets.Where(i => i.RunInputId == explicitInputId).ToList(); + if (targets.Count == 0) + throw new DomainException(ErrorCodes.Validation, + $"Input {explicitInputId} does not draw from output '{output.Name}'.", 422); + } + + // An output nothing consumes is legal (the graph validator does not require every + // output to be wired). Record the transfer against the output and stop there. + if (targets.Count == 0) + { + output.TransferredQty += qty; + return []; + } + + var results = new List(); + var remaining = qty; + + for (var idx = 0; idx < targets.Count && remaining > 0; idx++) + { + var target = targets[idx]; + var isLast = idx == targets.Count - 1; + var need = target.PlannedQty - target.DeliveredQty; + var give = isLast ? remaining : Math.Min(remaining, Math.Max(need, 0)); + if (give <= 0) continue; + + target.DeliveredQty += give; + remaining -= give; + + var childStage = run.Stages.First(s => s.RunStageId == target.RunStageId); + RecomputeReadiness(childStage); + + results.Add(new TransferDto( + output.RunOutputId, target.RunInputId, childStage.RunStageId, give, + target.DeliveredQty, childStage.Status)); + } + + output.TransferredQty += qty - remaining; + return results; + } + + /// + /// Terminal approve = production receipt (FR-MFG-13): creates the finished-goods layer + /// costed at costPool / goodQty and posts the inbound ledger entry. + /// + private async Task<(Receipt Receipt, CostPoolDto Pool, StockLedger Entry)> PostReceiptAsync( + ProductionRun run, RunStage stage, DateTime now, CancellationToken ct) + { + var output = stage.Outputs.SingleOrDefault() + ?? throw new DomainException(ErrorCodes.TerminalOutputItemRequired, + $"The final stage '{stage.Name}' must have exactly one output.", 422); + + if (output.ItemId is null) + throw new DomainException(ErrorCodes.TerminalOutputItemRequired, + $"The final stage '{stage.Name}' must produce a real item.", 422); + + var good = output.ProducedQty - output.ScrappedQty; + if (good <= 0) + throw new DomainException(ErrorCodes.Validation, + $"The final stage produced no good quantity ({output.ProducedQty} - {output.ScrappedQty}); " + + "there is nothing to receive.", 422); + + var item = await _items.Query().AsNoTracking() + .FirstOrDefaultAsync(i => i.ItemId == output.ItemId, ct) + ?? throw new DomainException(ErrorCodes.Validation, $"Item {output.ItemId} no longer exists.", 422); + + // docs/30 defines no batch/serial creation on receipt, so a tracked finished good has + // no valid path. Refuse explicitly rather than silently creating untracked stock. + if (item.TrackingMode != TrackingMode.None) + throw new DomainException(ErrorCodes.Validation, + $"Item {item.Sku} is {item.TrackingMode}-tracked; batch/serial-tracked finished goods " + + "are not supported in this phase.", 422); + + var qtyBase = await _uomConverter.ToBaseQtyAsync(item, output.UomId, good, ct); + + var consumedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue); + var returnedValue = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue); + var pool = consumedValue - returnedValue; + + var unitCost = Math.Round(pool / qtyBase, 6, MidpointRounding.AwayFromZero); + + var layer = await _fifo.CreateInboundLayerAsync( + item.ItemId, run.WarehouseId, batchId: null, serialId: null, grnLineId: null, + qtyBase, unitCost, now, ct); + + var balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct) + qtyBase; + + // valueOverride carries the pool exactly. unitCost is rounded to 6 dp, so at 100+ + // units qty × unitCost drifts from the pool by more than the ledger's 4 dp tick and + // Σ ledger value would no longer reconcile to Σ consumed − Σ returned. + var ledgerValue = Math.Round(pool, 4, MidpointRounding.AwayFromZero); + var entry = await _fifo.PostLedgerAsync( + item.ItemId, run.WarehouseId, run.OutputBinId, batchId: null, serialId: null, + _currentUser.AuditUserId, Direction.In, qtyBase, unitCost, balance, + LedgerSourceTypes.ProductionReceipt, run.RunId, now, ct, valueOverride: ledgerValue); + + // StockLayer carries no bin, so outputBinId reaches the ledger only; echo the run's + // bin rather than reading it back off the layer. + var receipt = new Receipt( + layer, item.ItemId, run.WarehouseId, run.OutputBinId, qtyBase, unitCost, ledgerValue); + + return (receipt, new CostPoolDto(consumedValue, returnedValue, pool), entry); + } + + private static bool IsTerminal(ProductionRun run, RunStage stage) + => !run.Edges.Any(e => e.ParentRunStageId == stage.RunStageId); + + /// + /// Loads the run graph tracked and resolves the stage, enforcing that the run is still + /// open. Shared by every stage action so the guards can't diverge. + /// + private async Task<(ProductionRun Run, RunStage Stage)> LoadForActionAsync( + int runId, int runStageId, CancellationToken ct) + { + var run = await LoadGraphQuery().FirstOrDefaultAsync(r => r.RunId == runId, ct) + ?? throw new NotFoundException($"Production run {runId} was not found."); + + if (run.Status != ProductionRunStatus.InProgress) + throw new ConflictException($"Run {run.DocNo} is {run.Status}; no further stage actions are possible."); + + var stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId) + ?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}."); + + return (run, stage); + } + + /// FR-MFG-07: every field marked required must carry a non-empty value. + private static void ValidateRequiredFields(RunStage stage, JsonElement? values) + { + var defs = ProductionJson.Deserialize>(stage.FieldDefs, []); + var required = defs.Where(d => d.Required).ToList(); + if (required.Count == 0) return; + + var missing = new List(); + foreach (var def in required) + { + if (values is null || values.Value.ValueKind != JsonValueKind.Object + || !values.Value.TryGetProperty(def.Key, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined + || (value.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(value.GetString()))) + { + missing.Add(def.Label); + } + } + + if (missing.Count > 0) + throw new DomainException(ErrorCodes.RequiredFieldMissing, + $"Required field(s) not provided for stage '{stage.Name}': {string.Join(", ", missing)}.", 400); + } + + private async Task EnsureProductionReasonAsync(int reasonCodeId, CancellationToken ct) + { + var reason = await _reasonCodes.Query().AsNoTracking() + .FirstOrDefaultAsync(r => r.ReasonCodeId == reasonCodeId, ct) + ?? throw new DomainException(ErrorCodes.Validation, + $"Reason code {reasonCodeId} does not exist.", 422); + + if (reason.Context != ReasonContext.Production) + throw new DomainException(ErrorCodes.Validation, + $"Reason code '{reason.Code}' is a {reason.Context} reason, not a Production reason.", 422); + } + + private async Task RequireStageAsync(int runId, int runStageId, CancellationToken ct) + { + var run = await GetAsync(runId, ct) + ?? throw new NotFoundException($"Production run {runId} was not found."); + return run.Value.Stages.First(s => s.RunStageId == runStageId); + } + + // --- shared helpers ------------------------------------------------------ + + private IQueryable LoadGraphQuery() => + _runs.Query() + .Include(r => r.Template) + .Include(r => r.Stages).ThenInclude(s => s.Inputs) + .Include(r => r.Stages).ThenInclude(s => s.Outputs) + .Include(r => r.Edges) + .Include(r => r.Events); + + /// + /// Re-evaluates a single stage's Waiting/Ready state against its upstream deliveries + /// (FR-MFG-09 — an all-parents join, per-edge full delivery). + /// + /// + /// Only ever moves between Waiting and Ready; a started stage is left alone, so callers + /// can invoke it on any stage without guarding. A stage with no upstream inputs is an + /// entry stage and is always Ready. The comparison is >=, not ==, so a + /// partial-then-full transfer sequence still satisfies it. + /// + internal static void RecomputeReadiness(RunStage stage) + { + if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready)) return; + + var upstream = stage.Inputs.Where(i => i.Source == StageInputSource.Upstream).ToList(); + var satisfied = upstream.Count == 0 || upstream.All(i => i.DeliveredQty >= i.PlannedQty); + + stage.Status = satisfied ? ProductionStageStatus.Ready : ProductionStageStatus.Waiting; + } + + private void AddEvent(ProductionRun run, RunStage? stage, RunStageEventType type, string? note, object? payload) + { + var ev = new RunStageEvent + { + Run = run, + RunStage = stage, + EventType = type, + Note = note, + Payload = payload is null ? null : ProductionJson.Serialize(payload), + UserId = _currentUser.AuditUserId, + CreatedAt = DateTime.UtcNow + }; + run.Events.Add(ev); + stage?.Events.Add(ev); + } + + // --- mapping ------------------------------------------------------------- + + private static RunGraphDto ToGraphDto(ProductionRun run) + { + var hasOutbound = run.Edges.Select(e => e.ParentRunStageId).ToHashSet(); + var hasInbound = run.Edges.Select(e => e.ChildRunStageId).ToHashSet(); + + var consumed = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue); + var returned = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue); + + var stages = run.Stages + .OrderBy(s => s.RunStageId) + .Select(s => new RunStageDto( + s.RunStageId, s.TemplateStageId, s.Name, s.RoleLabel, s.EstimatedMinutes, s.PosX, s.PosY, + s.Status, + !hasOutbound.Contains(s.RunStageId), + !hasInbound.Contains(s.RunStageId), + s.ActualStartAt, s.ActualEndAt, + s.ActualStartAt is not null && s.ActualEndAt is not null + ? (int)Math.Round((s.ActualEndAt.Value - s.ActualStartAt.Value).TotalMinutes) + : null, + ProductionJson.Deserialize>(s.FieldDefs, []), + ParseJson(s.FieldValues), + s.Inputs.OrderBy(i => i.RunInputId).Select(i => new RunStageInputDto( + i.RunInputId, i.Source, i.ItemId, i.FromRunOutputId, i.UomId, + i.PlannedQty, i.ConsumedQty, i.ConsumedValue, + i.DeliveredQty, i.ReturnedQty, i.ReturnedValue)).ToList(), + s.Outputs.OrderBy(o => o.RunOutputId).Select(o => new RunStageOutputDto( + o.RunOutputId, o.ItemId, o.Name, o.UomId, + o.PlannedQty, o.ProducedQty, o.ScrappedQty, o.ScrapReasonCodeId, + o.TransferredQty, + o.ProducedQty - o.ScrappedQty - o.TransferredQty)).ToList())) + .ToList(); + + var edges = run.Edges + .OrderBy(e => e.RunEdgeId) + .Select(e => new RunEdgeDto(e.RunEdgeId, e.ParentRunStageId, e.ChildRunStageId)) + .ToList(); + + var events = run.Events + .OrderBy(e => e.EventId) + .Select(e => new RunEventDto( + e.EventId, e.RunStageId, e.EventType, e.Note, ParseJson(e.Payload), e.UserId, e.CreatedAt)) + .ToList(); + + return new RunGraphDto( + run.RunId, run.DocNo, run.TemplateId, run.Template?.Name ?? string.Empty, + run.WarehouseId, run.OutputBinId, run.TargetQty, run.ScaleFactor, + run.Status, run.ReworkCount, run.CancelReasonCodeId, + new CostPoolDto(consumed, returned, consumed - returned), + stages, edges, events, + run.CreatedBy, run.CreatedAt, run.CompletedAt); + } + + private static JsonElement? ParseJson(string? json) + => string.IsNullOrWhiteSpace(json) ? null : JsonDocument.Parse(json).RootElement.Clone(); +} diff --git a/Backend/ERPCore/Services/Production/ProductionTemplateService.cs b/Backend/ERPCore/Services/Production/ProductionTemplateService.cs new file mode 100644 index 0000000..d63aaf6 --- /dev/null +++ b/Backend/ERPCore/Services/Production/ProductionTemplateService.cs @@ -0,0 +1,496 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Production; +using ERPCore.Infra.Auth; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Production; + +/// +/// Production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07). +/// Graph rules live in ; everything needing the +/// database (referential checks, the edit-lock, reconciliation) lives here. +/// +public sealed class ProductionTemplateService : IProductionTemplateService +{ + private readonly IRepository _templates; + private readonly IRepository _stages; + private readonly IRepository _inputs; + private readonly IRepository _outputs; + private readonly IRepository _edges; + private readonly IRepository _items; + private readonly IRepository _uoms; + private readonly IRepository _runs; + private readonly IUnitOfWork _uow; + private readonly ICurrentUser _currentUser; + + public ProductionTemplateService( + IRepository templates, IRepository stages, + IRepository inputs, IRepository outputs, IRepository edges, + IRepository items, IRepository uoms, IRepository runs, + IUnitOfWork uow, ICurrentUser currentUser) + { + _templates = templates; + _stages = stages; + _inputs = inputs; + _outputs = outputs; + _edges = edges; + _items = items; + _uoms = uoms; + _runs = runs; + _uow = uow; + _currentUser = currentUser; + } + + public async Task> ListAsync( + PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _templates.Query().AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(t => EF.Functions.ILike(t.Code, $"%{term}%") || EF.Functions.ILike(t.Name, $"%{term}%")); + } + + if (status is not null) q = q.Where(t => t.Status == status); + + var total = await q.CountAsync(ct); + + // Projected to an anonymous type first, then mapped client-side: EF Core 10 cannot + // translate a record constructor sitting alongside aggregates and a collection + // projection (same limitation recorded for WarehouseValuationDto, 2026-07-28). + var rows = await q + .OrderBy(t => t.Code) + .Skip(query.Skip).Take(query.PageSize) + .Select(t => new + { + t.TemplateId, + t.Code, + t.Name, + t.Status, + StageCount = t.Stages.Count, + Stages = t.Stages.Select(s => new { s.StageId, s.Name }).ToList(), + Edges = t.Edges.Select(e => new { e.ParentStageId, e.ChildStageId }).ToList(), + // Drives the canvas edit-lock banner (docs/21 §2) and mirrors the + // condition UpdateAsync enforces server-side. + ActiveRunCount = t.Runs.Count(r => r.Status == ProductionRunStatus.InProgress), + t.CreatedBy, + t.CreatedAt + }) + .ToListAsync(ct); + + var items = rows.Select(t => new TemplateSummaryDto( + t.TemplateId, t.Code, t.Name, t.Status, t.StageCount, + FlowOrderedNames( + t.Stages.Select(s => (s.StageId, s.Name)).ToList(), + t.Edges.Select(e => (e.ParentStageId, e.ChildStageId)).ToList()), + t.ActiveRunCount, t.CreatedBy, t.CreatedAt)).ToList(); + + return PagedResponse.Create(items, query.Page, query.PageSize, total); + } + + /// + /// Stage names in flow order — upstream first — for the overview canvas, which + /// draws each template as a line left to right (docs/21 §1). + /// + /// + /// Ordering by StageId would be insertion order, which routinely puts the terminal + /// stage first and renders the line backwards. Kahn's algorithm over the edge set gives the + /// real sequence, tie-broken by stage id so parallel branches come out deterministically. + /// Falls back to id order if the graph somehow contains a cycle, so a listing can never + /// fail because of bad data. + /// + private static List FlowOrderedNames( + List<(int StageId, string Name)> stages, List<(int ParentStageId, int ChildStageId)> edges) + { + var nameById = stages.ToDictionary(s => s.StageId, s => s.Name); + var children = stages.ToDictionary(s => s.StageId, _ => new List()); + var indegree = stages.ToDictionary(s => s.StageId, _ => 0); + + foreach (var (parent, child) in edges) + { + if (!children.ContainsKey(parent) || !indegree.ContainsKey(child)) continue; + children[parent].Add(child); + indegree[child]++; + } + + // SortedSet keeps the frontier in id order, so the output is stable run to run. + var frontier = new SortedSet(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key)); + var ordered = new List(stages.Count); + + while (frontier.Count > 0) + { + var next = frontier.Min; + frontier.Remove(next); + ordered.Add(nameById[next]); + + foreach (var child in children[next]) + if (--indegree[child] == 0) + frontier.Add(child); + } + + return ordered.Count == stages.Count + ? ordered + : stages.OrderBy(s => s.StageId).Select(s => s.Name).ToList(); + } + + public async Task?> GetAsync(int templateId, CancellationToken ct = default) + { + var template = await LoadGraphQuery().AsNoTracking() + .FirstOrDefaultAsync(t => t.TemplateId == templateId, ct); + + if (template is null) return null; + + // Counted separately rather than Included: the graph query already fans out over four + // collections, and adding Runs would multiply those rows again for a single integer. + var activeRuns = await _runs.Query().AsNoTracking() + .CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, ct); + + return new ETagged(ToGraphDto(template, activeRuns), template.RowVersion); + } + + public async Task> CreateAsync(SaveTemplateRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _templates.Query().AnyAsync(t => t.Code == code, ct)) + throw new ConflictException($"A production template with code '{code}' already exists."); + + var template = new ProductionTemplate + { + Code = code, + Name = request.Name.Trim(), + Description = request.Description?.Trim(), + Status = EntityStatus.Active, + CreatedBy = _currentUser.AuditUserId, + CreatedAt = DateTime.UtcNow + }; + + await ValidateAsync(request, ct); + + await _uow.ExecuteInTransactionAsync(async token => + { + await _templates.AddAsync(template, token); + BuildGraph(template, request); + }, ct); + + return await RequireGraphAsync(template.TemplateId, ct); + } + + public async Task> UpdateAsync( + int templateId, SaveTemplateRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + await ValidateAsync(request, ct); + + var code = request.Code.Trim(); + if (await _templates.Query().AnyAsync(t => t.Code == code && t.TemplateId != templateId, ct)) + throw new ConflictException($"A production template with code '{code}' already exists."); + + await _uow.ExecuteInTransactionAsync(async token => + { + var template = await LoadGraphQuery().FirstOrDefaultAsync(t => t.TemplateId == templateId, token) + ?? throw new NotFoundException($"Production template {templateId} was not found."); + + if (template.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, + "The template was modified by another request.", 412); + + // FR-MFG-06: edit-lock instead of versioning. Checked inside the transaction to + // keep the window small; the residual race is benign because a run copies + // everything it needs at creation and never reads the template again. + var activeRuns = await _runs.Query().AsNoTracking() + .CountAsync(r => r.TemplateId == templateId && r.Status == ProductionRunStatus.InProgress, token); + if (activeRuns > 0) + throw new DomainException(ErrorCodes.TemplateInUse, + $"This template has {activeRuns} run(s) in progress and cannot be edited until they finish.", 409); + + template.Code = code; + template.Name = request.Name.Trim(); + template.Description = request.Description?.Trim(); + + // Two passes, deliberately. Tearing the old graph down and flushing before + // rebuilding removes any dependence on how EF happens to order a mixed batch of + // inserts and deletes — which matters because stage_inputs → stage_outputs is a + // Restrict FK and stage_edges carries a unique (parent, child) index. + var keptStages = TearDownGraph(template, request); + await _uow.SaveChangesAsync(token); + + BuildGraph(template, request, keptStages); + }, ct); + + return await RequireGraphAsync(templateId, ct); + } + + public async Task SetStatusAsync(int templateId, EntityStatus status, CancellationToken ct = default) + { + var template = await _templates.Query().FirstOrDefaultAsync(t => t.TemplateId == templateId, ct) + ?? throw new NotFoundException($"Production template {templateId} was not found."); + + template.Status = status; + await _uow.SaveChangesAsync(ct); + } + + // --- graph reconciliation ------------------------------------------------ + + private IQueryable LoadGraphQuery() => + _templates.Query() + .Include(t => t.Stages).ThenInclude(s => s.Inputs) + .Include(t => t.Stages).ThenInclude(s => s.Outputs) + .Include(t => t.Edges); + + /// + /// Runs the pure graph rules then the referential ones. Item/UOM existence needs the + /// database, so it can't live in the validator — but it has to run before any write. + /// + private async Task ValidateAsync(SaveTemplateRequest request, CancellationToken ct) + { + ProductionGraphValidator.Validate( + request.Stages.Select(s => new ProductionGraphValidator.StageDraft( + s.Key, s.Name, + s.Inputs.Select((i, idx) => new ProductionGraphValidator.InputDraft(idx, i.Source, i.ItemId, i.FromOutputKey)).ToList(), + s.Outputs.Select(o => new ProductionGraphValidator.OutputDraft(o.Key, o.Name, o.ItemId)).ToList())).ToList(), + request.Edges.Select(e => new ProductionGraphValidator.EdgeDraft(e.ParentKey, e.ChildKey)).ToList()); + + var itemIds = request.Stages + .SelectMany(s => s.Inputs.Select(i => i.ItemId).Concat(s.Outputs.Select(o => o.ItemId))) + .OfType().Distinct().ToList(); + + if (itemIds.Count > 0) + { + var found = await _items.Query().AsNoTracking() + .Where(i => itemIds.Contains(i.ItemId)) + .Select(i => new { i.ItemId, i.Status }) + .ToListAsync(ct); + + var missing = itemIds.Except(found.Select(f => f.ItemId)).ToList(); + if (missing.Count > 0) + throw new DomainException(ErrorCodes.Validation, + $"Item(s) {string.Join(", ", missing)} do not exist.", 422); + + var inactive = found.Where(f => f.Status != EntityStatus.Active).Select(f => f.ItemId).ToList(); + if (inactive.Count > 0) + throw new DomainException(ErrorCodes.Validation, + $"Item(s) {string.Join(", ", inactive)} are inactive and cannot be used in a template.", 422); + } + + var uomIds = request.Stages + .SelectMany(s => s.Inputs.Select(i => i.UomId).Concat(s.Outputs.Select(o => o.UomId))) + .Distinct().ToList(); + + var knownUoms = await _uoms.Query().AsNoTracking() + .Where(u => uomIds.Contains(u.UomId)).Select(u => u.UomId).ToListAsync(ct); + + var missingUoms = uomIds.Except(knownUoms).ToList(); + if (missingUoms.Count > 0) + throw new DomainException(ErrorCodes.Validation, + $"UOM(s) {string.Join(", ", missingUoms)} do not exist.", 422); + + // Annotations go into jsonb unvalidated by anything else, so pin the one field the + // client renders off. Unknown kinds would round-trip fine but draw nothing. + var badKinds = request.Annotations + .Select(a => a.Kind) + .Where(k => k is not ("box" or "line")) + .Distinct().ToList(); + if (badKinds.Count > 0) + throw new DomainException(ErrorCodes.Validation, + $"Unknown canvas annotation kind(s): {string.Join(", ", badKinds)}. Expected 'box' or 'line'.", 422); + } + + /// + /// Removes everything the incoming payload replaces and returns the surviving stages + /// by key. + /// + /// + /// Inputs and outputs are always replaced wholesale — nothing outside the template + /// references them, because a run copies into its own run_stage_input/ + /// run_stage_output rows. Stages are diffed, not replaced, because + /// run_stages.TemplateStageId points at them; a stage that disappears from the + /// payload is deleted and that FK is set null for any historical run (see + /// RunStageConfiguration). + /// + private Dictionary TearDownGraph(ProductionTemplate template, SaveTemplateRequest request) + { + var payloadKeys = request.Stages.Select(s => s.Key).ToHashSet(StringComparer.Ordinal); + + foreach (var stage in template.Stages) + { + foreach (var input in stage.Inputs.ToList()) _inputs.Remove(input); + foreach (var output in stage.Outputs.ToList()) _outputs.Remove(output); + } + + var kept = new Dictionary(StringComparer.Ordinal); + var doomed = new List(); + foreach (var stage in template.Stages.ToList()) + { + var key = stage.StageId.ToString(); + if (payloadKeys.Contains(key)) kept[key] = stage; + else doomed.Add(stage); + } + + // Edges are diffed rather than replaced: stage_edges has a unique (parent, child) + // index, and dropping then re-adding an unchanged edge in the same round trip can + // trip it depending on statement order. + var wanted = request.Edges + .Select(e => (Parent: ResolveKeptId(kept, e.ParentKey), Child: ResolveKeptId(kept, e.ChildKey))) + .Where(e => e.Parent is not null && e.Child is not null) + .Select(e => (e.Parent!.Value, e.Child!.Value)) + .ToHashSet(); + + // Edges must go before stages. StageEdge.ParentStage/ChildStage are required + // relationships, so deleting a stage that a still-live edge points at makes EF throw + // "the association ... has been severed". Any edge touching a doomed stage is + // necessarily absent from `wanted` — the payload cannot reference a stage it dropped + // — so this ordering never orphans an edge the caller wanted to keep. + foreach (var edge in template.Edges.ToList()) + if (!wanted.Contains((edge.ParentStageId, edge.ChildStageId))) + _edges.Remove(edge); + + foreach (var stage in doomed) + _stages.Remove(stage); + + return kept; + } + + private static int? ResolveKeptId(Dictionary kept, string key) + => kept.TryGetValue(key, out var stage) ? stage.StageId : null; + + /// + /// Materialises the payload onto the template. + /// + /// + /// Everything is wired through navigation properties rather than foreign-key + /// ints, so EF resolves the ids itself during a single SaveChanges. That is what + /// lets a brand-new Upstream input point at a brand-new output without an intermediate + /// save to materialise generated keys — outputs are built in the first pass precisely + /// so the second pass has the entity instances to hand. + /// + private void BuildGraph( + ProductionTemplate template, SaveTemplateRequest request, + Dictionary? keptStages = null) + { + // Replaced wholesale — annotations are opaque client state, not part of the graph, so + // there is nothing to diff and nothing else can reference them. + template.Annotations = request.Annotations.Count == 0 + ? null + : ProductionJson.Serialize(request.Annotations); + + var stagesByKey = new Dictionary(StringComparer.Ordinal); + var outputsByKey = new Dictionary(StringComparer.Ordinal); + + // Pass 1 — stages and their outputs. + foreach (var s in request.Stages) + { + if (keptStages is null || !keptStages.TryGetValue(s.Key, out var stage)) + { + stage = new TemplateStage { Template = template }; + template.Stages.Add(stage); + } + + stage.Name = s.Name.Trim(); + stage.RoleLabel = string.IsNullOrWhiteSpace(s.RoleLabel) ? null : s.RoleLabel.Trim(); + stage.EstimatedMinutes = s.EstimatedMinutes; + stage.PosX = s.PosX; + stage.PosY = s.PosY; + stage.FieldDefs = ProductionJson.Serialize(s.FieldDefs); + + stagesByKey[s.Key] = stage; + + foreach (var o in s.Outputs) + { + var output = new StageOutput + { + Stage = stage, + ItemId = o.ItemId, + Name = o.Name.Trim(), + UomId = o.UomId, + QtyPerBatch = o.QtyPerBatch + }; + stage.Outputs.Add(output); + outputsByKey[o.Key] = output; + } + } + + // Pass 2 — inputs, which may reference any output built above. + foreach (var s in request.Stages) + { + var stage = stagesByKey[s.Key]; + foreach (var i in s.Inputs) + { + stage.Inputs.Add(new StageInput + { + Stage = stage, + Source = i.Source, + ItemId = i.Source == StageInputSource.Stock ? i.ItemId : null, + FromOutput = i.Source == StageInputSource.Upstream ? outputsByKey[i.FromOutputKey!] : null, + UomId = i.UomId, + QtyPerBatch = i.QtyPerBatch + }); + } + } + + // Pass 3 — edges not already present. + var existing = template.Edges + .Select(e => (e.ParentStageId, e.ChildStageId)).ToHashSet(); + + foreach (var e in request.Edges) + { + var parent = stagesByKey[e.ParentKey]; + var child = stagesByKey[e.ChildKey]; + if (parent.StageId != 0 && child.StageId != 0 && existing.Contains((parent.StageId, child.StageId))) + continue; + + template.Edges.Add(new StageEdge + { + Template = template, + ParentStage = parent, + ChildStage = child + }); + } + } + + // --- mapping ------------------------------------------------------------- + + private async Task> RequireGraphAsync(int templateId, CancellationToken ct) + => await GetAsync(templateId, ct) + ?? throw new NotFoundException($"Production template {templateId} was not found."); + + private static TemplateGraphDto ToGraphDto(ProductionTemplate t, int activeRunCount) + { + // The key a client sends back is just the id as a string; computing it here keeps + // that contract in exactly one place. + var outputKeyById = t.Stages + .SelectMany(s => s.Outputs) + .ToDictionary(o => o.OutputId, o => o.OutputId.ToString()); + + var stages = t.Stages + .OrderBy(s => s.StageId) + .Select(s => new TemplateStageDto( + s.StageId, s.StageId.ToString(), s.Name, s.RoleLabel, s.EstimatedMinutes, s.PosX, s.PosY, + ProductionJson.Deserialize>(s.FieldDefs, []), + s.Inputs.OrderBy(i => i.InputId).Select(i => new StageInputDto( + i.InputId, i.Source, i.ItemId, i.FromOutputId, + i.FromOutputId is null ? null : outputKeyById.GetValueOrDefault(i.FromOutputId.Value), + i.UomId, i.QtyPerBatch)).ToList(), + s.Outputs.OrderBy(o => o.OutputId).Select(o => new StageOutputDto( + o.OutputId, o.OutputId.ToString(), o.ItemId, o.Name, o.UomId, o.QtyPerBatch)).ToList())) + .ToList(); + + var edges = t.Edges + .OrderBy(e => e.EdgeId) + .Select(e => new TemplateEdgeDto( + e.EdgeId, e.ParentStageId, e.ChildStageId, + e.ParentStageId.ToString(), e.ChildStageId.ToString())) + .ToList(); + + return new TemplateGraphDto( + t.TemplateId, t.Code, t.Name, t.Description, t.Status, stages, edges, + ProductionJson.Deserialize>(t.Annotations, []), + activeRunCount, t.CreatedBy, t.CreatedAt); + } +} diff --git a/Backend/ERPCore/Services/RoleService.cs b/Backend/ERPCore/Services/RoleService.cs index d9a9acd..4fb4fd7 100644 --- a/Backend/ERPCore/Services/RoleService.cs +++ b/Backend/ERPCore/Services/RoleService.cs @@ -187,8 +187,12 @@ public sealed class RoleService : IRoleService if (string.IsNullOrWhiteSpace(roleCode)) return new MeResponseDto(null, null, Array.Empty()); + // AuthHex mints the RoleCode claim independently of ERPCore's stored casing + // (e.g. token "ADMIN" vs seeded "Admin"), so match case-insensitively — an + // identity code differing only by case must not lock the user out of the nav. + var normalized = roleCode.Trim(); var role = await _roles.Query().AsNoTracking() - .FirstOrDefaultAsync(r => r.Code == roleCode, ct); + .FirstOrDefaultAsync(r => r.Code.ToLower() == normalized.ToLower(), ct); if (role is null) return new MeResponseDto(roleCode, null, Array.Empty()); diff --git a/Backend/ERPCore/Services/Stock/FifoCostingService.cs b/Backend/ERPCore/Services/Stock/FifoCostingService.cs index 5deadbc..c2f5d23 100644 --- a/Backend/ERPCore/Services/Stock/FifoCostingService.cs +++ b/Backend/ERPCore/Services/Stock/FifoCostingService.cs @@ -121,7 +121,8 @@ public sealed class FifoCostingService : IFifoCostingService public async Task PostLedgerAsync( int itemId, int warehouseId, int? binId, int? batchId, int? serialId, int userId, Direction direction, decimal qtyBase, decimal unitCost, decimal runningBalance, - string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default) + string sourceDocType, int sourceDocId, DateTime createdAt, CancellationToken ct = default, + decimal? valueOverride = null) { var entry = new StockLedger { @@ -134,7 +135,9 @@ public sealed class FifoCostingService : IFifoCostingService Direction = direction, QtyBase = qtyBase, UnitCost = unitCost, - Value = Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero), + Value = valueOverride is not null + ? Math.Round(valueOverride.Value, 4, MidpointRounding.AwayFromZero) + : Math.Round(qtyBase * unitCost, 4, MidpointRounding.AwayFromZero), RunningBalance = runningBalance, SourceDocType = sourceDocType, SourceDocId = sourceDocId, diff --git a/Backend/ERPCore/Services/Stock/UomConverter.cs b/Backend/ERPCore/Services/Stock/UomConverter.cs new file mode 100644 index 0000000..c9fcacc --- /dev/null +++ b/Backend/ERPCore/Services/Stock/UomConverter.cs @@ -0,0 +1,38 @@ +using ERPCore.Domain.Entities; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services.Stock; + +/// +/// Shared UOM → base-UOM conversion (see ). Behaviour is +/// unchanged from the GrnService.ToBaseAsync it was extracted from, so the GRN +/// receive path keeps costing exactly as before. +/// +public sealed class UomConverter : IUomConverter +{ + private readonly IRepository _conversions; + + public UomConverter(IRepository conversions) => _conversions = conversions; + + public async Task<(decimal QtyBase, decimal UnitCostBase)> ToBaseAsync( + Item item, int uomId, decimal qty, decimal unitCostPerUom, CancellationToken ct = default) + { + if (uomId == item.BaseUomId) + return (qty, unitCostPerUom); + + var conv = await _conversions.Query().AsNoTracking() + .FirstOrDefaultAsync(c => c.ItemId == item.ItemId && c.FromUomId == uomId && c.ToUomId == item.BaseUomId, ct) + ?? throw new DomainException(ErrorCodes.Validation, + $"No UOM conversion from {uomId} to base UOM {item.BaseUomId} for item {item.ItemId}.", 422); + + // Quantity scales up by the factor, so the per-unit cost scales down by it — + // total value is preserved. + return (qty * conv.Factor, unitCostPerUom / conv.Factor); + } + + public async Task ToBaseQtyAsync(Item item, int uomId, decimal qty, CancellationToken ct = default) + => (await ToBaseAsync(item, uomId, qty, 0m, ct)).QtyBase; +} diff --git a/Backend/ERPCore/Services/UserManagementService.cs b/Backend/ERPCore/Services/UserManagementService.cs index 3471270..b5be8e5 100644 --- a/Backend/ERPCore/Services/UserManagementService.cs +++ b/Backend/ERPCore/Services/UserManagementService.cs @@ -18,15 +18,18 @@ public sealed class UserManagementService : IUserManagementService private readonly IRepository _roles; private readonly IAuthUserService _authUsers; private readonly IAuthHexClient _authHex; + private readonly IEmployeeUserLinkService _links; private readonly IUnitOfWork _uow; public UserManagementService( - IRepository users, IRepository roles, IAuthUserService authUsers, IAuthHexClient authHex, IUnitOfWork uow) + IRepository users, IRepository roles, IAuthUserService authUsers, IAuthHexClient authHex, + IEmployeeUserLinkService links, IUnitOfWork uow) { _users = users; _roles = roles; _authUsers = authUsers; _authHex = authHex; + _links = links; _uow = uow; } @@ -82,12 +85,15 @@ public sealed class UserManagementService : IUserManagementService }, ct); // Mirror into the local shadow User row immediately, rather than waiting - // for ShadowUserClaimsTransformation's next-login JIT provisioning. + // for ShadowUserClaimsTransformation's next-login JIT provisioning. Email is + // persisted here too — it is the field the Employee<->User cross-link + // (docs/12-BACKEND-HRM.md A.5) matches on. var user = new User { AuthUserId = authUserId, Username = username, DisplayName = request.FullName.Trim(), + Email = request.Email.Trim(), RoleId = role.RoleId, Status = EntityStatus.Active }; @@ -95,6 +101,10 @@ public sealed class UserManagementService : IUserManagementService await _users.AddAsync(user, ct); await _uow.SaveChangesAsync(ct); + // Explicit, human-confirmed link to an existing Staff record (never automatic). + if (request.LinkEmployeeId is not null) + await _links.LinkAsync(request.LinkEmployeeId.Value, user.UserId, ct); + user.Role = role; return Map(user); } @@ -120,5 +130,5 @@ public sealed class UserManagementService : IUserManagementService } private static ManagedUserDto Map(User u) => new( - u.UserId, u.Username, u.DisplayName, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name); + u.UserId, u.Username, u.DisplayName, u.Email, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name); } diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index fdba3e0..903af82 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -32,4 +32,38 @@ public static class ErrorCodes public const string AuthServiceUnavailable = "AUTH_SERVICE_UNAVAILABLE"; public const string CsrfTokenMismatch = "CSRF_TOKEN_MISMATCH"; public const string RefreshTokenMissing = "REFRESH_TOKEN_MISSING"; + + // HRM (docs/13-BACKEND-HRM-API.md §7) + public const string EmployeeCodeDuplicate = "EMPLOYEE_CODE_DUPLICATE"; + public const string EmployeeAlreadyLinked = "EMPLOYEE_ALREADY_LINKED"; + public const string UserAlreadyLinked = "USER_ALREADY_LINKED"; + public const string DepartmentCycleDetected = "DEPARTMENT_CYCLE_DETECTED"; + public const string DocumentTypeInUse = "DOCUMENT_TYPE_IN_USE"; + public const string FileTypeNotAllowed = "FILE_TYPE_NOT_ALLOWED"; + public const string FileTooLarge = "FILE_TOO_LARGE"; + public const string AttendanceBatchLocked = "ATTENDANCE_BATCH_LOCKED"; + public const string AttendanceDuplicateUnresolved = "ATTENDANCE_DUPLICATE_UNRESOLVED"; + public const string AttendanceNotConfirmed = "ATTENDANCE_NOT_CONFIRMED"; + public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP"; + public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID"; + public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED"; + + // Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4) + public const string TemplateInUse = "TEMPLATE_IN_USE"; + public const string TemplateInactive = "TEMPLATE_INACTIVE"; + public const string GraphCycle = "GRAPH_CYCLE"; + public const string GraphTerminalCount = "GRAPH_TERMINAL_COUNT"; + public const string GraphDisconnected = "GRAPH_DISCONNECTED"; + public const string GraphInputSourceInvalid = "GRAPH_INPUT_SOURCE_INVALID"; + public const string TerminalOutputItemRequired = "TERMINAL_OUTPUT_ITEM_REQUIRED"; + public const string StageNotReady = "STAGE_NOT_READY"; + public const string StageNotInProgress = "STAGE_NOT_IN_PROGRESS"; + public const string StageNotDone = "STAGE_NOT_DONE"; + public const string StageNotEditable = "STAGE_NOT_EDITABLE"; + public const string StageRejectInvalid = "STAGE_REJECT_INVALID"; + public const string RequiredFieldMissing = "REQUIRED_FIELD_MISSING"; + public const string TransferExceedsAvailable = "TRANSFER_EXCEEDS_AVAILABLE"; + public const string LeftoverExceedsConsumed = "LEFTOVER_EXCEEDS_CONSUMED"; + public const string RunCostClosed = "RUN_COST_CLOSED"; + public const string RunNotCancellable = "RUN_NOT_CANCELLABLE"; } diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index 7735c12..ee6e372 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -16,7 +16,11 @@ "RequiredRoleCode": "" }, "AuthHex": { - "BaseUrl": "http://localhost:5011" + "BaseUrl": "http://localhost:5602" + }, + "FileStorage": { + "RootPath": "App_Data/hr-documents", + "MaxSizeBytes": 10485760 }, "AllowedHosts": "*" } diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index 4cbffea..f9431cf 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -27,6 +27,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [x] Item Type master (FR-MD-10) — CRUD + status + ETag; **unreferenced by design**, feeds the builder dropdown only - [x] SubCategory (FR-MD-04) — nested list/create under a category, `PUT`/`PATCH status` by id; `Item.subCategoryId` nullable FK, validated to belong to `categoryId` - [x] Product Configuration (FR-MD-11) — singleton `GET`/`PUT /product-config`; `CONFIG_DISABLED` gating on item writes +- [x] Item **sale price** (FR-MD-01, 2026-07-22) — nullable `Item.SalePrice` (`numeric(18,4)`); on all Item DTOs (list/detail/create/update), validated `>= 0`. **Sales-only** — never enters GRN/FIFO/ledger. `null` ⇒ sell at stock value. Migration `AddItemSalePrice`. See the 2026-07-22 Done entry. > ### 2026-07-16 — Brands, Subcategories, Item Types, Product Config (migration #2) > Makes real three concepts the frontend had been faking on mock data (`Frontend/erp-system/lib/api/mock-data.ts`), per docs/10 §B.3.1 FR-MD-09/10/11 and docs/11 §2.3/2.6/2.7/2.8. @@ -75,6 +76,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 3. Goods Receipt > Implemented + **smoke test PASSED** 2026-07-13 (see §4 note for the shared stock verification). Same `[~]` reason as §1/§2: the §6 auth+audit gate. - [x] GRN create (against PO / direct), over-receipt tolerance — `unitCost` **defaults to the PO price but is now overridable per line** (variance recorded vs `poUnitPrice` snapshot — 02-SECURITY C.3 revised 2026-07-20; the old "client cost ignored" block is gone); direct receipt requires `vendorId` + entered cost (AR-04); over-receipt → `422 OVER_RECEIPT_TOLERANCE` (verified at open-qty boundary); batch created/reused per (item, batchNo). Serial capture deferred. **Discount/VAT added** — see the 2026-07-20 entry. +- [x] **Off-PO lines on a PO-based GRN** (FR-GRN-01, 2026-07-22) — a line with `poLineId: null` on a PO-based GRN is received like a direct line (entered `unitCost`, no over-receipt check, PO balances untouched). **No code change was needed** — `GrnService.CreateAsync` already branches per-line on `input.PoLineId is not null`; documented + frontend-enabled. Same review/audit surface as AR-04 (02-SECURITY C.3). - [x] GRN confirm → FIFO layer + ledger + PO `qtyReceived` (single UoW txn) — verified: layers+ledger posted, running balance, PO → PartiallyReceived/FullyReceived, UOM→base conversion (10 Box-12 → 120 base @10). **Idempotent** re-confirm verified (no double-post). Note: `Idempotency-Key` accepted but idempotency is resource-state based (already-Confirmed replays existing result); a keyed idempotency store is deferred. - [x] Inspection hold release / reject — Release fully verified (OnHold excluded from `available`, then released). Reject removes on-hand + posts a reversing ledger entry; formal link to a Purchase Return is deferred (§3.4). @@ -108,10 +110,181 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [ ] Reservation/allocation fulfilment - [ ] RBAC policy enforcement + approval workflow activation +--- + +# HRM (Phase 2) + +Spec: `docs/12-BACKEND-HRM.md` (model + rules) · `docs/13-BACKEND-HRM-API.md` (API). Security: `docs/02-SECURITY.md §C.8` (run before ticking any HRM feature `[x]` — salary/PII data, see AR-09/AR-10). + +### 2026-07-23 — Bug fix: `DateTime Kind=Unspecified` 500 on every user-supplied date (Employee create, Statutory Settings create, etc.) +- **Root cause:** Postgres/Npgsql requires `DateTime` values written to a `timestamp with time zone` column to have `Kind=Utc`. Every Phase-1 `DateTime` was always server-generated (`DateTime.UtcNow`), so this never surfaced before. HRM is the first place user-supplied dates (hire date, salary-structure/statutory-setting effective date, leave/attendance period dates, document issue/expiry dates, …) get deserialized straight from a JSON request body — which produces `Kind=Unspecified` — and then persisted, so **any** create involving a date (`POST /employees`, `POST /payroll-statutory-settings`, `POST /tax-slabs`, `POST /employees/{id}/salary-structure`, `POST /leave-requests`, attendance upload, …) threw a `500` (`DbUpdateException` → `ArgumentException: Cannot write DateTime with Kind=Unspecified...`). Confirmed via `logs/erpcore-20260723.log`. +- **Fix:** a global EF Core `ValueConverter`/`ValueConverter` registered once in `ErpDbContext.OnModelCreating` (applied to every entity property of type `DateTime`/`DateTime?` via `modelBuilder.Model.GetEntityTypes()`), forcing `Kind=Utc` on write. Fixes the bug for every current and future HRM (and Phase-1) entity in one place, rather than patching each service call site individually. +- **No migration needed** — confirmed by scaffolding a migration and finding it empty (`Up`/`Down` both no-ops), then removing it. The converter doesn't change the store type (`timestamp with time zone` throughout), only how the CLR value's `Kind` is normalized before Npgsql sees it. +- **Verified:** `dotnet build` clean (0/0). Not yet re-verified end-to-end against a live AuthHex session (same blocker as the rest of this phase's runtime testing) — the next person to get a token should re-try `POST /employees` and `POST /payroll-statutory-settings` to confirm the `500` is gone. + +## 7. Sub-phase 2.1 — Employee + User-link + Documents +> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0/0); migration `AddHrmPhase1Foundation` generated + applied to the local Postgres DB (new tables only + one nullable `users.Email` column — the scaffolder's "possible data loss" warning is just the benign `UpdateData` setting the seeded system user's `Email` to `null`, not a drop). Runtime smoke-test (Swagger/browser) not yet run — do that before ticking `[x]`, per the §6 security-gate convention §1's note established for Phase 1. +- [x] Org masters: Branch, Department (self-nesting + cycle guard), Designation, EmploymentType, WorkShift — entities + configs + services + controllers (CRUD, ETag, deactivate-not-delete). Routes: `/branches`, `/departments`, `/designations`, `/employment-types`, `/work-shifts`. +- [x] Employee entity + config (EmployeeStatus enum, EmployeeCode uniqueness, all FKs) + service + controller (`/employees`) +- [x] EmployeeBankDetail (one-to-many, IsPrimary) — `GET/PUT /employees/{id}/bank-details` (full-replace) +- [x] `User.Email` column + unique index (Postgres allows multiple NULLs natively, same pattern as `AuthUserId` — no explicit filter needed); `UsersController.Create` now persists it locally (previously silently dropped despite `CreateUserRequest.Email` being required) and returns it on `ManagedUserDto`. +- [x] `EmployeeUserLinkService` + email-lookup endpoints (`GET /employees/email-lookup`, `GET /users/email-lookup`, both advisory/non-mutating) + `POST/DELETE /employees/{id}/link-user` + `linkUserId`/`linkEmployeeId` on the two create endpoints. `Employee.UserId` has a unique index (DB-level one-User-per-Employee guarantee) backed by service-level `EMPLOYEE_ALREADY_LINKED`/`USER_ALREADY_LINKED` (409) checks. +- [x] `HrDocumentType` master (CRUD, deactivate-not-delete) — `/hr-document-types` +- [x] `Infra/Storage/IFileStorageService` + `LocalFileStorageService` (root `App_Data/hr-documents` outside wwwroot, year/month bucketing, path-escape guard, registered as a **singleton** — stateless aside from the configured root) +- [x] `EmployeeDocument` entity + upload/list/download/status endpoints (`POST/GET /employees/{id}/documents`, `GET .../documents/{docId}/download`, `PATCH .../documents/{docId}/status`) — extension allowlist (`.pdf/.jpg/.jpeg/.png/.docx`) cross-checked against declared content-type, size cap from `FileStorage:MaxSizeBytes` (appsettings, default 10MB) + +**Deviations (recorded, not silently skipped):** +- **JIT-provisioning Email backfill not implemented.** `ShadowUserClaimsTransformation` still only sets Username/DisplayName from the AuthHex token — the token carries no `Email` claim (confirmed set: `UserId`/`UserTypeCode`/`RoleCode`/`NIC`/`jti`/`iat`), so backfilling it would require an extra AuthHex API call (`getUserDetails`) inside the claims-transformation hot path. Deferred as a follow-up; the primary path (`UsersController.Create`, which already collects `Email` in the request body) covers the common case of an ERPCore-driven user creation. +- **`DOCUMENT_TYPE_IN_USE` error code is defined but not wired to anything** — there is no hard-`DELETE` endpoint for `HrDocumentType` (same deactivate-only convention as every Phase-1 master; FR-MD-08), so nothing currently triggers it. Reserved for consistency with the doc, same posture as Phase 1's unused `MASTER_IN_USE` before transaction tables existed. +- **`EmployeeDocumentService` reads `Stream.Length` for the size-cap check** rather than `IFormFile.Length` directly — works because ASP.NET Core's default `IFormFile.OpenReadStream()` returns a seekable buffered stream, but would need revisiting if a non-seekable upload path is ever added. + +## 8. Sub-phase 2.2 — Attendance + Leave +> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0 errors); migration `AddHrmAttendanceAndLeave` generated (purely additive, no data-loss warning) + applied to the local Postgres DB. Packages added: `ClosedXML` 0.105.0, `CsvHelper` 33.1.0. Runtime smoke-test not yet run. +- [x] LeaveType (master CRUD, `/leave-types`), LeaveRequest (Draft/Submitted/Approved/Rejected/Cancelled, `/leave-requests`, DocNo via `NumberSequenceService` "LV"), LeaveBalance (`GET/PUT /employees/{id}/leave-balances`) — approving a request increments `LeaveBalance.TakenDays` via `ILeaveBalanceService.IncrementTakenDaysAsync` +- [x] WorkShift-based `AttendanceComputationService` (Working/Late/Early/OT minutes; overnight-shift handling; derives Present/HalfDay/Absent/Holiday/WeekOff/OnLeave) — the analog of `FifoCostingService` +- [x] AttendanceUploadBatch + AttendanceRecord entities/config (`WorkShiftId` snapshotted at ingestion per docs A.3) +- [x] Excel/CSV parsing (ClosedXML for `.xlsx`, CsvHelper for `.csv`) + `GET /attendance-batches/template.xlsx`/`?format=csv` — both share `AttendanceUploadService.ColumnNames` so template and parser can't drift +- [x] Upload → validate (employee-code resolution against Active employees, date/time parse, within-batch + cross-batch-confirmed duplicate detection) → confirm pipeline, exact status flow Draft→Validated→Confirmed→UsedInPayroll (`/attendance-batches`, `.../validate`, `.../confirm`) +- [x] Manual record edit (`PUT .../records/{id}`, blocked once Confirmed/UsedInPayroll → `409 ATTENDANCE_BATCH_LOCKED`) + duplicate resolution (`POST .../resolve-duplicate`, keep/discard/supersede) + unlock (`POST .../unlock`, mandatory reason, blocked once `UsedInPayroll`) +- [x] Leave→Attendance OnLeave classification wired in — `AttendanceUploadService` calls `ILeaveRequestService.FindApprovedLeaveCoveringAsync` per record during upload and re-computation + +**Deviations (recorded):** +- **LeaveRequest.DaysCount is a calendar-day count** (`EndDate − StartDate + 1`), not business-day/holiday-aware. Flagged as a simplification in the service's own doc comment — a real deployment will want to exclude weekends/holidays from paid-leave day counts before this feeds Payroll. +- **No `Holiday` calendar entity exists yet** — `AttendanceComputationService.Compute` always receives `isHoliday: false`; only `WeekOff` (derived from `WorkShift.WorkingDaysMask`) and `OnLeave` are currently distinguishable from a plain `Absent`. A company-holiday calendar is a natural near-term addition, not built in this pass. +- **`ResolveDuplicateAsync`'s "supersede" action does not yet locate/mutate the prior confirmed record** — it currently just accepts the new row as Valid. The prior confirmed `AttendanceRecord` this is meant to supersede is not looked up or flagged; this needs a follow-up pass before "supersede" is safe to expose to non-admin users in the UI. + +## 9. Sub-phase 2.3 — Payroll +> **Code complete + migration applied (2026-07-23).** `dotnet build` clean (0 errors); migration `AddHrmPayroll` generated + applied to the local Postgres DB. Runtime smoke-test not yet run. +- [x] SalaryComponent master (`/salary-components`) — Earning/Deduction, IsTaxable, IsEpfEtfApplicable +- [x] EmployeeSalaryStructure (+Lines), effective-dated (`GET/POST /employees/{id}/salary-structure`) — creating a new structure automatically supersedes the previous open-ended one (`EffectiveTo` set the day before the new `EffectiveFrom`), `409 SALARY_STRUCTURE_OVERLAP` if the new date isn't after the current one +- [x] EmployeeLoan (+Installments) ledger (`GET/POST /employees/{id}/loans`) — creating a loan generates its full installment schedule up front; `IEmployeeLoanService.GetDueInstallmentsAsync` is what `PayrollCalculationService` consumes +- [x] PayrollStatutorySetting (`/payroll-statutory-settings`), TaxSlab (`/tax-slabs`) — both effective-dated; creating a new statutory setting supersedes the prior open-ended one; tax slab creation validates no gap/overlap for the same effective date (`422 TAX_SLAB_GAP_INVALID`) +- [x] `PayrollCalculationService` — Gross = Basic + allowance lines + OT; Net = Gross − Late − NoPay − Loan − EPF(employee) − Tax − OtherDeductions; **EPF-employer/ETF are informational-only, never subtracted**, matching the spec's "Company Contribution" framing; Tax via standard ascending marginal-slab computation over taxable earnings +- [x] PayrollRun/PayrollLine/PayrollLineComponent + Draft→Approved→Locked workflow (`/payroll-runs`, generate/approve/lock/unlock/generate-payslips) — Generate blocked (`422 ATTENDANCE_NOT_CONFIRMED`) if any attendance batch for the period is still Draft/Validated; loan-installment (Pending→Deducted, balance decremented) and attendance-batch (Confirmed→UsedInPayroll) stamping deferred to **Lock**, not Generate/Approve, per docs A.4 +- [x] Unlock (`POST .../unlock`, mandatory reason) — reverses both the loan-installment and attendance-batch stamps made at Lock, back to Approved +- [x] Payslip generation (Locked-only, idempotent) + HTML print view (`GET /payslips/{id}/view`) — no PDF dependency, per the confirmed decision + +**Deviations / open items (recorded, not silently assumed):** +- **The exact APIT taxable-income base is a real compliance question, not resolved here** (docs/12-BACKEND-HRM.md B.4 flags this explicitly) — `PayrollCalculationService` computes taxable income as `Basic + taxable allowance lines + Overtime` and applies the configurable `TaxSlab` table as a standard ascending marginal calculation; whether EPF-employee should reduce taxable income first, or whether OT should be taxable at all, needs finance/statutory sign-off before go-live. +- **NoPayAmount currently only counts plain `Absent` days**, not unpaid-leave days — `AttendanceRecord` doesn't yet carry which `LeaveType` covered an `OnLeave` day (or whether it's paid), so all `OnLeave` days are currently treated as paid. A follow-up should either stamp the record with the leave's paid/unpaid flag at attendance-computation time, or join back to `LeaveRequest`/`LeaveType` during payroll calculation. +- **OT/Late per-minute rate model**: `dailyRate = Basic ÷ daysInMonth`, `perMinuteRate = dailyRate ÷ WorkShift.StandardWorkingMinutes` — a simplification flagged in `12-BACKEND-HRM.md B.4` (tiered late/OT policies are a future improvement, not built now). +- **`PayrollRunService.GenerateAsync` skips employees with no effective salary structure** for the period rather than failing the whole run — intentional (a partially-onboarded workforce shouldn't block payroll for everyone else), but means a run's employee count can silently be less than total active headcount; worth surfacing in the frontend as a warning list. +- **Loan-installment/attendance-batch reversal on Unlock re-derives "what this run touched" by period/status query**, not from a stored per-run link table (e.g. any `Confirmed`-turned-`UsedInPayroll` batch for the run's period, any installment whose `PayrollRunId` matches). This is correct for the common case but would need a real link if multiple concurrent runs ever target overlapping periods/branches — not expected in this phase (one run per period/branch). + +## 10. Sub-phase 2.4 — Reports +> **Code complete (2026-07-23).** `dotnet build` clean (0 errors). No new entities/migration — pure read-only aggregation over Attendance/Payroll/Leave/Document tables (`IHrReportService`/`HrReportsController`, `/reports/hrm/*`), same posture as `StockController`'s on-hand/ledger queries. Runtime smoke-test not yet run. +- [x] Attendance summary (`GET /reports/hrm/attendance-summary?periodYear=&periodMonth=&departmentId=`), OT report (`.../overtime`), late-arrival report (`.../late-arrivals`) +- [x] Payroll register (`.../payroll-register?payrollRunId=`) — `TotalDeductions` computed as `GrossSalary − NetSalary` (informational EPF-employer/ETF already excluded since they were never subtracted from Net) +- [x] Employee salary history (`.../salary-history?employeeId=`) — full `EmployeeSalaryStructure` revision history, ordered newest first +- [x] Leave balance report (`.../leave-balances?year=`), document expiry report (`.../document-expiry?withinDays=`) + +--- + +# Manufacturing — Production Lines (Phase 2) + +Spec: `docs/30-BACKEND-PHASE2.md` (model + rules **and** API — one doc, unlike Phase 1). Frontend consumption: `docs/21-FRONTEND-PHASE2.md`. Security: `docs/02-SECURITY.md §B.6` (narrow DTOs — statuses are never client-settable) + `§B.7` (FIFO row-locking inside the UoW txn). + +> **§11–§16 code complete, migration applied, and live smoke-tested (2026-07-30).** `dotnet build` clean (0 errors; the only warnings are the two pre-existing `CS8981` from the badly-named `chages` migration). Migration `AddManufacturingPhase2` — 11 `CreateTable`, zero `AlterColumn`, applied and verified against `information_schema`. **312 smoke assertions, all green**, via re-runnable scripts in `Backend/smoke/` against local Postgres + a real AuthHex session. + +## 11. Sub-phase 2.1 — Schema + enums +- [x] 11 entities (`ProductionTemplate`, `TemplateStage`, `StageEdge`, `StageInput`, `StageOutput`, `ProductionRun`, `RunStage`, `RunEdge`, `RunStageInput`, `RunStageOutput`, `RunStageEvent`) + `ProductionConfiguration.cs` (all 11 configs in one file, per the `StockConfiguration.cs` precedent). snake_case tables, PascalCase columns, enums as `varchar(20)`, qty/value `(18,4)`, unit cost + scale factor `(18,6)`, `xmin` RowVersion on template/run/run-stage +- [x] Enums `ProductionRunStatus`, `ProductionStageStatus`, `StageInputSource`, `RunStageEventType`, `CustomFieldType`; `ReasonContext` += `Production`; `DocumentTypes.Production = "PRD"`; new `Domain/LedgerSourceTypes.cs` +- [x] 4 Production reason codes seeded idempotently (`PRD-SCRAP`, `PRD-LEFTOVER`, `PRD-CANCEL`, `PRD-REWORK-LOSS`) — verified live via `GET /reason-codes?context=Production` +- [x] jsonb (`field_defs`, `field_values`, `payload`) as CLR `string` + `HasColumnType("jsonb")`, always written through `ProductionJson` so a column can only hold canonical JSON. Follows the `AuditLog.ChangeSet` precedent; a typed/owned mapping would make `AuditScribe` emit spurious audit rows for the nested entries + +## 12. Sub-phase 2.2 — Templates + graph validation (FR-MFG-01..07) +> **Smoke: 38/38** (`Backend/smoke/m2_templates.py`). Zero stock touched. +- [x] `ProductionGraphValidator` — a `public static class`, deliberately not an injected service (pure, synchronous, no DI). Kahn toposort → `GRAPH_CYCLE`; terminal count → `GRAPH_TERMINAL_COUNT`; one combined bidirectional-reachability check → `GRAPH_DISCONNECTED`; direct-parent check → `GRAPH_INPUT_SOURCE_INVALID`; terminal output → `TERMINAL_OUTPUT_ITEM_REQUIRED`. Works in **keys**, not ids, so identical code serves POST and PUT +- [x] `/production-templates` list/get/create/update/status, ETag + `If-Match` (428 missing, 412 stale), `409 TEMPLATE_IN_USE` on PUT while a run is InProgress +- [x] Full-graph PUT reconciliation: stages **diffed** (a run references them), inputs/outputs **replaced**, edges **diffed** (unique `(parent, child)` index). Rebuilt through navigation properties so EF resolves generated keys in one `SaveChanges` + +## 13. Sub-phase 2.3 — Run creation, board, detail, quantities (FR-MFG-08/09/18) +> **Smoke: 54/54** (`m3_runs.py`). Zero stock touched. +- [x] `POST /production-runs` — copies stages/inputs/outputs/edges in three passes, scales from the **unrounded** ratio (rounding each quantity once, so a repeating scale factor doesn't compound), `PRD-2026-0000N` from `NumberSequenceService` inside the transaction, entry stages `Ready` +- [x] `GET /production-runs` with `stageSummary` computed server-side; projected to an anonymous type first then mapped client-side (EF Core 10 cannot translate a record ctor alongside aggregates — same failure as `WarehouseValuationDto`, 2026-07-28) +- [x] `GET /production-runs/{id}` full graph incl. events, derived `isTerminal`/`isEntry`/`availableToTransfer`/`actualMinutes`/`costPool` +- [x] `PUT .../stages/{sid}/quantities` — `409 STAGE_NOT_EDITABLE` once started, and re-evaluates readiness (raising an upstream planned qty demotes a Ready stage back to Waiting) + +## 14. Sub-phase 2.4 — Stage execution (FR-MFG-10/11/12) · first stock-touching +> **Smoke: 66/66** (`m4_stage_actions.py`) **+ 14/14** (`m4b_uom_conversion.py`). Isolated `SMOKE-PRD` warehouse. +- [x] `…/start` — FIFO-consumes Stock inputs via `IFifoCostingService.ConsumeAsync`, `PRDI` ledger, `actualStartAt`. Consumes `max(0, plannedBase − consumedQty)` so a rework restart draws only the delta +- [x] `…/complete` — produced/scrapped per output + custom field values; `400 REQUIRED_FIELD_MISSING`, `400 REASON_CODE_REQUIRED`, Production-context reason enforced. **Overwrites** on a re-complete +- [x] `…/approve` (non-terminal) + `…/transfer` — default full transfer, optional partial, `422 TRANSFER_EXCEEDS_AVAILABLE`, child readiness recomputed. Routes by `fromRunOutputId`, not by edge +- [x] **`IUomConverter` extracted** from `GrnService.ToBaseAsync` into `Services/Stock/UomConverter.cs`; `GrnService` delegates to it, behaviour unchanged. Verified: a stage input declared in a 12× UOM consumes **360** base units, not 30; the ledger records base; an undefined conversion is `422`, never assumed 1:1 + +## 15. Sub-phase 2.5 — Terminal receipt + cost pool (FR-MFG-13) +> **Smoke: 36/36** (`m5_receipt.py`). +- [x] Terminal approve creates the finished layer at `costPool / goodQty`, posts `PRDR`, completes the run and closes the pool (`409 RUN_COST_CLOSED`) +- [x] **`decimal? valueOverride` added to `IFifoCostingService.PostLedgerAsync`** (default keeps `round(qty × unitCost, 4)`; every existing call site unaffected). Empirically necessary, not theoretical: at 300 units the 6 dp unit cost gives a naive value of `3405.5553` against a pool of `3405.5552` — a real 0.0001 drift. The smoke test asserts the naive product *would* have drifted, so the fixture cannot silently go blind +- [x] Batch/serial-tracked finished goods refused with `422` (this phase defines no batch creation on receipt). **Untested** — no tracked item exists in the dev DB; noted in the script + +## 16. Sub-phase 2.6 — Leftover return, rework, cancel (FR-MFG-14..17) +> **Smoke: 104/104** (`m6_m7_leftover_rework_cancel.py`). +- [x] `…/return-leftover` — inbound at the input's consumed weighted cost, `PRDL`, bin null (raw material, not the finished-goods bin). Value computed from the **unrounded** weighted cost and rounded once; a *full* return takes the exact residual so `returnedValue == consumedValue` precisely. `422 LEFTOVER_EXCEEDS_CONSUMED`, `409 RUN_COST_CLOSED` +- [x] `…/reject-intake` — parent `transferredQty` **decremented** (not zeroed, so a parent that also fed another child stays consistent), parent `Approved → InProgress` with `ActualStartAt` preserved, rejecting stage → `Waiting`. Allowed from `Ready` **or** `Waiting` with delivered intake +- [x] `…/reject` (terminal) — whole-run reset with one snapshot event per pass; `plannedQty` and `consumed*`/`returned*` **preserved**, stock untouched. Verified across two consecutive rework passes +- [x] `POST .../cancel` — returns `consumed − returned` per input at the consumed weighted cost (`PRDC`), `balances` dictionary accumulated per item (layers created in-transaction are invisible to `GetOnHandAsync` until `SaveChanges`), scrapped output qty recorded as `scrappedWrittenOff`. `409 RUN_NOT_CANCELLABLE` +- [x] Event history + estimated-vs-actual (FR-MFG-19) — every action writes one `RUN_STAGE_EVENT`; failed actions write none (the write rolls back with the change) + +**Bugs found and fixed during this phase (not silently patched):** +- **Template PUT 500** — deleting a `TemplateStage` while a `StageEdge` still referenced it severed a required EF relationship. Edges are now removed before stages; any edge touching a deleted stage is by construction absent from the payload, so nothing the caller wanted is orphaned. +- **`receipt.layerId` returned 0** — the `ReceiptDto` was built inside the transaction, before `SaveChanges` generated the id. Now mapped after the commit (same fix as the `ledgerRefs:[0]` issue recorded 2026-07-13). +- **Runtime messages carried U+2212** (typographic minus) and broke console/log encoding on Windows cp1252. Exception strings now use ASCII hyphens; comments keep the typographic form, matching the rest of the codebase. + +**Deviations / decisions (recorded, not silently assumed) — all mirrored into `docs/30`:** +- **§A.1 was a no-op.** `StockLayer.GrnLineId` was already nullable in entity, config, snapshot **and** database. Phase-1 schema was not altered at all; NFR-08 holds without exception. +- **Ledger codes are `PRDI`/`PRDR`/`PRDL`/`PRDC`**, not the doc's 15–22-char names — `SourceDocType` is `varchar(10)` on both `stock_ledger` and `journal_entry_stubs`, and widening it would have been a second Phase-1 schema change. +- **Three additions to Part C:** `RUN_EDGE` (a run must own its edges or a later template edit rewrites completed-run history), `RUN_STAGE.pos_x/pos_y` (the run canvas renders from them), `RUN_STAGE_EVENT.run_id` + nullable `run_stage_id` (run-level events, single-query timeline). `RUN_STAGE.template_stage_id` made nullable + `SET NULL` so a template stays editable after runs complete. +- **UOM conversion is unspecified in docs/30** but essential. Contract consequence: `plannedQty` is in the input's declared UOM while `consumed*`/`returned*` are in the item's **base** UOM. +- **`Idempotency-Key` accepted and ignored**, matching `GrnService.ConfirmAsync`. Status guards are the replay story; `RunStage.RowVersion` (xmin) prevents two concurrent terminal approves double-posting a receipt. +- **FR-MFG-17's "− scrapped" is not computable** at the input level (scrap lives on outputs, in output UOM). Scrap never entered stock, so nothing is deducted; scrapped quantities are recorded on the cancel event instead. +- **`GRAPH_DISCONNECTED` is unreachable** once cycle + terminal-count pass; kept as defence in depth. An isolated stage surfaces as `GRAPH_TERMINAL_COUNT`. +- **Edit-lock TOCTOU accepted** — checked inside the transaction, but under READ COMMITTED a run could still be created against a template mid-edit. Benign: runs copy everything at creation and never re-read the template. + +**Not done this pass (tracked, not silently skipped):** +- [x] **Frontend wiring** (`docs/21-FRONTEND-PHASE2.md` §8) — done in the same session; see `Frontend/PROGRESS.md` §§11–13. Not browser-verified (same AuthHex blocker). +- **Batch/serial-tracked finished goods** — guarded with a 422, and that guard is unexercised (no tracked item in the dev DB). +- **`NAV:production` permission** is not seeded; the sidebar still relies on the `bypassCodes` stopgap (same as `procurement`/`hrm`). +- **No automated test project** — verification is the `Backend/smoke/` scripts, per house practice. + +> ### 2026-07-30 — Dev-database repair + a drift audit worth repeating +> **`users."Email"` was missing from the database** while present in the entity and the model snapshot, so `ShadowUserClaimsTransformation`'s JIT insert failed with `42703` on **every authenticated request** — surfacing to callers as a confusing `InvalidOperationException: Sequence contains no elements`. `GET /items` and everything else 500'd. Fixed by the hand-written migration `RepairUserEmailColumn` (idempotent `ADD COLUMN IF NOT EXISTS` + the unique index, matching `UserConfiguration`'s `HasMaxLength(320)`). +> +> **Root cause — four migrations recorded as applied with zero operations:** `ini2`, `initial2`, `chages`, `chages1` each advanced the model snapshot without emitting any DDL. Anything added to the model in those windows exists in the snapshot but never reached the database. +> +> **Method note (this is the reusable part):** a scaffolded probe migration coming back **empty proves only `model == snapshot`, never `snapshot == database`** — which is exactly how this hid. The real audit was `dotnet ef dbcontext script` (which renders the *current model*) diffed against `information_schema.columns`. +> +> **Still outstanding — not fixed here, deliberately:** the same four empty migrations mean **all 25 HRM tables (`hr_*`) exist in the model and snapshot but not in this database**, so every HRM endpoint fails. Creating 25 tables of another module as a side effect of manufacturing work would be worse than reporting it; it needs its own repair migration and its own verification. +> +> **Also worth knowing:** `.gitignore:38` is `**/Migrations/`, so **no migration in this repo is version-controlled** — `AddManufacturingPhase2` and `RepairUserEmailColumn` exist only on the machine that created them. Anyone else must regenerate them. + +> ### 2026-07-30 (later) — Three server-side additions the frontend wiring needed +> All three are amended into `docs/30` as built. None changes an existing endpoint's behaviour. +> +> - **`TemplateGraphDto.activeRunCount`** — the builder derives its edit-locked state from it. Counted with its own scalar query rather than an `Include`, because the graph query already fans out over four collections and adding `Runs` would multiply those rows again for one integer. +> - **`production_templates."Annotations"` (jsonb)** + `SaveTemplateRequest.Annotations`, migration **`AddTemplateCanvasAnnotations`** (exactly one `AddColumn`, applied and verified). The builder canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so **every save would have silently discarded the user's layout**. Stored through `ProductionJson` like every other jsonb column, so the column can only ever hold canonical JSON; `List` capped at 200 by `[MaxLength]`, and `Kind` validated to `box`/`line` in `ValidateAsync` because nothing else constrains free-form client state going into jsonb. Deliberately invisible to `ProductionGraphValidator` — annotations carry no graph semantics. +> - **Wholesale replacement is the flip side and is now pinned by an assertion:** a PUT that omits `annotations` clears them. `m2_templates.py` asserts preserve → clear → restore explicitly, because silent data loss is worse than an error. +> +> **Smoke suite: extended but NOT re-run.** `m2_templates.py` gained 10 assertions (annotation round-trip incl. geometry/label/rotation, `activeRunCount` on the graph, unknown-kind rejection, and the clear/restore pair). **These are unverified.** AuthHex cannot issue a token — its configured MySQL host `187.127.102.190:3306` is unreachable from this machine (`MySqlConnector … Connect Timeout expired` on `POST /api/user`), and the localhost alternative in its `appsettings.json` is commented out. `dotnet build` is clean and the migration applied cleanly, but the last full green run of the suite (312/312) predates these additions. +> +> **HRM schema gap CLOSED** (by the repo owner, not this work): migrations `production` (another empty one — the fifth) and **`AddHrmTables`** now exist, the latter creating all 25 `hr_*` tables. The "still outstanding" note in the entry above is resolved; the underlying lesson about empty migrations is not. + + ## Done -### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (migration `AddGrnPricingAndPoDraft`) +### 2026-07-28 — Dashboard overview endpoint (`GET /dashboard/stats`) +- **New cross-domain aggregate for the frontend dashboard** — `Dtos/Dashboard/DashboardDtos.cs`, `IDashboardService`/`DashboardService`, `DashboardController` (`GET /api/v1/dashboard/stats`). Mirrors `docs/dashboard-implementation.pdf`'s widget list: low-stock alerts (reuses `IReorderService.GetAlertsAsync`, `PageSize:1` since it computes the full count before paging), on-hand total/warehouse-count and stock-valuation total/by-warehouse (all SQL-side `SUM`/`GROUP BY` over `StockLayer`, cheap unlike reorder alerts since they need no per-item live lookup), pending-approval POs, pending (Draft) GRNs, open (Submitted) requisitions, pending (Counted) stock counts, open RFQs. Registered in `Program.cs`. docs/11-BACKEND-PHASE1.md §5.8. +- **Not covered:** GRN inspection-hold counts (`HoldStatus` lives on GRN lines, no list/count endpoint exposes it) and recent stock movements (frontend calls `GET /stock/ledger` directly — no aggregation needed for a small `pageSize`). +- **Bug found + fixed during this work — `WarehouseValuationDto` construction inside `GroupBy().Select()` doesn't translate.** EF Core 10 can't turn a record's constructor call into SQL inside a grouped projection (`InvalidOperationException`, confirmed live via `logs/erpcore-20260728.log`). Fixed by projecting to an anonymous type first (`Select(g => new { g.Key, Total = ... })`), materializing with `ToListAsync`, then mapping to the DTO record client-side. +- **Unrelated bug found while testing this — `GET /items` 500s on every call: `column i.SalePrice does not exist`.** `ItemConfiguration.cs` maps `Item.SalePrice`, but the `AddItemSalePrice` migration (2026-07-22 entry above) was never actually applied to this dev database — despite that entry claiming "Applied to the local DB". Confirmed via `logs/erpcore-20260727.log`; `dotnet ef migrations add` against the current model produces an **empty** migration (no `Up`/`Down` ops), meaning the model snapshot already believes `SalePrice` exists even though the column doesn't — the snapshot and the real schema have drifted. **Not yet fixed** — needs a hand-written `AddColumn` migration (the auto-diff can't see the gap) run against this specific database; blocks the dashboard's on-hand/valuation widgets from ever showing item names, and blocks the entire Products page and every item picker (GRN/PO/ledger/valuation). +- **Verified:** `dotnet build` clean (isolated output directory, to avoid the Visual-Studio-debugger file lock that repeatedly blocked rebuilding the live dev instance this session). Runtime-verified against the live log after a VS restart — confirmed reaching real code (not 404), the `GroupBy` bug above was caught this way. Full 200-response verification still pending the next VS restart. + +### 2026-07-22 — Item fixed sale price + GRN off-PO items (migration `AddItemSalePrice`) +- **Item sale price (FR-MD-01).** New nullable `Item.SalePrice` (`numeric(18,4)`, `ItemConfiguration.HasPrecision(18,4)`), threaded through `ItemListItemDto`/`ItemDetailDto`/`CreateItemRequest`/`UpdateItemRequest` (`[Range(0, …)]`) and mapped in `ItemService` (create/update/`ToDetail`/list projection). **Sales-only** — it never touches `GrnService`, FIFO, `StockLayer`, or the ledger, so receipt/costing behaviour is byte-for-byte unchanged. `null` ⇒ "use stock value"; the fixed-vs-stock choice is a frontend toggle, not a server field (no `price_mode` enum). docs/10 C.1/C.9 + decision #14, docs/11 §2.1, 02-SECURITY C.1. +- **GRN off-PO items (FR-GRN-01).** A PO-based GRN may now carry lines with `poLineId: null` (item not on the PO). **No backend change** — `GrnService.CreateAsync` already routed such lines through the direct-receipt path (entered cost, no over-receipt check, no PO-balance update). Documented as intended behaviour; the frontend now exposes it. docs/10 FR-GRN-01/C.3 (`po_line_id` nullable) + decision #15, docs/11 §4.1, 02-SECURITY C.3. +- **Migration** `AddItemSalePrice` — single nullable column add; no backfill (`Down()` drops it). **Applied** to the local DB (`dotnet ef database update` → Done). +- **Verified:** `dotnet build` clean (compile succeeded; the only earlier failure was the running dev exe holding a file lock, resolved by stopping it). Migration Up applied. Frontend `tsc --noEmit` clean. End-to-end runtime smoke (Swagger/UI) still to be run by the user. - **PO draft lifecycle (FR-PROC-05 revised).** `CreatePurchaseOrderRequest.SaveAsDraft` (default `false` → auto-approve unchanged; `true` → `Draft`). New `POST /purchase-orders/{id}/submit` (Draft→Approved, else `409 PO_NOT_EDITABLE`) and `DELETE /purchase-orders/{id}` (Draft-only, else 409). `IsEditable` narrowed from "not FullyReceived/Closed/Cancelled" to **`Draft` only** — so `PUT` now 409s on any submitted PO. **Option B ("freely edit while open") is superseded**; docs/10 FR-PROC-05, docs/11 §3.3 updated. ⚠️ **Every pre-existing PO is `Approved` and therefore now uneditable/undeletable** — intended, not a regression. No schema change (reuses the existing `Draft` enum value). - **GRN discount + VAT + price override.** `GrnLine` gained `PoUnitPrice`(nullable snapshot), `DiscountPct`, `NetUnitCost`, `VatPct`, `VatAmount`, `LineTotal`. All derived figures **server-computed**, never client-supplied. FIFO layer + ledger now cost at **`NetUnitCost`** (after discount) — VAT is recoverable and never enters stock value (docs/10 FR-GRN-06 revised). For a PO line, `unitCost` defaults to the PO price but an entered override wins and a **variance** is recorded against `PoUnitPrice` (02-SECURITY C.3 revised — the "client cost ignored, decision locked" control is deliberately loosened; the variance trail + audit log are the compensating control). Multi-GRN-per-PO at differing prices (the 20/50/30 case) already worked via `openQty`/`QtyReceived` and is untouched. - **Migration** `AddGrnPricingAndPoDraft` — hand-added a data backfill (`UPDATE grn_lines SET NetUnitCost = UnitCost, LineTotal = ReceivedValue`) so existing GRN lines stay consistent with their already-posted FIFO layers; `PoUnitPrice` left NULL for historical rows (no retroactive variance). `Down()` drops the six columns cleanly. diff --git a/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc b/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc new file mode 100644 index 0000000..6a93b70 Binary files /dev/null and b/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc differ diff --git a/Backend/smoke/m2_templates.py b/Backend/smoke/m2_templates.py new file mode 100644 index 0000000..54aaa7e --- /dev/null +++ b/Backend/smoke/m2_templates.py @@ -0,0 +1,351 @@ +"""M2 smoke test — production template CRUD + graph validation (docs/30 §D.1, FR-MFG-01..07). + +Touches no stock: templates only. Run with the API and AuthHex up: + + python Backend/smoke/m2_templates.py + +Shape under test is a diamond, which exercises multiple entries converging on one +terminal *and* a stage with two parents: + + Cut ─┐ + ├─▶ Assemble (terminal, real item) + Prep ─┘ +""" + +from __future__ import annotations + +import sys + +from smoke_common import bootstrap + +TEMPLATE_CODE = "SMOKE-PT-M2" + + +def pick_fixtures(c, chk): + """Grab two active items and a UOM to build a realistic template from.""" + items = c.get("/items?pageSize=5&status=Active").body["items"] + if len(items) < 2: + sys.exit("FATAL: need at least 2 active items in the database.") + uoms = c.get("/uoms?pageSize=5").body["items"] + if not uoms: + sys.exit("FATAL: need at least 1 UOM in the database.") + return items[0]["itemId"], items[1]["itemId"], uoms[0]["uomId"] + + +def diamond(raw_item, finished_item, uom): + """A valid graph: two entry stages feeding one terminal stage.""" + return { + "code": TEMPLATE_CODE, + "name": "Smoke chair line", + "description": "Created by m2_templates.py", + "stages": [ + { + "key": "tmp-cut", "name": "Cut frame", "roleLabel": "Carpentry", + "estimatedMinutes": 60, "posX": 80, "posY": 120, + "fieldDefs": [{"key": "moisture_ok", "label": "Moisture check", + "type": "Checkbox", "required": True}], + "inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 8}], + "outputs": [{"key": "tmp-frame", "name": "Frame set", "uomId": uom, "qtyPerBatch": 1}], + }, + { + "key": "tmp-prep", "name": "Prep cushions", "roleLabel": "Upholstery", + "estimatedMinutes": 30, "posX": 80, "posY": 320, "fieldDefs": [], + "inputs": [{"source": "Stock", "itemId": raw_item, "uomId": uom, "qtyPerBatch": 2}], + "outputs": [{"key": "tmp-cushion", "name": "Cushion set", "uomId": uom, "qtyPerBatch": 1}], + }, + { + "key": "tmp-asm", "name": "Assemble & QA", "roleLabel": "QA", + "estimatedMinutes": 45, "posX": 560, "posY": 200, "fieldDefs": [], + "inputs": [ + {"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1}, + {"source": "Upstream", "fromOutputKey": "tmp-cushion", "uomId": uom, "qtyPerBatch": 1}, + ], + # Terminal output must name the finished item (FR-MFG-05). + "outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished_item, + "uomId": uom, "qtyPerBatch": 1}], + }, + ], + "edges": [ + {"parentKey": "tmp-cut", "childKey": "tmp-asm"}, + {"parentKey": "tmp-prep", "childKey": "tmp-asm"}, + ], + # Canvas-only decoration. No graph semantics at all — the validator never sees these, + # which is exactly what the "annotations do not affect the graph" assertion checks. + "annotations": [ + {"kind": "box", "posX": 40, "posY": 60, "width": 400, "height": 320, + "label": "Sub-assembly", "rotation": None}, + {"kind": "line", "posX": 480, "posY": 40, "width": 220, "height": 4, + "label": "Phase 2", "rotation": 90}, + ], + } + + +def cleanup(c): + """ + Find any template this script left behind, and clear the edit-lock if later scripts + started runs against it. + + Without this the script is single-use: FR-MFG-06 refuses a PUT while any run of the + template is InProgress, so a second execution would fail its very first assertion with + 409 TEMPLATE_IN_USE. Cancelling those runs is safe — they belong to the smoke suite. + """ + existing = c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"] + template = next((t for t in existing if t["code"] == TEMPLATE_CODE), None) + if template is None: + return None + + tid = template["templateId"] + if template["activeRunCount"] > 0: + reason = next((r["reasonCodeId"] for r in + c.get("/reason-codes?context=Production&pageSize=50").body["items"] + if r["code"] == "PRD-CANCEL"), None) + blocking = [r for r in c.get(f"/production-runs?templateId={tid}&status=InProgress&pageSize=200").body["items"]] + for run in blocking: + c.post(f"/production-runs/{run['runId']}/cancel", + {"reasonCodeId": reason, "note": "cancelled by m2_templates.py to clear the edit-lock"}) + print(f"cancelled {len(blocking)} in-progress run(s) to release the template edit-lock") + return tid + + +def main(): + c, chk, args = bootstrap(__doc__) + print(f"API {args.api} · AuthHex {args.auth}") + + raw_item, finished_item, uom = pick_fixtures(c, chk) + print(f"fixtures: rawItem={raw_item} finishedItem={finished_item} uom={uom}") + + stale = cleanup(c) + if stale: + print(f"note: reusing/overwriting existing template {stale} ({TEMPLATE_CODE})") + + payload = diamond(raw_item, finished_item, uom) + + # ---------------------------------------------------------------- create + chk.section("1. Create a valid diamond graph") + if stale: + head = c.get(f"/production-templates/{stale}") + created = c.put(f"/production-templates/{stale}", payload, if_match=head.etag) + chk.status("PUT existing template", created, 200) + else: + created = c.post("/production-templates", payload) + chk.status("POST /production-templates", created, 201) + + if created.status not in (200, 201): + return chk.finish("M2") + + tid = created.body["templateId"] + chk.check("ETag header present", created.etag is not None, True) + chk.check("3 stages persisted", len(created.body["stages"]), 3) + chk.check("2 edges persisted", len(created.body["edges"]), 2) + + # ------------------------------------------------------------------- get + chk.section("2. GET round-trips ids, keys, positions and fieldDefs") + got = c.get(f"/production-templates/{tid}") + chk.status("GET /production-templates/{id}", got, 200) + g = got.body + + stages = {s["name"]: s for s in g["stages"]} + chk.check("stage keys equal their ids", + all(s["key"] == str(s["stageId"]) for s in g["stages"]), True) + + cut = stages["Cut frame"] + chk.check("posX survived the round trip", float(cut["posX"]), 80.0) + chk.check("fieldDefs jsonb survived", cut["fieldDefs"][0]["key"], "moisture_ok") + chk.check("fieldDef type survived as enum name", cut["fieldDefs"][0]["type"], "Checkbox") + + asm = stages["Assemble & QA"] + chk.check("terminal has 2 upstream inputs", + sum(1 for i in asm["inputs"] if i["source"] == "Upstream"), 2) + chk.check("upstream inputs resolved fromOutputId", + all(i["fromOutputId"] for i in asm["inputs"] if i["source"] == "Upstream"), True) + chk.check("upstream inputs expose fromOutputKey", + all(i["fromOutputKey"] for i in asm["inputs"] if i["source"] == "Upstream"), True) + chk.check("terminal output carries the finished item", asm["outputs"][0]["itemId"], finished_item) + + # The builder reads activeRunCount straight off the graph to decide whether to disable + # itself; without it on this response it would need a second request to the list endpoint. + chk.check("graph exposes activeRunCount", g["activeRunCount"], 0) + + # Canvas annotations. Not in docs/30 Part C — added so a save can't silently discard the + # boxes and dividers the builder already draws. + chk.section("2b. Canvas annotations round-trip") + anns = {a["kind"]: a for a in g["annotations"]} + chk.check("both annotations persisted", len(g["annotations"]), 2) + chk.check("box label survived", anns.get("box", {}).get("label"), "Sub-assembly") + chk.check("box geometry survived", (float(anns["box"]["posX"]), float(anns["box"]["width"])), (40.0, 400.0)) + chk.check("line rotation survived", float(anns.get("line", {}).get("rotation") or 0), 90.0) + chk.check("annotations are not stages", len(g["stages"]), 3) + + bad_kind = dict(rebuild_from_get(g)) + bad_kind["annotations"] = [{"kind": "circle", "posX": 0, "posY": 0, "width": 10, + "height": 10, "label": None, "rotation": None}] + chk.status("unknown annotation kind rejected", + c.put(f"/production-templates/{tid}", bad_kind, if_match=got.etag), 422) + + # The keys GET hands back must be directly reusable as a PUT payload. + chk.section("3. Idempotent re-save using the server's own keys") + echo = rebuild_from_get(g) + resaved = c.put(f"/production-templates/{tid}", echo, if_match=got.etag) + chk.status("PUT echoing server keys", resaved, 200) + if resaved.status == 200: + chk.check("stage ids preserved (diffed, not recreated)", + sorted(s["stageId"] for s in resaved.body["stages"]), + sorted(s["stageId"] for s in g["stages"])) + chk.check("edge count still 2", len(resaved.body["edges"]), 2) + chk.check("annotations survived the echo re-save", len(resaved.body["annotations"]), 2) + etag = resaved.etag + + # Wholesale replacement cuts both ways: a client that forgets to echo annotations back + # wipes them. Pinned explicitly because that is a silent data loss, not an error. + stripped = rebuild_from_get(g) + stripped["annotations"] = [] + cleared = c.put(f"/production-templates/{tid}", stripped, if_match=etag) + chk.status("PUT omitting annotations", cleared, 200) + if cleared.status == 200: + chk.check("omitted annotations are cleared", len(cleared.body["annotations"]), 0) + restored = c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match=cleared.etag) + chk.status("PUT restoring annotations", restored, 200) + etag = restored.etag if restored.status == 200 else cleared.etag + else: + etag = got.etag + + # ------------------------------------------------------- graph rejections + chk.section("4. Graph validation rejections (FR-MFG-02, FR-MFG-04, FR-MFG-05)") + + cycle = rebuild_from_get(g) + cycle["edges"].append({"parentKey": key_of(g, "Assemble & QA"), "childKey": key_of(g, "Cut frame")}) + chk.status("cycle", c.put(f"/production-templates/{tid}", cycle, if_match=etag), 422, "GRAPH_CYCLE") + + two_term = rebuild_from_get(g) + two_term["edges"] = [e for e in two_term["edges"] if e["parentKey"] != key_of(g, "Prep cushions")] + chk.status("two terminals", c.put(f"/production-templates/{tid}", two_term, if_match=etag), + 422, "GRAPH_TERMINAL_COUNT") + + # An isolated stage has no outbound edge, so it is *also* a second terminal and the + # cheaper terminal-count check catches it first. That is the more useful error anyway — + # it names both offending stages. GRAPH_DISCONNECTED is unreachable in a valid-so-far + # DAG (see the note in ProductionGraphValidator) and is kept only as defence in depth. + orphan = rebuild_from_get(g) + orphan["stages"].append({ + "key": "tmp-orphan", "name": "Orphan stage", "estimatedMinutes": 5, + "posX": 900, "posY": 600, "fieldDefs": [], "inputs": [], + "outputs": [{"key": "tmp-orphan-out", "name": "Nothing", "uomId": uom, "qtyPerBatch": 1}], + }) + chk.status("isolated stage (reported as a second terminal)", + c.put(f"/production-templates/{tid}", orphan, if_match=etag), + 422, "GRAPH_TERMINAL_COUNT") + + # Grandparent reference: Cut -> Mid -> Asm, with Asm drawing from Cut's output. + grandparent = rebuild_from_get(g) + cut_key = key_of(g, "Cut frame") + asm_key = key_of(g, "Assemble & QA") + cut_output_key = output_key_of(g, "Cut frame", "Frame set") + grandparent["stages"].append({ + "key": "tmp-mid", "name": "Middle", "estimatedMinutes": 5, "posX": 320, "posY": 120, + "fieldDefs": [], + "inputs": [{"source": "Upstream", "fromOutputKey": cut_output_key, "uomId": uom, "qtyPerBatch": 1}], + "outputs": [{"key": "tmp-mid-out", "name": "Mid part", "uomId": uom, "qtyPerBatch": 1}], + }) + grandparent["edges"] = [e for e in grandparent["edges"] if e["parentKey"] != cut_key] + grandparent["edges"] += [{"parentKey": cut_key, "childKey": "tmp-mid"}, + {"parentKey": "tmp-mid", "childKey": asm_key}] + # Assemble still reads Cut's output, but Cut is now a grandparent -> invalid. + chk.status("upstream input from a grandparent", + c.put(f"/production-templates/{tid}", grandparent, if_match=etag), + 422, "GRAPH_INPUT_SOURCE_INVALID") + + no_item = rebuild_from_get(g) + for s in no_item["stages"]: + if s["name"] == "Assemble & QA": + s["outputs"][0]["itemId"] = None + chk.status("terminal output without an item", + c.put(f"/production-templates/{tid}", no_item, if_match=etag), + 422, "TERMINAL_OUTPUT_ITEM_REQUIRED") + + wip_item = rebuild_from_get(g) + for s in wip_item["stages"]: + if s["name"] == "Cut frame": + s["outputs"][0]["itemId"] = finished_item + chk.status("intermediate output claiming an item", + c.put(f"/production-templates/{tid}", wip_item, if_match=etag), 422) + + bad_item = rebuild_from_get(g) + for s in bad_item["stages"]: + if s["name"] == "Cut frame": + s["inputs"][0]["itemId"] = 999_999 + chk.status("stock input naming a nonexistent item", + c.put(f"/production-templates/{tid}", bad_item, if_match=etag), 422) + + chk.section("5. Concurrency + status") + chk.status("PUT with no If-Match", c.request("PUT", f"/production-templates/{tid}", rebuild_from_get(g)), + 428, "PRECONDITION_REQUIRED") + # Malformed vs stale are different failures. "AQAAAA==" is a well-formed 4-byte token + # (xmin = 1) that no live row will ever carry, so it reaches the service's version + # comparison and yields 412 rather than the 428 a garbage token would. + chk.status("PUT with a malformed If-Match", + c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match='"not-base64"'), + 428, "PRECONDITION_REQUIRED") + chk.status("PUT with a stale If-Match", + c.put(f"/production-templates/{tid}", rebuild_from_get(g), if_match='"AQAAAA=="'), + 412, "CONCURRENCY_CONFLICT") + + chk.status("PATCH status -> Inactive", + c.patch(f"/production-templates/{tid}/status", {"status": "Inactive"}), 204) + listed = c.get(f"/production-templates?q={TEMPLATE_CODE}") + row = next((t for t in listed.body["items"] if t["templateId"] == tid), None) + chk.check("list reports Inactive", row and row["status"], "Inactive") + chk.check("list reports stageCount 3", row and row["stageCount"], 3) + chk.check("list reports activeRunCount 0", row and row["activeRunCount"], 0) + + # Leave it Active so the M3 run-creation smoke can start runs from it. + c.patch(f"/production-templates/{tid}/status", {"status": "Active"}) + print(f"\nleft template {tid} ({TEMPLATE_CODE}) Active for the M3 smoke test") + + return chk.finish("M2") + + +# --- helpers: turn a GET response back into a save payload ------------------- + +def rebuild_from_get(g: dict) -> dict: + """Echo a fetched graph back as a save payload, reusing the server's keys.""" + return { + "code": g["code"], + "name": g["name"], + "description": g.get("description"), + "stages": [ + { + "key": s["key"], "name": s["name"], "roleLabel": s.get("roleLabel"), + "estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"], + "fieldDefs": s["fieldDefs"], + "inputs": [ + {"source": i["source"], "itemId": i.get("itemId"), + "fromOutputKey": i.get("fromOutputKey"), + "uomId": i["uomId"], "qtyPerBatch": i["qtyPerBatch"]} + for i in s["inputs"] + ], + "outputs": [ + {"key": o["key"], "itemId": o.get("itemId"), "name": o["name"], + "uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} + for o in s["outputs"] + ], + } + for s in g["stages"] + ], + "edges": [{"parentKey": e["parentKey"], "childKey": e["childKey"]} for e in g["edges"]], + # Echoed back deliberately: annotations are replaced wholesale, so omitting them here + # would make every re-save silently clear the canvas layout notes. + "annotations": g["annotations"], + } + + +def key_of(g: dict, stage_name: str) -> str: + return next(s["key"] for s in g["stages"] if s["name"] == stage_name) + + +def output_key_of(g: dict, stage_name: str, output_name: str) -> str: + stage = next(s for s in g["stages"] if s["name"] == stage_name) + return next(o["key"] for o in stage["outputs"] if o["name"] == output_name) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Backend/smoke/m3_runs.py b/Backend/smoke/m3_runs.py new file mode 100644 index 0000000..de0dc52 --- /dev/null +++ b/Backend/smoke/m3_runs.py @@ -0,0 +1,218 @@ +"""M3 smoke test — run creation, list/detail, quantity override (docs/30 §D.2, FR-MFG-08/09/18). + +Still touches no stock: creating a run only copies and scales the template. Depends on the +template m2_templates.py leaves behind, so run that first: + + python Backend/smoke/m2_templates.py + python Backend/smoke/m3_runs.py +""" + +from __future__ import annotations + +import sys + +from smoke_common import bootstrap + +TEMPLATE_CODE = "SMOKE-PT-M2" +TARGET_QTY = 50 + + +def find_template(c): + listed = c.get(f"/production-templates?q={TEMPLATE_CODE}") + row = next((t for t in listed.body["items"] if t["code"] == TEMPLATE_CODE), None) + if not row: + sys.exit(f"FATAL: template {TEMPLATE_CODE} not found — run m2_templates.py first.") + return row["templateId"] + + +def main(): + c, chk, args = bootstrap(__doc__) + print(f"API {args.api}") + + tid = find_template(c) + tpl = c.get(f"/production-templates/{tid}").body + warehouse = c.get("/warehouses?pageSize=1").body["items"][0]["warehouseId"] + # Captured before creating so the script stays re-runnable — a previous run of this + # script leaves its own InProgress run behind for M4. + runs_before = next(t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"] + if t["templateId"] == tid)["activeRunCount"] + print(f"template={tid} warehouse={warehouse} targetQty={TARGET_QTY} activeRunsBefore={runs_before}") + + terminal_stage = next(s for s in tpl["stages"] if s["name"] == "Assemble & QA") + terminal_qpb = terminal_stage["outputs"][0]["qtyPerBatch"] + expected_scale = TARGET_QTY / terminal_qpb + + # ------------------------------------------------------------------ create + chk.section("1. Create a run (FR-MFG-08: scale, copy, number)") + created = c.post("/production-runs", { + "templateId": tid, "targetQty": TARGET_QTY, "warehouseId": warehouse, + }) + if not chk.status("POST /production-runs", created, 201): + return chk.finish("M3") + + run = created.body + rid = run["runId"] + chk.check("ETag header present", created.etag is not None, True) + chk.check("docNo uses the PRD sequence", run["docNo"].startswith("PRD-"), True) + chk.check("run starts InProgress", run["status"], "InProgress") + chk.check("reworkCount starts at 0", run["reworkCount"], 0) + chk.check("scaleFactor computed", float(run["scaleFactor"]), float(expected_scale)) + chk.check("3 stages copied", len(run["stages"]), 3) + chk.check("2 edges copied", len(run["edges"]), 2) + + # ------------------------------------------------------- copy-on-create + chk.section("2. Copy-on-create carries display fields and fieldDefs (FR-MFG-06)") + stages = {s["name"]: s for s in run["stages"]} + chk.check("stage names copied", sorted(stages), ["Assemble & QA", "Cut frame", "Prep cushions"]) + + cut = stages["Cut frame"] + chk.check("roleLabel copied", cut["roleLabel"], "Carpentry") + chk.check("estimatedMinutes copied", cut["estimatedMinutes"], 60) + chk.check("posX copied", float(cut["posX"]), 80.0) + chk.check("fieldDefs copied", cut["fieldDefs"][0]["key"], "moisture_ok") + chk.check("fieldValues empty before complete", cut["fieldValues"], None) + chk.check("templateStageId links back", cut["templateStageId"] is not None, True) + + # -------------------------------------------------------------- readiness + chk.section("3. Entry stages Ready, others Waiting (FR-MFG-09)") + chk.check("Cut frame is an entry", cut["isEntry"], True) + chk.check("Cut frame Ready", cut["status"], "Ready") + chk.check("Prep cushions Ready", stages["Prep cushions"]["status"], "Ready") + + asm = stages["Assemble & QA"] + chk.check("Assemble is terminal", asm["isTerminal"], True) + chk.check("Assemble is not an entry", asm["isEntry"], False) + chk.check("Assemble Waiting", asm["status"], "Waiting") + chk.check("no stage is terminal but Cut/Prep", + [s["name"] for s in run["stages"] if s["isTerminal"]], ["Assemble & QA"]) + + # ----------------------------------------------------------------- scaling + chk.section("4. Every quantity scaled by the factor (FR-MFG-08)") + tpl_stages = {s["name"]: s for s in tpl["stages"]} + ok = True + for name, rs in stages.items(): + ts = tpl_stages[name] + for ti, ri in zip(ts["inputs"], rs["inputs"]): + want = round(float(ti["qtyPerBatch"]) * expected_scale, 4) + if float(ri["plannedQty"]) != want: + ok = False + print(f" input mismatch on {name}: {ri['plannedQty']} != {want}") + for to, ro in zip(ts["outputs"], rs["outputs"]): + want = round(float(to["qtyPerBatch"]) * expected_scale, 4) + if float(ro["plannedQty"]) != want: + ok = False + print(f" output mismatch on {name}: {ro['plannedQty']} != {want}") + chk.check("all planned quantities == qtyPerBatch x scaleFactor", ok, True) + chk.check("Cut frame input scaled (8 x 50)", float(cut["inputs"][0]["plannedQty"]), 400.0) + chk.check("terminal output scaled to the target", float(asm["outputs"][0]["plannedQty"]), float(TARGET_QTY)) + + chk.check("consumed/delivered start at zero", + all(float(i["consumedQty"]) == 0 and float(i["deliveredQty"]) == 0 + for s in run["stages"] for i in s["inputs"]), True) + chk.check("cost pool starts empty", float(run["costPool"]["net"]), 0.0) + + # Upstream inputs must point at the run's own copied outputs, not the template's. + run_output_ids = {o["runOutputId"] for s in run["stages"] for o in s["outputs"]} + chk.check("upstream inputs rewired to run outputs", + all(i["fromRunOutputId"] in run_output_ids + for i in asm["inputs"] if i["source"] == "Upstream"), True) + + # -------------------------------------------------------------------- list + chk.section("5. Run board projection (FR-MFG-18)") + listed = c.get(f"/production-runs?q={run['docNo']}") + chk.status("GET /production-runs", listed, 200) + row = next((r for r in listed.body["items"] if r["runId"] == rid), None) + chk.check("run appears on the board", row is not None, True) + if row: + chk.check("stageSummary counts match", + row["stageSummary"], {"waiting": 1, "ready": 2, "inProgress": 0, "done": 0, "approved": 0}) + chk.check("templateName joined", row["templateName"], tpl["name"]) + chk.check("finished item surfaced", row["finishedItemId"], asm["outputs"][0]["itemId"]) + chk.check("finished item name joined", row["finishedItemName"] is not None, True) + + chk.check("filter by status=InProgress finds it", + any(r["runId"] == rid for r in c.get("/production-runs?status=InProgress&pageSize=200").body["items"]), True) + chk.check("filter by status=Completed excludes it", + any(r["runId"] == rid for r in c.get("/production-runs?status=Completed&pageSize=200").body["items"]), False) + chk.check("filter by templateId finds it", + any(r["runId"] == rid for r in c.get(f"/production-runs?templateId={tid}&pageSize=200").body["items"]), True) + + # -------------------------------------------------------------- edit-lock + chk.section("6. Template edit-lock now that a run is InProgress (FR-MFG-06)") + head = c.get(f"/production-templates/{tid}") + locked = c.put(f"/production-templates/{tid}", { + "code": tpl["code"], "name": tpl["name"], "description": tpl.get("description"), + "stages": [ + {"key": s["key"], "name": s["name"], "roleLabel": s.get("roleLabel"), + "estimatedMinutes": s["estimatedMinutes"], "posX": s["posX"], "posY": s["posY"], + "fieldDefs": s["fieldDefs"], + "inputs": [{"source": i["source"], "itemId": i.get("itemId"), + "fromOutputKey": i.get("fromOutputKey"), "uomId": i["uomId"], + "qtyPerBatch": i["qtyPerBatch"]} for i in s["inputs"]], + "outputs": [{"key": o["key"], "itemId": o.get("itemId"), "name": o["name"], + "uomId": o["uomId"], "qtyPerBatch": o["qtyPerBatch"]} for o in s["outputs"]]} + for s in tpl["stages"] + ], + "edges": [{"parentKey": e["parentKey"], "childKey": e["childKey"]} for e in tpl["edges"]], + }, if_match=head.etag) + chk.status("PUT template while a run is InProgress", locked, 409, "TEMPLATE_IN_USE") + + # Deactivating must stay allowed — it only blocks NEW runs (FR-MFG-01). + chk.status("PATCH status while a run is InProgress is still allowed", + c.patch(f"/production-templates/{tid}/status", {"status": "Inactive"}), 204) + chk.status("run creation from an Inactive template", + c.post("/production-runs", {"templateId": tid, "targetQty": 5, "warehouseId": warehouse}), + 422, "TEMPLATE_INACTIVE") + c.patch(f"/production-templates/{tid}/status", {"status": "Active"}) + + chk.check("activeRunCount incremented by the new run", + next(t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"] + if t["templateId"] == tid)["activeRunCount"], runs_before + 1) + + # ------------------------------------------------------------- quantities + chk.section("7. Per-run quantity override (FR-MFG-08)") + cut_input = cut["inputs"][0] + edited = c.put(f"/production-runs/{rid}/stages/{cut['runStageId']}/quantities", + {"inputs": [{"id": cut_input["runInputId"], "plannedQty": 420}], "outputs": []}) + chk.status("PUT quantities on a Ready stage", edited, 200) + if edited.status == 200: + chk.check("plannedQty updated", float(edited.body["inputs"][0]["plannedQty"]), 420.0) + chk.check("stage still Ready (no upstream inputs)", edited.body["status"], "Ready") + + chk.status("PUT quantities naming another stage's input", + c.put(f"/production-runs/{rid}/stages/{cut['runStageId']}/quantities", + {"inputs": [{"id": asm["inputs"][0]["runInputId"], "plannedQty": 9}], "outputs": []}), + 422) + + # Raising a Waiting stage's upstream input must keep it Waiting, and the edit must be + # rejected outright once a stage has started (covered in M4 once we can start one). + asm_up = next(i for i in asm["inputs"] if i["source"] == "Upstream") + bumped = c.put(f"/production-runs/{rid}/stages/{asm['runStageId']}/quantities", + {"inputs": [{"id": asm_up["runInputId"], "plannedQty": 60}], "outputs": []}) + chk.status("PUT quantities on a Waiting stage", bumped, 200) + if bumped.status == 200: + chk.check("stage stays Waiting (nothing delivered)", bumped.body["status"], "Waiting") + + chk.status("PUT quantities on a nonexistent stage", + c.put(f"/production-runs/{rid}/stages/999999/quantities", {"inputs": [], "outputs": []}), 404) + + # ------------------------------------------------------------------ events + chk.section("8. Event history records the edits") + detail = c.get(f"/production-runs/{rid}") + chk.status("GET /production-runs/{id}", detail, 200) + events = detail.body["events"] + # Two, not four: only the two successful edits are recorded. The 422 (wrong stage) and + # the 404 both throw inside ExecuteInTransactionAsync, so their event write rolls back + # with the rest of the change — history never shows an edit that did not happen. + chk.check("only successful edits are logged", + sum(1 for e in events if e["eventType"] == "QuantityEdit"), 2) + chk.check("event payload captured", + events[0]["payload"] is not None if events else False, True) + chk.check("event carries an actor", events[0]["userId"] > 0 if events else False, True) + + print(f"\nleft run {rid} ({run['docNo']}) InProgress for the M4 smoke test") + return chk.finish("M3") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Backend/smoke/m4_stage_actions.py b/Backend/smoke/m4_stage_actions.py new file mode 100644 index 0000000..140808b --- /dev/null +++ b/Backend/smoke/m4_stage_actions.py @@ -0,0 +1,329 @@ +"""M4 smoke test — stage start/complete/approve/transfer (docs/30 §D.3, FR-MFG-09..12). + +FIRST STOCK-TOUCHING MILESTONE. Everything happens in a dedicated `SMOKE-PRD` warehouse so +the effects are isolated from real data and easy to inspect or clean up: + + DELETE FROM stock_ledger WHERE "WarehouseId" = (SELECT "WarehouseId" FROM warehouses WHERE "Code"='SMOKE-PRD'); + +Self-contained — builds its own template and run, so it does not depend on M2/M3 leftovers: + + python Backend/smoke/m4_stage_actions.py + +Leaves the terminal stage InProgress for m5_receipt.py to complete and approve. +""" + +from __future__ import annotations + +import json +import sys + +from smoke_common import bootstrap, drain_stock, seed_costed_stock + +WAREHOUSE_CODE = "SMOKE-PRD" +TEMPLATE_CODE = "SMOKE-PT-M4" +TARGET_QTY = 50 +SEED_RAW = 1000 # base units of the raw item +SEED_PACK = 500 # base units of the packaging item +# Deliberately awkward unit costs: the raw item is seeded in two layers at different costs +# so FIFO consumption produces a genuinely weighted value rather than a round number. +RAW_COST_1 = 2.5 +RAW_COST_2 = 4.75 +PACK_COST = 1.25 +STATE_FILE = "m4_state.json" + + +def ensure_warehouse(c): + for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"]: + if w["code"] == WAREHOUSE_CODE: + return w["warehouseId"] + created = c.post("/warehouses", {"code": WAREHOUSE_CODE, "name": "Production smoke warehouse"}) + if created.status != 201: + sys.exit(f"FATAL: could not create the smoke warehouse: {created.status} {created.body}") + return created.body["warehouseId"] + + +def adjustment_reason(c): + codes = c.get("/reason-codes?context=Adjustment&pageSize=50").body["items"] + if not codes: + sys.exit("FATAL: no Adjustment reason codes seeded.") + return codes[0]["reasonCodeId"] + + +def production_reason(c, code): + for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]: + if r["code"] == code: + return r["reasonCodeId"] + sys.exit(f"FATAL: Production reason {code} not seeded.") + + +def on_hand(c, item, wh): + return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"]) + + +def ledger_rows(c, run_id, source): + rows = c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"] + return rows + + +def seed_stock(c, wh, raw, pack, uom): + """ + Seed on-hand at explicit unit costs. Two raw layers at different costs mean the FIFO + consumption at start has to weight them, so `consumedValue` is a real number the + assertions can check rather than the 0.00 a positive adjustment would produce. + """ + seed_costed_stock(c, wh, [ + (raw, uom, SEED_RAW * 0.4, RAW_COST_1), + (raw, uom, SEED_RAW * 0.6, RAW_COST_2), + (pack, uom, SEED_PACK, PACK_COST), + ]) + + +def build_template(c, raw, pack, finished, uom): + """Cut (entry, stock input) → Assemble (terminal, upstream + a late stock input).""" + payload = { + "code": TEMPLATE_CODE, + "name": "Smoke M4 line", + "description": "Created by m4_stage_actions.py", + "stages": [ + { + "key": "tmp-cut", "name": "Cut", "roleLabel": "Carpentry", + "estimatedMinutes": 60, "posX": 80, "posY": 100, + "fieldDefs": [{"key": "moisture_ok", "label": "Moisture check", + "type": "Checkbox", "required": True}], + "inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": 8}], + "outputs": [{"key": "tmp-frame", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}], + }, + { + # A Stock input on a non-entry stage — FR-MFG-04 allows material to join late + # (packaging), which is exactly what this covers. + "key": "tmp-asm", "name": "Assemble", "roleLabel": "QA", + "estimatedMinutes": 45, "posX": 520, "posY": 100, "fieldDefs": [], + "inputs": [ + {"source": "Upstream", "fromOutputKey": "tmp-frame", "uomId": uom, "qtyPerBatch": 1}, + {"source": "Stock", "itemId": pack, "uomId": uom, "qtyPerBatch": 2}, + ], + "outputs": [{"key": "tmp-chair", "name": "Chair", "itemId": finished, + "uomId": uom, "qtyPerBatch": 1}], + }, + ], + "edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}], + } + + existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"] + if t["code"] == TEMPLATE_CODE), None) + if existing: + head = c.get(f"/production-templates/{existing['templateId']}") + res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag) + if res.status == 409: + # An earlier smoke run is still InProgress; reuse the template as-is. + return existing["templateId"] + if res.status != 200: + sys.exit(f"FATAL: could not update the M4 template: {res.status} {res.body}") + return res.body["templateId"] + + res = c.post("/production-templates", payload) + if res.status != 201: + sys.exit(f"FATAL: could not create the M4 template: {res.status} {res.body}") + return res.body["templateId"] + + +def main(): + c, chk, args = bootstrap(__doc__) + print(f"API {args.api}") + + items = c.get("/items?pageSize=5&status=Active").body["items"] + if len(items) < 3: + sys.exit("FATAL: need at least 3 active items.") + raw, pack, finished = items[0]["itemId"], items[1]["itemId"], items[2]["itemId"] + uom = items[0]["baseUomId"] + + wh = ensure_warehouse(c) + drained = drain_stock(c, wh) + if drained: + print(f"drained {len(drained)} leftover item(s) from a previous execution") + seed_stock(c, wh, raw, pack, uom) + raw_before = on_hand(c, raw, wh) + pack_before = on_hand(c, pack, wh) + print(f"warehouse={wh} raw={raw}(on-hand {raw_before}) pack={pack}(on-hand {pack_before}) finished={finished}") + + tid = build_template(c, raw, pack, finished, uom) + created = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh}) + if not chk.status("create the run", created, 201): + return chk.finish("M4") + + run = created.body + rid = run["runId"] + cut = next(s for s in run["stages"] if s["name"] == "Cut") + asm = next(s for s in run["stages"] if s["name"] == "Assemble") + cut_id, asm_id = cut["runStageId"], asm["runStageId"] + print(f"run={rid} {run['docNo']} cut={cut_id} assemble={asm_id}") + + # ------------------------------------------------------------------- start + chk.section("1. Stage start FIFO-consumes its stock inputs (FR-MFG-10)") + started = c.post(f"/production-runs/{rid}/stages/{cut_id}/start", idempotency_key="m4-start-cut") + if not chk.status("POST .../start", started, 200): + return chk.finish("M4") + + chk.check("stage now InProgress", started.body["status"], "InProgress") + chk.check("actualStartAt stamped", started.body["actualStartAt"] is not None, True) + chk.check("one input consumed", len(started.body["consumed"]), 1) + + con = started.body["consumed"][0] + chk.check("consumed the scaled quantity (8 x 50)", float(con["qty"]), 400.0) + chk.check("consumed layers reported", len(con["consumedLayers"]) >= 1, True) + chk.check("consumed value = qty x layer cost", + round(float(con["value"]), 4), + round(sum(float(l["qty"]) * float(l["unitCost"]) for l in con["consumedLayers"]), 4)) + # The whole point of seeding two layers at different costs: prove the value is genuinely + # FIFO-weighted rather than zero or a single flat rate. + chk.check("consumed value is non-zero", float(con["value"]) > 0, True) + layer_costs = {float(l["unitCost"]) for l in con["consumedLayers"]} + chk.check("consumption drew from the cheaper layer first (FR-STK-03)", + min(layer_costs), RAW_COST_1) + chk.check("ledgerRefs returned", len(started.body["ledgerRefs"]), 1) + + chk.check("on-hand fell by exactly the consumed qty", on_hand(c, raw, wh), raw_before - 400.0) + + prdi = ledger_rows(c, rid, "PRDI") + chk.check("one PRDI ledger row", len(prdi), 1) + if prdi: + chk.check("PRDI direction is Out", prdi[0]["direction"], "Out") + chk.check("PRDI qty is base-UOM 400", float(prdi[0]["qtyBase"]), 400.0) + + detail = c.get(f"/production-runs/{rid}").body + cut_now = next(s for s in detail["stages"] if s["runStageId"] == cut_id) + chk.check("consumedQty recorded on the input", float(cut_now["inputs"][0]["consumedQty"]), 400.0) + chk.check("cost pool now reflects the consumption", + float(detail["costPool"]["net"]), round(float(con["value"]), 4)) + + chk.section("2. Guards after starting") + chk.status("start again", c.post(f"/production-runs/{rid}/stages/{cut_id}/start"), + 409, "STAGE_NOT_READY") + chk.status("start a Waiting stage", c.post(f"/production-runs/{rid}/stages/{asm_id}/start"), + 409, "STAGE_NOT_READY") + chk.status("edit quantities on a started stage", + c.put(f"/production-runs/{rid}/stages/{cut_id}/quantities", + {"inputs": [{"id": cut_now["inputs"][0]["runInputId"], "plannedQty": 500}], "outputs": []}), + 409, "STAGE_NOT_EDITABLE") + + # ---------------------------------------------------------------- complete + chk.section("3. Stage complete records produced/scrap/fields (FR-MFG-11)") + cut_out = cut_now["outputs"][0]["runOutputId"] + + chk.status("complete without the required custom field", + c.post(f"/production-runs/{rid}/stages/{cut_id}/complete", + {"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}]}), + 400, "REQUIRED_FIELD_MISSING") + + chk.status("complete with scrap but no reason code", + c.post(f"/production-runs/{rid}/stages/{cut_id}/complete", + {"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2}], + "fieldValues": {"moisture_ok": True}}), + 400, "REASON_CODE_REQUIRED") + + chk.status("complete with an Adjustment-context reason", + c.post(f"/production-runs/{rid}/stages/{cut_id}/complete", + {"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 2, + "scrapReasonCodeId": adjustment_reason(c)}], + "fieldValues": {"moisture_ok": True}}), + 422) + + chk.status("complete with scrapped > produced", + c.post(f"/production-runs/{rid}/stages/{cut_id}/complete", + {"outputs": [{"runOutputId": cut_out, "producedQty": 5, "scrappedQty": 9, + "scrapReasonCodeId": production_reason(c, "PRD-SCRAP")}], + "fieldValues": {"moisture_ok": True}}), + 422) + + done = c.post(f"/production-runs/{rid}/stages/{cut_id}/complete", + {"outputs": [{"runOutputId": cut_out, "producedQty": 50, "scrappedQty": 0}], + "fieldValues": {"moisture_ok": True}}) + chk.status("complete properly", done, 200) + if done.status == 200: + chk.check("stage now Done", done.body["status"], "Done") + chk.check("actualEndAt stamped", done.body["actualEndAt"] is not None, True) + chk.check("actualMinutes computed", done.body["actualMinutes"] is not None, True) + chk.check("producedQty recorded", float(done.body["outputs"][0]["producedQty"]), 50.0) + chk.check("availableToTransfer = produced - scrapped - transferred", + float(done.body["outputs"][0]["availableToTransfer"]), 50.0) + chk.check("fieldValues persisted", done.body["fieldValues"], {"moisture_ok": True}) + + # ----------------------------------------------------------------- approve + chk.section("4. Approve with a partial transfer (FR-MFG-12)") + approved = c.post(f"/production-runs/{rid}/stages/{cut_id}/approve", + {"transfers": [{"runOutputId": cut_out, "qty": 30}]}) + chk.status("approve transferring 30 of 50", approved, 200) + if approved.status == 200: + chk.check("stage now Approved", approved.body["status"], "Approved") + chk.check("run still InProgress (non-terminal)", approved.body["runStatus"], "InProgress") + chk.check("no receipt on a non-terminal approve", approved.body["receipt"], None) + chk.check("one transfer reported", len(approved.body["transfers"]), 1) + t = approved.body["transfers"][0] + chk.check("transferred 30", float(t["qty"]), 30.0) + chk.check("child delivered 30", float(t["childDeliveredQty"]), 30.0) + chk.check("child still Waiting (30 < 50 planned)", t["childStatus"], "Waiting") + chk.check("remainder held on the stage", + float(approved.body["stage"]["outputs"][0]["availableToTransfer"]), 20.0) + + chk.section("5. Transfer the remainder, then over-transfer (FR-MFG-12)") + chk.status("transfer 21 (more than the 20 remaining)", + c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer", + {"runOutputId": cut_out, "qty": 21}), + 422, "TRANSFER_EXCEEDS_AVAILABLE") + + moved = c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer", + {"runOutputId": cut_out, "qty": 20}) + chk.status("transfer the remaining 20", moved, 200) + if moved.status == 200: + chk.check("child delivered 50", float(moved.body["transfers"][0]["childDeliveredQty"]), 50.0) + chk.check("child flipped to Ready (FR-MFG-09)", moved.body["transfers"][0]["childStatus"], "Ready") + chk.check("nothing left to transfer", + float(moved.body["stage"]["outputs"][0]["availableToTransfer"]), 0.0) + + chk.status("transfer once everything is gone", + c.post(f"/production-runs/{rid}/stages/{cut_id}/transfer", + {"runOutputId": cut_out, "qty": 1}), + 422, "TRANSFER_EXCEEDS_AVAILABLE") + + # -------------------------------------------------- late stock input start + chk.section("6. Start the terminal stage — a late Stock input (FR-MFG-04)") + started2 = c.post(f"/production-runs/{rid}/stages/{asm_id}/start") + chk.status("start Assemble", started2, 200) + if started2.status == 200: + chk.check("only the Stock input consumed (upstream is WIP)", len(started2.body["consumed"]), 1) + chk.check("packaging consumed 2 x 50", float(started2.body["consumed"][0]["qty"]), 100.0) + chk.check("packaging on-hand fell by 100", on_hand(c, pack, wh), pack_before - 100.0) + chk.check("no ledger row for the upstream WIP input", len(ledger_rows(c, rid, "PRDI")), 2) + + chk.section("7. Insufficient stock is refused (FR-MFG-10)") + big = c.post("/production-runs", {"templateId": tid, "targetQty": 100000, "warehouseId": wh}) + if big.status == 201: + big_cut = next(s for s in big.body["stages"] if s["name"] == "Cut")["runStageId"] + chk.status("start a stage needing more than on-hand", + c.post(f"/production-runs/{big.body['runId']}/stages/{big_cut}/start"), + 409, "STOCK_NEGATIVE_BLOCKED") + chk.check("on-hand untouched by the failed start", on_hand(c, raw, wh), raw_before - 400.0) + else: + chk.check("could create the oversized run", big.status, 201) + + chk.section("8. Event history") + events = c.get(f"/production-runs/{rid}").body["events"] + kinds = [e["eventType"] for e in events] + chk.check("Start logged twice", kinds.count("Start"), 2) + chk.check("Complete logged once", kinds.count("Complete"), 1) + chk.check("Approve logged once", kinds.count("Approve"), 1) + chk.check("Transfer logged once", kinds.count("Transfer"), 1) + chk.check("no event for the rejected actions", kinds.count("QuantityEdit"), 0) + + # Hand off to M5. + with open(STATE_FILE, "w") as f: + json.dump({"runId": rid, "templateId": tid, "warehouseId": wh, + "assembleStageId": asm_id, "finishedItemId": finished, + "rawItemId": raw, "packItemId": pack}, f) + print(f"\nwrote {STATE_FILE}; run {rid} has Assemble InProgress for m5_receipt.py") + + return chk.finish("M4") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Backend/smoke/m4_state.json b/Backend/smoke/m4_state.json new file mode 100644 index 0000000..8c047c9 --- /dev/null +++ b/Backend/smoke/m4_state.json @@ -0,0 +1 @@ +{"runId": 64, "templateId": 2, "warehouseId": 4, "assembleStageId": 118, "finishedItemId": 13, "rawItemId": 18, "packItemId": 14} \ No newline at end of file diff --git a/Backend/smoke/m4b_uom_conversion.py b/Backend/smoke/m4b_uom_conversion.py new file mode 100644 index 0000000..3351e3a --- /dev/null +++ b/Backend/smoke/m4b_uom_conversion.py @@ -0,0 +1,170 @@ +"""M4b smoke test — UOM conversion on production stock inputs. + +This covers the single highest-risk correctness gap in the manufacturing phase. +`IFifoCostingService.ConsumeAsync` works exclusively in an item's BASE UOM, while +`STAGE_INPUT.uom_id` is a free FK — docs/30 never mentions conversion at all. Without the +shared `IUomConverter` (extracted from `GrnService.ToBaseAsync`), a stage input declared in +"box of 12" would consume 1 base unit instead of 12 and silently mis-cost the whole run. + +The dev database has no `uom_conversions` rows at all, so the non-base path was previously +unexercised by any data. This script creates a real conversion and proves: + + * a stage input in a non-base UOM consumes qtyPerBatch x scaleFactor x factor base units + * the ledger records the BASE quantity, not the declared one + * an input in a UOM with no conversion defined is refused with 422 rather than mis-consumed + + python Backend/smoke/m4b_uom_conversion.py +""" + +from __future__ import annotations + +import sys + +from smoke_common import bootstrap + +WAREHOUSE_CODE = "SMOKE-PRD" +TEMPLATE_CODE = "SMOKE-PT-M4B" +FACTOR = 12 # 1 case = 12 base units +QTY_PER_BATCH = 3 # cases per batch +TARGET_QTY = 10 # -> scale 10 -> 30 cases -> 360 base units +SEED = 5000 + + +def main(): + c, chk, args = bootstrap(__doc__) + print(f"API {args.api}") + + # --- fixtures --------------------------------------------------------- + wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"] + if w["code"] == WAREHOUSE_CODE), None) + if wh is None: + sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).") + + items = c.get("/items?pageSize=5&status=Active").body["items"] + raw, finished = items[0], items[1] + base_uom = raw["baseUomId"] + + uoms = c.get("/uoms?pageSize=50").body["items"] + case_uom = next((u["uomId"] for u in uoms if u["uomId"] != base_uom), None) + if case_uom is None: + sys.exit("FATAL: need at least 2 UOMs to test conversion.") + print(f"item={raw['itemId']} baseUom={base_uom} caseUom={case_uom} factor={FACTOR}") + + # --- define the conversion ------------------------------------------- + chk.section("1. Define a non-base UOM conversion for the item") + conv = c.put(f"/items/{raw['itemId']}/uom-conversions", + {"conversions": [{"fromUom": case_uom, "toUom": base_uom, "factor": FACTOR}]}) + chk.status("PUT /items/{id}/uom-conversions", conv, 200) + if conv.status != 200: + return chk.finish("M4b") + chk.check("conversion stored", any(float(x["factor"]) == FACTOR for x in conv.body["conversions"]), True) + + reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"] + c.post("/stock-adjustments", {"warehouseId": wh, "reasonCodeId": reason, + "lines": [{"itemId": raw["itemId"], "qtyDelta": SEED}]}) + before = float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]) + print(f"on-hand before: {before}") + + # --- template whose stock input is declared in CASES ------------------ + chk.section("2. A stage input declared in the non-base UOM") + payload = { + "code": TEMPLATE_CODE, "name": "Smoke M4b conversion line", + "stages": [{ + "key": "tmp-only", "name": "Pack", "estimatedMinutes": 10, + "posX": 0, "posY": 0, "fieldDefs": [], + # Declared in cases, not base units. + "inputs": [{"source": "Stock", "itemId": raw["itemId"], + "uomId": case_uom, "qtyPerBatch": QTY_PER_BATCH}], + "outputs": [{"key": "tmp-out", "name": "Packed", "itemId": finished["itemId"], + "uomId": base_uom, "qtyPerBatch": 1}], + }], + "edges": [], + } + + existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"] + if t["code"] == TEMPLATE_CODE), None) + if existing: + head = c.get(f"/production-templates/{existing['templateId']}") + res = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=head.etag) + tid = existing["templateId"] if res.status in (200, 409) else None + chk.check("template ready", tid is not None, True) + else: + res = c.post("/production-templates", payload) + chk.status("create single-stage template", res, 201) + tid = res.body["templateId"] if res.status == 201 else None + + if tid is None: + return chk.finish("M4b") + + # A lone stage is both the entry and the terminal — worth asserting explicitly. + run = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET_QTY, "warehouseId": wh}) + chk.status("create the run", run, 201) + if run.status != 201: + return chk.finish("M4b") + + stage = run.body["stages"][0] + chk.check("single stage is both entry and terminal", + (stage["isEntry"], stage["isTerminal"]), (True, True)) + chk.check("single stage starts Ready", stage["status"], "Ready") + chk.check("plannedQty stays in the DECLARED uom (3 x 10 cases)", + float(stage["inputs"][0]["plannedQty"]), float(QTY_PER_BATCH * TARGET_QTY)) + + # --- the actual conversion assertion --------------------------------- + chk.section("3. Consumption converts cases to base units") + expected_base = QTY_PER_BATCH * TARGET_QTY * FACTOR # 3 x 10 x 12 = 360 + started = c.post(f"/production-runs/{run.body['runId']}/stages/{stage['runStageId']}/start") + chk.status("start the stage", started, 200) + if started.status != 200: + return chk.finish("M4b") + + con = started.body["consumed"][0] + chk.check(f"consumed {expected_base} BASE units, not {QTY_PER_BATCH * TARGET_QTY}", + float(con["qty"]), float(expected_base)) + chk.check("on-hand fell by the base quantity", + float(c.get(f"/stock/on-hand?itemId={raw['itemId']}&warehouseId={wh}").body["onHand"]), + before - expected_base) + + rows = c.get(f"/stock/ledger?sourceDocType=PRDI&sourceDocId={run.body['runId']}&pageSize=50").body["items"] + chk.check("one PRDI row", len(rows), 1) + if rows: + chk.check("ledger qtyBase is the converted quantity", float(rows[0]["qtyBase"]), float(expected_base)) + + detail = c.get(f"/production-runs/{run.body['runId']}").body + chk.check("consumedQty stored in base units", + float(detail["stages"][0]["inputs"][0]["consumedQty"]), float(expected_base)) + + # --- missing conversion is refused, not silently mis-consumed -------- + chk.section("4. An undefined conversion is refused (422), never assumed 1:1") + third_uom = next((u["uomId"] for u in c.get("/uoms?pageSize=50").body["items"] + if u["uomId"] not in (base_uom, case_uom)), None) + if third_uom is None: + chk.check("skipped: need a third UOM", True, True) + else: + bad = dict(payload) + bad["code"] = TEMPLATE_CODE + "-BAD" + bad["stages"] = [dict(payload["stages"][0])] + bad["stages"][0] = {**payload["stages"][0], + "inputs": [{"source": "Stock", "itemId": raw["itemId"], + "uomId": third_uom, "qtyPerBatch": 1}]} + made = c.post("/production-templates", bad) + if made.status != 201: + head = c.get(f"/production-templates?q={TEMPLATE_CODE}-BAD") + tid2 = next((t["templateId"] for t in head.body["items"] + if t["code"] == TEMPLATE_CODE + "-BAD"), None) + else: + tid2 = made.body["templateId"] + + if tid2: + run2 = c.post("/production-runs", {"templateId": tid2, "targetQty": 1, "warehouseId": wh}) + if run2.status == 201: + s2 = run2.body["stages"][0]["runStageId"] + chk.status("start a stage whose input UOM has no conversion", + c.post(f"/production-runs/{run2.body['runId']}/stages/{s2}/start"), 422) + else: + chk.check("could create the second run", run2.status, 201) + + return chk.finish("M4b") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Backend/smoke/m5_receipt.py b/Backend/smoke/m5_receipt.py new file mode 100644 index 0000000..e4ea40d --- /dev/null +++ b/Backend/smoke/m5_receipt.py @@ -0,0 +1,269 @@ +"""M5 smoke test — terminal approve = production receipt + cost pool (FR-MFG-13). + +Three things are proven here, the last two being the ones most likely to be silently wrong: + + A. A terminal approve creates a finished-goods layer costed at costPool / goodQty, + posts a PRDR inbound ledger entry, and completes the run. + B. Sum-of-ledger reconciliation: PRDR.value == sum(PRDI.value) - sum(PRDL.value) EXACTLY. + This is what the `valueOverride` parameter added to PostLedgerAsync exists for — the + 6 dp unit cost, multiplied out over 100+ units, drifts past the ledger's 4 dp tick. + C. A batch/serial-tracked finished item is refused rather than silently receiving + untracked stock (docs/30 defines no batch creation on receipt). + +Depends on the run m4_stage_actions.py leaves with its terminal stage InProgress: + + python Backend/smoke/m4_stage_actions.py + python Backend/smoke/m5_receipt.py +""" + +from __future__ import annotations + +import json +import os +import sys + +from smoke_common import bootstrap, drain_stock, seed_costed_stock + +STATE_FILE = "m4_state.json" +WAREHOUSE_CODE = "SMOKE-PRD" +SCRAP = 2 + +# Part B — the rounding case. The drift only appears when costPool / goodQty needs more than +# 6 decimal places, so the numbers are chosen so it does: +# consumed = 3.5 x 300 = 1050 base units at 1.234567 -> pool = 1296.29535 +# unitCost = round(1296.29535 / 300, 6) = 4.320985 (from 4.3209845, away from zero) +# naive qty x unitCost = 300 x 4.320985 = 1296.2955 != round(pool, 4) = 1296.2954 +# Without valueOverride the ledger would carry 1296.2955 and stop reconciling to the pool. +BIG_TEMPLATE_CODE = "SMOKE-PT-M5B" +BIG_TARGET = 300 +BIG_QTY_PER_BATCH = 3.5 +BIG_UNIT_COST = 1.234567 + + +def on_hand(c, item, wh): + return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"]) + + +def ledger_sum(c, run_id, source): + rows = c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"] + return sum(float(r["value"]) for r in rows), rows + + +def main(): + c, chk, args = bootstrap(__doc__) + print(f"API {args.api}") + + if not os.path.exists(STATE_FILE): + sys.exit(f"FATAL: {STATE_FILE} missing — run m4_stage_actions.py first.") + state = json.load(open(STATE_FILE)) + rid, wh = state["runId"], state["warehouseId"] + asm_id, finished = state["assembleStageId"], state["finishedItemId"] + + detail = c.get(f"/production-runs/{rid}").body + asm = next((s for s in detail["stages"] if s["runStageId"] == asm_id), None) + if asm is None: + sys.exit(f"FATAL: stage {asm_id} not on run {rid}.") + if asm["status"] != "InProgress": + sys.exit(f"FATAL: expected the terminal stage InProgress, found {asm['status']} — re-run m4.") + + pool_before = float(detail["costPool"]["net"]) + fin_before = on_hand(c, finished, wh) + print(f"run={rid} costPool={pool_before} finishedOnHand={fin_before}") + + # ------------------------------------------------- complete the terminal + chk.section("A1. Complete the terminal stage with scrap (FR-MFG-11)") + out_id = asm["outputs"][0]["runOutputId"] + scrap_reason = next(r["reasonCodeId"] for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"] + if r["code"] == "PRD-SCRAP") + + done = c.post(f"/production-runs/{rid}/stages/{asm_id}/complete", { + "outputs": [{"runOutputId": out_id, "producedQty": 50, "scrappedQty": SCRAP, + "scrapReasonCodeId": scrap_reason}], + }) + chk.status("complete the terminal stage", done, 200) + if done.status != 200: + return chk.finish("M5") + chk.check("stage Done", done.body["status"], "Done") + chk.check("scrap recorded", float(done.body["outputs"][0]["scrappedQty"]), float(SCRAP)) + chk.check("scrap reason recorded", done.body["outputs"][0]["scrapReasonCodeId"], scrap_reason) + + # Scrap is absorbed into the pool, not written off (FR-MFG-11): no extra ledger row. + chk.check("scrap posts no ledger entry", + float(c.get(f"/production-runs/{rid}").body["costPool"]["net"]), pool_before) + + # ------------------------------------------------------------- the receipt + chk.section("A2. Terminal approve posts the receipt and completes the run (FR-MFG-13)") + good = 50 - SCRAP + approved = c.post(f"/production-runs/{rid}/stages/{asm_id}/approve") + chk.status("approve the terminal stage", approved, 200) + if approved.status != 200: + return chk.finish("M5") + + chk.check("stage Approved", approved.body["status"], "Approved") + chk.check("run Completed", approved.body["runStatus"], "Completed") + chk.check("no WIP transfers from a terminal stage", approved.body["transfers"], []) + + receipt = approved.body["receipt"] + chk.check("receipt returned", receipt is not None, True) + if receipt: + chk.check("receipt is for the finished item", receipt["itemId"], finished) + chk.check("received the good quantity (produced - scrapped)", + float(receipt["qtyReceived"]), float(good)) + chk.check("receipt warehouse is the run warehouse", receipt["warehouseId"], wh) + chk.check("layer created", receipt["layerId"] > 0, True) + + pool = approved.body["costPool"] + chk.check("cost pool reported", pool is not None, True) + if pool and receipt: + chk.check("pool net = consumed - returned", + round(float(pool["net"]), 4), + round(float(pool["consumed"]) - float(pool["returned"]), 4)) + chk.check("pool net matches the pool before approval", float(pool["net"]), pool_before) + expected_unit = round(float(pool["net"]) / float(good), 6) + chk.check("unitCost = costPool / goodQty", float(receipt["unitCost"]), expected_unit) + chk.check("receipt value = the cost pool exactly", + float(receipt["value"]), round(float(pool["net"]), 4)) + + chk.check("finished on-hand rose by the good quantity", on_hand(c, finished, wh), fin_before + good) + + completed = c.get(f"/production-runs/{rid}").body + chk.check("completedAt stamped", completed["completedAt"] is not None, True) + chk.check("run status persisted as Completed", completed["status"], "Completed") + + # ------------------------------------------------- ledger reconciliation + chk.section("A3. Ledger reconciles to the cost pool") + issued, prdi = ledger_sum(c, rid, "PRDI") + returned, _ = ledger_sum(c, rid, "PRDL") + received, prdr = ledger_sum(c, rid, "PRDR") + chk.check("one PRDR row", len(prdr), 1) + if prdr: + chk.check("PRDR direction is In", prdr[0]["direction"], "In") + chk.check("sum(PRDI) - sum(PRDL) == sum(PRDR) [the core invariant]", + round(issued - returned, 4), round(received, 4)) + print(f" issued={issued} returned={returned} received={received}") + + # --------------------------------------------------- closed-run guards + chk.section("A4. A completed run is closed to further action") + chk.status("approve again", c.post(f"/production-runs/{rid}/stages/{asm_id}/approve"), 409) + chk.status("start a stage on a completed run", + c.post(f"/production-runs/{rid}/stages/{state['assembleStageId']}/start"), 409) + chk.status("edit quantities on a completed run", + c.put(f"/production-runs/{rid}/stages/{asm_id}/quantities", {"inputs": [], "outputs": []}), 409) + + # ------------------------------------ the rounding case (>= 100 units) + chk.section("B. Rounding: 300 units, where a 6 dp unit cost drifts past the 4 dp ledger tick") + raw = state["rawItemId"] + raw_uom = next(i["baseUomId"] for i in c.get("/items?pageSize=200").body["items"] + if i["itemId"] == raw) + finished_b = state["finishedItemId"] + + # A fresh single-stage template: base-UOM input so no conversion muddies the arithmetic, + # qtyPerBatch 3.5 so the consumed quantity is NOT a multiple of the target (which is what + # forces pool / goodQty to repeat). + payload_b = { + "code": BIG_TEMPLATE_CODE, "name": "Smoke M5 rounding line", + "stages": [{ + "key": "tmp-b", "name": "Mix", "estimatedMinutes": 5, "posX": 0, "posY": 0, + "fieldDefs": [], + "inputs": [{"source": "Stock", "itemId": raw, "uomId": raw_uom, + "qtyPerBatch": BIG_QTY_PER_BATCH}], + "outputs": [{"key": "tmp-bo", "name": "Mixed", "itemId": finished_b, + "uomId": raw_uom, "qtyPerBatch": 1}], + }], + "edges": [], + } + existing_b = next((t for t in c.get(f"/production-templates?q={BIG_TEMPLATE_CODE}").body["items"] + if t["code"] == BIG_TEMPLATE_CODE), None) + if existing_b: + hb = c.get(f"/production-templates/{existing_b['templateId']}") + rb = c.put(f"/production-templates/{existing_b['templateId']}", payload_b, if_match=hb.etag) + tid = existing_b["templateId"] if rb.status in (200, 409) else None + else: + rb = c.post("/production-templates", payload_b) + tid = rb.body["templateId"] if rb.status == 201 else None + + if tid is None: + chk.check("create the rounding template", False, True) + else: + # Drain first, then seed at the exact unit cost the arithmetic above assumes. Without + # the drain, FIFO would consume whatever earlier scripts left behind (at their costs) + # and the pool would not match the figures this section reasons about — the assertions + # would still "pass" while testing something else entirely. + drain_stock(c, wh) + seed_costed_stock(c, wh, [(raw, raw_uom, 5000, BIG_UNIT_COST)]) + + big = c.post("/production-runs", {"templateId": tid, "targetQty": BIG_TARGET, "warehouseId": wh}) + if big.status != 201: + chk.check(f"create the {BIG_TARGET}-unit run", big.status, 201) + else: + brid = big.body["runId"] + bstage = big.body["stages"][0] + bout = bstage["outputs"][0]["runOutputId"] + + s = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/start") + chk.status("start", s, 200) + d = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/complete", + {"outputs": [{"runOutputId": bout, "producedQty": BIG_TARGET, "scrappedQty": 0}]}) + chk.status("complete", d, 200) + a = c.post(f"/production-runs/{brid}/stages/{bstage['runStageId']}/approve") + chk.status("approve (single stage is the terminal)", a, 200) + + if a.status == 200: + bpool = float(a.body["costPool"]["net"]) + brec = a.body["receipt"] + unit = float(brec["unitCost"]) + naive = round(unit * BIG_TARGET, 4) + chk.check("cost pool is non-zero", bpool > 0, True) + chk.check("receipt value == cost pool exactly", float(brec["value"]), round(bpool, 4)) + print(f" pool={bpool} unitCost={unit} naive qty*unit={naive}") + # This is the assertion that justifies the valueOverride parameter existing. + # If the naive product ever equals the pool, the fixture stopped exercising + # the rounding path and the test has quietly gone blind. + chk.check("naive qty x unitCost really would have drifted (fixture still valid)", + naive != round(bpool, 4), True) + + bissued, _ = ledger_sum(c, brid, "PRDI") + breturned, _ = ledger_sum(c, brid, "PRDL") + breceived, _ = ledger_sum(c, brid, "PRDR") + chk.check("sum(PRDI) - sum(PRDL) == sum(PRDR) at 300 units", + round(bissued - breturned, 4), round(breceived, 4)) + + # ------------------------------------------- tracked finished goods + chk.section("C. A batch/serial-tracked finished item is refused") + tracked = next((i for i in c.get("/items?pageSize=200&status=Active").body["items"] + if i.get("trackingMode") in ("Batch", "Serial")), None) + if tracked is None: + chk.check("skipped: no batch/serial-tracked item in the database", True, True) + print(" NOTE: the TrackingMode guard in PostReceiptAsync is unexercised here.") + else: + print(f" using tracked item {tracked['itemId']} ({tracked['trackingMode']})") + payload = { + "code": "SMOKE-PT-TRACKED", "name": "Tracked finished good", + "stages": [{ + "key": "tmp-one", "name": "Make", "estimatedMinutes": 1, "posX": 0, "posY": 0, + "fieldDefs": [], "inputs": [], + "outputs": [{"key": "tmp-o", "name": "Tracked", "itemId": tracked["itemId"], + "uomId": tracked["baseUomId"], "qtyPerBatch": 1}], + }], + "edges": [], + } + made = c.post("/production-templates", payload) + ttid = made.body["templateId"] if made.status == 201 else next( + (t["templateId"] for t in c.get("/production-templates?q=SMOKE-PT-TRACKED").body["items"] + if t["code"] == "SMOKE-PT-TRACKED"), None) + if ttid: + tr = c.post("/production-runs", {"templateId": ttid, "targetQty": 1, "warehouseId": wh}) + if tr.status == 201: + st = tr.body["stages"][0]["runStageId"] + to = tr.body["stages"][0]["outputs"][0]["runOutputId"] + c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/start") + c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/complete", + {"outputs": [{"runOutputId": to, "producedQty": 1, "scrappedQty": 0}]}) + chk.status("approve a tracked finished good", + c.post(f"/production-runs/{tr.body['runId']}/stages/{st}/approve"), 422) + + return chk.finish("M5") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Backend/smoke/m6_m7_leftover_rework_cancel.py b/Backend/smoke/m6_m7_leftover_rework_cancel.py new file mode 100644 index 0000000..74f2b3b --- /dev/null +++ b/Backend/smoke/m6_m7_leftover_rework_cancel.py @@ -0,0 +1,422 @@ +"""M6 + M7 smoke test — leftover return, reject-intake, terminal reject, run cancel. + +Covers FR-MFG-14 (leftover return at the consumed weighted cost), FR-MFG-15 (downstream +reject pulls work back to the parent), FR-MFG-16 (terminal reject resets the run for a +rework pass) and FR-MFG-17 (cancel returns net consumed stock). + +Self-contained: builds its own two-stage template and three separate runs, and drains the +warehouse first so FIFO costs are known. Run after m4 so the warehouse exists: + + python Backend/smoke/m4_stage_actions.py + python Backend/smoke/m6_m7_leftover_rework_cancel.py + +The assertions that matter most and are easiest to get silently wrong: + * a FULL leftover return must leave returnedValue == consumedValue EXACTLY (no crumb) + * reject-intake must DECREMENT the parent's transferredQty, not zero it + * a terminal reject must PRESERVE consumedQty/consumedValue and plannedQty + * a re-complete after rework must OVERWRITE producedQty, not add to it + * a rework restart with an unchanged planned qty must consume NOTHING +""" + +from __future__ import annotations + +import sys + +from smoke_common import bootstrap, drain_stock, seed_costed_stock + +WAREHOUSE_CODE = "SMOKE-PRD" +TEMPLATE_CODE = "SMOKE-PT-M67" +TARGET = 10 +RAW_QPB = 5 # 5 raw per batch -> 50 base units at target 10 +UNIT_COST = 3.0 +SEED = 4000 + + +def on_hand(c, item, wh): + return float(c.get(f"/stock/on-hand?itemId={item}&warehouseId={wh}").body["onHand"]) + + +def ledger(c, run_id, source): + return c.get(f"/stock/ledger?sourceDocType={source}&sourceDocId={run_id}&pageSize=200").body["items"] + + +def prod_reason(c, code): + for r in c.get("/reason-codes?context=Production&pageSize=50").body["items"]: + if r["code"] == code: + return r["reasonCodeId"] + sys.exit(f"FATAL: Production reason {code} not seeded.") + + +def ensure_template(c, raw, finished, uom): + """Cut (entry) -> Assemble (terminal). Cut has the stock input we return leftovers from.""" + payload = { + "code": TEMPLATE_CODE, "name": "Smoke M6/M7 line", + "stages": [ + {"key": "tmp-cut", "name": "Cut", "estimatedMinutes": 10, "posX": 0, "posY": 0, + "fieldDefs": [], + "inputs": [{"source": "Stock", "itemId": raw, "uomId": uom, "qtyPerBatch": RAW_QPB}], + "outputs": [{"key": "tmp-f", "name": "Frame", "uomId": uom, "qtyPerBatch": 1}]}, + {"key": "tmp-asm", "name": "Assemble", "estimatedMinutes": 10, "posX": 400, "posY": 0, + "fieldDefs": [], + "inputs": [{"source": "Upstream", "fromOutputKey": "tmp-f", "uomId": uom, "qtyPerBatch": 1}], + "outputs": [{"key": "tmp-c", "name": "Chair", "itemId": finished, + "uomId": uom, "qtyPerBatch": 1}]}, + ], + "edges": [{"parentKey": "tmp-cut", "childKey": "tmp-asm"}], + } + existing = next((t for t in c.get(f"/production-templates?q={TEMPLATE_CODE}").body["items"] + if t["code"] == TEMPLATE_CODE), None) + if existing: + h = c.get(f"/production-templates/{existing['templateId']}") + r = c.put(f"/production-templates/{existing['templateId']}", payload, if_match=h.etag) + if r.status in (200, 409): + return existing["templateId"] + sys.exit(f"FATAL: could not update the template: {r.status} {r.body}") + r = c.post("/production-templates", payload) + if r.status != 201: + sys.exit(f"FATAL: could not create the template: {r.status} {r.body}") + return r.body["templateId"] + + +def new_run(c, tid, wh): + r = c.post("/production-runs", {"templateId": tid, "targetQty": TARGET, "warehouseId": wh}) + if r.status != 201: + sys.exit(f"FATAL: could not create a run: {r.status} {r.body}") + cut = next(s for s in r.body["stages"] if s["name"] == "Cut") + asm = next(s for s in r.body["stages"] if s["name"] == "Assemble") + return r.body, cut, asm + + +def main(): + c, chk, args = bootstrap(__doc__) + print(f"API {args.api}") + + wh = next((w["warehouseId"] for w in c.get(f"/warehouses?q={WAREHOUSE_CODE}&pageSize=50").body["items"] + if w["code"] == WAREHOUSE_CODE), None) + if wh is None: + sys.exit("FATAL: run m4_stage_actions.py first (it creates the SMOKE-PRD warehouse).") + + items = c.get("/items?pageSize=5&status=Active").body["items"] + raw, finished = items[0]["itemId"], items[1]["itemId"] + uom = items[0]["baseUomId"] + + drain_stock(c, wh) + seed_costed_stock(c, wh, [(raw, uom, SEED, UNIT_COST)]) + tid = ensure_template(c, raw, finished, uom) + consumed_units = RAW_QPB * TARGET # 50 + consumed_value = consumed_units * UNIT_COST # 150.00 + print(f"warehouse={wh} raw={raw} unitCost={UNIT_COST} consumesPerRun={consumed_units}") + + # ===================================================================== + # M6 — leftover return + # ===================================================================== + chk.section("M6-1. Partial leftover return at the consumed weighted cost (FR-MFG-14)") + run, cut, asm = new_run(c, tid, wh) + rid = run["runId"] + cut_in = cut["inputs"][0]["runInputId"] + + c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/start") + before = on_hand(c, raw, wh) + pool0 = float(c.get(f"/production-runs/{rid}").body["costPool"]["net"]) + chk.check("cost pool after start", pool0, consumed_value) + + ret = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", + {"qty": 5, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}) + chk.status("return 5 of 50 consumed", ret, 200) + if ret.status == 200: + chk.check("returnedQty echoed", float(ret.body["returnedQty"]), 5.0) + chk.check("returned at the consumed weighted cost", + float(ret.body["createdLayer"]["unitCost"]), UNIT_COST) + chk.check("returnedValue = qty x weighted cost", float(ret.body["returnedValue"]), 15.0) + chk.check("layer id populated", ret.body["createdLayer"]["layerId"] > 0, True) + chk.check("pool reduced by the returned value", + float(ret.body["costPool"]["net"]), consumed_value - 15.0) + chk.check("on-hand rose by the returned qty", on_hand(c, raw, wh), before + 5.0) + + prdl = ledger(c, rid, "PRDL") + chk.check("one PRDL row", len(prdl), 1) + if prdl: + chk.check("PRDL direction is In", prdl[0]["direction"], "In") + chk.check("PRDL has no bin (raw material, not the output bin)", prdl[0]["binId"], None) + + chk.section("M6-2. Guards") + chk.status("return more than remains", + c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", + {"qty": 46, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}), + 422, "LEFTOVER_EXCEEDS_CONSUMED") + chk.status("return with no reason code", + c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", {"qty": 1}), + 400, "REASON_CODE_REQUIRED") + adj_reason = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"][0]["reasonCodeId"] + chk.status("return with an Adjustment-context reason", + c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", + {"qty": 1, "reasonCodeId": adj_reason}), + 422) + chk.status("return against an upstream (WIP) input", + c.post(f"/production-runs/{rid}/inputs/{asm['inputs'][0]['runInputId']}/return-leftover", + {"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}), + 422) + + chk.section("M6-3. A FULL return must net the input to exactly zero") + rest = c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", + {"qty": 45, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}) + chk.status("return the remaining 45", rest, 200) + if rest.status == 200: + chk.check("pool is exactly zero after a full return", float(rest.body["costPool"]["net"]), 0.0) + detail = c.get(f"/production-runs/{rid}").body + ci = detail["stages"][0]["inputs"][0] + chk.check("returnedQty == consumedQty exactly", + float(ci["returnedQty"]), float(ci["consumedQty"])) + chk.check("returnedValue == consumedValue exactly", + float(ci["returnedValue"]), float(ci["consumedValue"])) + + # Close this run out so the RUN_COST_CLOSED guard can be checked. Each step is asserted: + # letting an intermediate call fail silently here previously made the *next* assertion + # look like the bug. + cut_out = next(s for s in detail["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"] + chk.status("close-out: complete Cut", + c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/complete", + {"outputs": [{"runOutputId": cut_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200) + chk.status("close-out: approve Cut", + c.post(f"/production-runs/{rid}/stages/{cut['runStageId']}/approve"), 200) + asm_out = next(s for s in c.get(f"/production-runs/{rid}").body["stages"] + if s["name"] == "Assemble")["outputs"][0]["runOutputId"] + chk.status("close-out: start Assemble", + c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/start"), 200) + chk.status("close-out: complete Assemble", + c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/complete", + {"outputs": [{"runOutputId": asm_out, "producedQty": TARGET, "scrappedQty": 0}]}), 200) + fin = c.post(f"/production-runs/{rid}/stages/{asm['runStageId']}/approve") + chk.status("close-out: approve the terminal", fin, 200) + if fin.status == 200: + chk.check("run completed even though the pool was fully returned (cost 0)", + fin.body["runStatus"], "Completed") + chk.check("zero-cost receipt still creates a layer", fin.body["receipt"]["layerId"] > 0, True) + chk.status("return a leftover after the receipt closed the pool", + c.post(f"/production-runs/{rid}/inputs/{cut_in}/return-leftover", + {"qty": 1, "reasonCodeId": prod_reason(c, "PRD-LEFTOVER")}), + 409, "RUN_COST_CLOSED") + + # ===================================================================== + # M7a — reject-intake + # ===================================================================== + chk.section("M7-1. Reject-intake pulls work back to the parent (FR-MFG-15)") + run2, cut2, asm2 = new_run(c, tid, wh) + rid2 = run2["runId"] + c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start") + d2 = c.get(f"/production-runs/{rid2}").body + cut2_out = next(s for s in d2["stages"] if s["name"] == "Cut")["outputs"][0]["runOutputId"] + c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete", + {"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]}) + start_at = next(s for s in c.get(f"/production-runs/{rid2}").body["stages"] + if s["name"] == "Cut")["actualStartAt"] + c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve") + + after_approve = c.get(f"/production-runs/{rid2}").body + a_cut = next(s for s in after_approve["stages"] if s["name"] == "Cut") + a_asm = next(s for s in after_approve["stages"] if s["name"] == "Assemble") + chk.check("child Ready before the reject", a_asm["status"], "Ready") + chk.check("parent transferred the full quantity", + float(a_cut["outputs"][0]["transferredQty"]), float(TARGET)) + + rej = c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake", + {"note": "Frames warped"}) + chk.status("reject-intake on the Ready child", rej, 200) + if rej.status == 200: + chk.check("rejecting stage back to Waiting", rej.body["status"], "Waiting") + chk.check("one parent pulled back", len(rej.body["pulledBack"]), 1) + pb = rej.body["pulledBack"][0] + chk.check("pulled-back qty", float(pb["qty"]), float(TARGET)) + chk.check("parent was Approved", pb["priorParentStatus"], "Approved") + chk.check("parent reverted to InProgress", pb["parentStatus"], "InProgress") + + r_cut = next(s for s in rej.body["run"]["stages"] if s["name"] == "Cut") + r_asm = next(s for s in rej.body["run"]["stages"] if s["name"] == "Assemble") + chk.check("parent transferredQty decremented to 0", + float(r_cut["outputs"][0]["transferredQty"]), 0.0) + chk.check("parent available to transfer restored", + float(r_cut["outputs"][0]["availableToTransfer"]), float(TARGET)) + chk.check("child deliveredQty cleared", float(r_asm["inputs"][0]["deliveredQty"]), 0.0) + chk.check("parent ActualStartAt PRESERVED (FR-MFG-19)", r_cut["actualStartAt"], start_at) + chk.check("parent ActualEndAt cleared for rework", r_cut["actualEndAt"], None) + chk.check("consumed stock stays consumed", + float(r_cut["inputs"][0]["consumedQty"]), float(consumed_units)) + + chk.status("reject-intake again with nothing delivered", + c.post(f"/production-runs/{rid2}/stages/{asm2['runStageId']}/reject-intake", {}), + 409, "STAGE_REJECT_INVALID") + + chk.section("M7-2. Re-complete OVERWRITES rather than accumulating") + re_done = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/complete", + {"outputs": [{"runOutputId": cut2_out, "producedQty": TARGET, "scrappedQty": 0}]}) + chk.status("re-complete the parent", re_done, 200) + if re_done.status == 200: + chk.check("producedQty overwritten, not doubled", + float(re_done.body["outputs"][0]["producedQty"]), float(TARGET)) + re_app = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/approve") + chk.status("re-approve the parent", re_app, 200) + if re_app.status == 200: + chk.check("child Ready again", + next(s for s in re_app.body["stage"]["outputs"] + for _ in [0])["transferredQty"] is not None, True) + + # ===================================================================== + # M7b — terminal reject + # ===================================================================== + chk.section("M7-3. Terminal reject resets the run for a rework pass (FR-MFG-16)") + d3 = c.get(f"/production-runs/{rid2}").body + asm3 = next(s for s in d3["stages"] if s["name"] == "Assemble") + asm3_out = asm3["outputs"][0]["runOutputId"] + pool_before = float(d3["costPool"]["net"]) + + c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/start") + c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/complete", + {"outputs": [{"runOutputId": asm3_out, "producedQty": TARGET, "scrappedQty": 0}]}) + + trj = c.post(f"/production-runs/{rid2}/stages/{asm3['runStageId']}/reject", + {"note": "Final QA failed batch"}) + chk.status("terminal reject", trj, 200) + if trj.status == 200: + chk.check("reworkCount incremented", trj.body["reworkCount"], 1) + run_after = trj.body["run"] + chk.check("run still InProgress", run_after["status"], "InProgress") + chk.check("completedAt still null", run_after["completedAt"], None) + + t_cut = next(s for s in run_after["stages"] if s["name"] == "Cut") + t_asm = next(s for s in run_after["stages"] if s["name"] == "Assemble") + chk.check("entry stage reset to Ready", t_cut["status"], "Ready") + chk.check("non-entry stage reset to Waiting", t_asm["status"], "Waiting") + chk.check("timings cleared", (t_cut["actualStartAt"], t_cut["actualEndAt"]), (None, None)) + chk.check("fieldValues cleared", t_cut["fieldValues"], None) + chk.check("producedQty cleared", float(t_cut["outputs"][0]["producedQty"]), 0.0) + chk.check("transferredQty cleared", float(t_cut["outputs"][0]["transferredQty"]), 0.0) + chk.check("deliveredQty cleared", float(t_asm["inputs"][0]["deliveredQty"]), 0.0) + + # The load-bearing half of FR-MFG-16. + chk.check("plannedQty PRESERVED", float(t_cut["inputs"][0]["plannedQty"]), float(consumed_units)) + chk.check("consumedQty PRESERVED", float(t_cut["inputs"][0]["consumedQty"]), float(consumed_units)) + chk.check("cost pool PRESERVED across the rework", float(run_after["costPool"]["net"]), pool_before) + + snap = [e for e in run_after["events"] if e["eventType"] == "TerminalReject"] + chk.check("exactly one snapshot event", len(snap), 1) + if snap: + chk.check("snapshot records the rework number", snap[0]["payload"]["reworkNumber"], 1) + chk.check("snapshot captured every stage", len(snap[0]["payload"]["stages"]), 2) + chk.check("snapshot kept the pre-reset produced figure", + any(float(o["producedQty"]) == TARGET + for s in snap[0]["payload"]["stages"] for o in s["outputs"]), True) + chk.check("reject note recorded", snap[0]["note"], "Final QA failed batch") + + # This is the single behavioural rule that makes FR-MFG-16 work: a start always consumes + # max(0, plannedBase - consumedQty), never the full planned figure. After a rework the + # material is still in the pool, so re-consuming it would double-charge the run. + chk.section("M7-4. Rework restart after RAISING the planned qty consumes only the delta") + cut2_in = next(i for s in c.get(f"/production-runs/{rid2}").body["stages"] + for i in s["inputs"] if s["name"] == "Cut")["runInputId"] + raised = consumed_units + 10 + chk.status("raise the planned qty on the reset (Ready) stage", + c.put(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/quantities", + {"inputs": [{"id": cut2_in, "plannedQty": raised}], "outputs": []}), 200) + + oh_before = on_hand(c, raw, wh) + pool_pre = float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"]) + restart = c.post(f"/production-runs/{rid2}/stages/{cut2['runStageId']}/start") + chk.status("restart after the raise", restart, 200) + if restart.status == 200: + chk.check("consumed ONLY the 10-unit delta, not the full 60", + float(restart.body["consumed"][0]["qty"]), 10.0) + chk.check("on-hand fell by only the delta", on_hand(c, raw, wh), oh_before - 10.0) + chk.check("consumedQty accumulated to the new planned total", + float(next(i for s in c.get(f"/production-runs/{rid2}").body["stages"] + for i in s["inputs"] if i["runInputId"] == cut2_in)["consumedQty"]), + float(raised)) + chk.check("pool grew by only the delta's value", + float(c.get(f"/production-runs/{rid2}").body["costPool"]["net"]), + pool_pre + 10.0 * UNIT_COST) + + chk.section("M7-5. A second rework, restarted UNCHANGED, consumes nothing at all") + # Drive the run round again to get the entry stage back to Ready with consumed == planned. + d5 = c.get(f"/production-runs/{rid2}").body + c5 = next(s for s in d5["stages"] if s["name"] == "Cut") + a5 = next(s for s in d5["stages"] if s["name"] == "Assemble") + c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/complete", + {"outputs": [{"runOutputId": c5["outputs"][0]["runOutputId"], + "producedQty": TARGET, "scrappedQty": 0}]}) + c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/approve") + c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/start") + c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/complete", + {"outputs": [{"runOutputId": a5["outputs"][0]["runOutputId"], + "producedQty": TARGET, "scrappedQty": 0}]}) + second = c.post(f"/production-runs/{rid2}/stages/{a5['runStageId']}/reject", {"note": "again"}) + chk.status("second terminal reject", second, 200) + if second.status == 200: + chk.check("reworkCount now 2", second.body["reworkCount"], 2) + chk.check("two snapshot events retained", + sum(1 for e in second.body["run"]["events"] if e["eventType"] == "TerminalReject"), 2) + + oh2 = on_hand(c, raw, wh) + prdi2 = len(ledger(c, rid2, "PRDI")) + again = c.post(f"/production-runs/{rid2}/stages/{c5['runStageId']}/start") + chk.status("restart with planned unchanged", again, 200) + if again.status == 200: + chk.check("nothing consumed (delta is zero)", len(again.body["consumed"]), 0) + chk.check("on-hand unchanged", on_hand(c, raw, wh), oh2) + chk.check("no new PRDI ledger row", len(ledger(c, rid2, "PRDI")), prdi2) + + chk.section("M7-6. Cancel returns net consumed stock (FR-MFG-17)") + run4, cut4, _ = new_run(c, tid, wh) + rid4 = run4["runId"] + in4 = cut4["inputs"][0]["runInputId"] + c.post(f"/production-runs/{rid4}/stages/{cut4['runStageId']}/start") + first = float(next(i for s in c.get(f"/production-runs/{rid4}").body["stages"] + for i in s["inputs"] if i["runInputId"] == in4)["consumedQty"]) + chk.check("first start consumed the full planned qty", first, float(consumed_units)) + + # ===================================================================== + # M7c — cancel + # ===================================================================== + oh_pre_cancel = on_hand(c, raw, wh) + cancelled = c.post(f"/production-runs/{rid4}/cancel", + {"reasonCodeId": prod_reason(c, "PRD-CANCEL"), "note": "Order cancelled"}) + chk.status("cancel the run", cancelled, 200) + if cancelled.status == 200: + chk.check("run Cancelled", cancelled.body["status"], "Cancelled") + chk.check("one return posted", len(cancelled.body["returns"]), 1) + r0 = cancelled.body["returns"][0] + chk.check("returned the net consumed qty", float(r0["qty"]), float(consumed_units)) + chk.check("returned at the consumed weighted cost", float(r0["unitCost"]), UNIT_COST) + chk.check("layer id populated", r0["layerId"] > 0, True) + chk.check("ledgerRefs populated", len(cancelled.body["ledgerRefs"]), 1) + chk.check("on-hand restored", on_hand(c, raw, wh), oh_pre_cancel + consumed_units) + + prdc = ledger(c, rid4, "PRDC") + chk.check("one PRDC row", len(prdc), 1) + if prdc: + chk.check("PRDC direction is In", prdc[0]["direction"], "In") + chk.check("PRDC value = the exact consumed residual", + float(prdc[0]["value"]), consumed_units * UNIT_COST) + + after_cancel = c.get(f"/production-runs/{rid4}").body + chk.check("cancel reason recorded", after_cancel["cancelReasonCodeId"] is not None, True) + chk.check("completedAt stays null on a cancel", after_cancel["completedAt"], None) + chk.check("pool nets to zero after the cancel return", + float(after_cancel["costPool"]["net"]), 0.0) + chk.check("cancel event logged", + any(e["eventType"] == "Cancel" for e in after_cancel["events"]), True) + + chk.section("M7-7. Cancel guards") + chk.status("cancel an already-cancelled run", + c.post(f"/production-runs/{rid4}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}), + 409, "RUN_NOT_CANCELLABLE") + chk.status("cancel a COMPLETED run", + c.post(f"/production-runs/{rid}/cancel", {"reasonCodeId": prod_reason(c, "PRD-CANCEL")}), + 409, "RUN_NOT_CANCELLABLE") + run5, _, _ = new_run(c, tid, wh) + chk.status("cancel with no reason code", + c.post(f"/production-runs/{run5['runId']}/cancel", {}), 400, "REASON_CODE_REQUIRED") + + return chk.finish("M6+M7") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Backend/smoke/run_all.py b/Backend/smoke/run_all.py new file mode 100644 index 0000000..ea6cb51 --- /dev/null +++ b/Backend/smoke/run_all.py @@ -0,0 +1,70 @@ +"""Run the whole manufacturing smoke suite in dependency order. + + python Backend/smoke/run_all.py + +Order matters: m4 creates the isolated SMOKE-PRD warehouse and drains it, m5 consumes the +run m4 leaves with its terminal stage InProgress. Each script is individually re-runnable, +but m5 deliberately refuses to run twice against an already-approved terminal stage — that +guard is what stops it silently asserting against the wrong state. + +Prerequisites: ERPCore on :5224 and AuthHex on :5602 (override with ERP_SMOKE_API / +ERP_SMOKE_AUTH / ERP_SMOKE_USER / ERP_SMOKE_PASSWORD). +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +SCRIPTS = [ + ("M2 templates + graph validation", "m2_templates.py"), + ("M3 run creation / board / quantities", "m3_runs.py"), + ("M4 stage start / complete / approve / transfer", "m4_stage_actions.py"), + ("M4b UOM conversion on stock inputs", "m4b_uom_conversion.py"), + ("M5 terminal receipt + cost pool", "m5_receipt.py"), + ("M6+M7 leftover / rework / cancel", "m6_m7_leftover_rework_cancel.py"), +] + +SUMMARY = re.compile(r"^(\S+): (\d+)/(\d+) assertions passed") + + +def main() -> int: + here = Path(__file__).parent + results = [] + failed = False + + for label, script in SCRIPTS: + print(f"\n{'=' * 70}\n{label} ({script})\n{'=' * 70}", flush=True) + proc = subprocess.run( + [sys.executable, script, *sys.argv[1:]], + cwd=here, capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + sys.stdout.write(proc.stdout) + if proc.stderr.strip(): + sys.stderr.write(proc.stderr) + + passed = total = 0 + for line in proc.stdout.splitlines(): + m = SUMMARY.match(line.strip()) + if m: + passed, total = int(m.group(2)), int(m.group(3)) + results.append((label, passed, total, proc.returncode)) + if proc.returncode != 0: + failed = True + + print(f"\n{'=' * 70}\nSUITE SUMMARY\n{'=' * 70}") + grand_passed = grand_total = 0 + for label, passed, total, rc in results: + grand_passed += passed + grand_total += total + state = "OK " if rc == 0 else "FAIL" + print(f" [{state}] {label:<48} {passed}/{total}") + print(f"\n TOTAL: {grand_passed}/{grand_total} assertions" + + (" — ALL GREEN" if not failed else " (SUITE FAILED)")) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Backend/smoke/smoke_common.py b/Backend/smoke/smoke_common.py new file mode 100644 index 0000000..c3ed12a --- /dev/null +++ b/Backend/smoke/smoke_common.py @@ -0,0 +1,259 @@ +"""Shared harness for the manufacturing smoke tests (docs/30-BACKEND-PHASE2.md). + +The repo has no test project; verification is a live smoke test against local Postgres +with a real AuthHex session, with the assertion count recorded in Backend/PROGRESS.md. +These scripts make that repeatable instead of ad-hoc curl. + +Auth note: ERPCore validates AuthHex's RS256 tokens offline against a statically +configured public key, so we log in to AuthHex *directly* and send the access token as a +Bearer header. That deliberately bypasses ERPCore's own /auth/login proxy, which would +otherwise need AuthHex:BaseUrl to match the port AuthHex actually listens on. + +Usage: + python m2_templates.py [--api URL] [--auth URL] [--user EMAIL] [--password PW] + +Environment variables (ERP_SMOKE_API, ERP_SMOKE_AUTH, ERP_SMOKE_USER, +ERP_SMOKE_PASSWORD) override the defaults; command-line flags override those. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request + +# Server messages and box-drawing output contain non-cp1252 characters, and the default +# Windows console codepage would raise UnicodeEncodeError mid-report — losing exactly the +# diagnostic text a failing assertion needs to show. +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + +DEFAULTS = { + "api": "http://localhost:5224/api/v1", + "auth": "http://localhost:5602", + "user": "admin@gmail.com", + "password": "Naveen@99", +} + + +def parse_args(description: str) -> argparse.Namespace: + p = argparse.ArgumentParser(description=description) + for key, default in DEFAULTS.items(): + p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default)) + return p.parse_args() + + +class Response: + __slots__ = ("status", "body", "headers") + + def __init__(self, status: int, body, headers: dict): + self.status = status + self.body = body + self.headers = headers + + @property + def code(self): + """The RFC 7807 domain error code, when the body carries one (docs/11 §1.8).""" + return self.body.get("code") if isinstance(self.body, dict) else None + + @property + def etag(self): + return self.headers.get("ETag") + + def __repr__(self): + return f"<{self.status} code={self.code}>" + + +class Client: + def __init__(self, api: str, token: str): + self.api = api.rstrip("/") + self.token = token + + def request(self, method: str, path: str, body=None, if_match: str | None = None, + idempotency_key: str | None = None) -> Response: + url = f"{self.api}{path}" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Accept", "application/json") + req.add_header("Authorization", f"Bearer {self.token}") + if data is not None: + req.add_header("Content-Type", "application/json") + if if_match: + req.add_header("If-Match", if_match) + if idempotency_key: + req.add_header("Idempotency-Key", idempotency_key) + + try: + with urllib.request.urlopen(req) as r: + raw = r.read() + return Response(r.status, _decode(raw), dict(r.headers)) + except urllib.error.HTTPError as e: + raw = e.read() + return Response(e.code, _decode(raw), dict(e.headers)) + + def get(self, path): + return self.request("GET", path) + + def post(self, path, body=None, **kw): + return self.request("POST", path, body, **kw) + + def put(self, path, body=None, **kw): + return self.request("PUT", path, body, **kw) + + def patch(self, path, body=None, **kw): + return self.request("PATCH", path, body, **kw) + + +def _decode(raw: bytes): + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw.decode(errors="replace") + + +def login(auth_url: str, identifier: str, password: str) -> str: + """Obtain an AuthHex access token via its `{functionName, payload}` envelope.""" + payload = { + "functionName": "loginUser", + "payload": {"identifier": identifier, "password": password, "deviceName": "erp-smoke"}, + "reference": "", + } + req = urllib.request.Request( + f"{auth_url.rstrip('/')}/api/user", + data=json.dumps(payload).encode(), + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req) as r: + envelope = json.loads(r.read()) + except urllib.error.URLError as e: + sys.exit(f"FATAL: cannot reach AuthHex at {auth_url} ({e}). Start ERP_Auth_Service first.") + + if not envelope.get("success") or not envelope.get("data"): + sys.exit(f"FATAL: AuthHex login failed: {envelope.get('message')!r}") + return envelope["data"]["accessToken"] + + +class Checker: + """Counts assertions so the pass total can be recorded in PROGRESS.md.""" + + def __init__(self): + self.passed = 0 + self.failed = 0 + + def check(self, label: str, actual, expected) -> bool: + ok = actual == expected + if ok: + self.passed += 1 + print(f" PASS {label}") + else: + self.failed += 1 + print(f" FAIL {label}\n expected: {expected!r}\n actual: {actual!r}") + return ok + + def status(self, label: str, response: Response, expected_status: int, expected_code: str | None = None): + ok = self.check(f"{label} -> {expected_status}", response.status, expected_status) + if expected_code is not None: + ok = self.check(f"{label} -> code {expected_code}", response.code, expected_code) and ok + if not ok and response.status >= 400: + detail = response.body.get("detail") if isinstance(response.body, dict) else response.body + print(f" server said: {detail}") + return ok + + def section(self, title: str): + print(f"\n--- {title} ---") + + def finish(self, name: str) -> int: + total = self.passed + self.failed + print(f"\n{'=' * 60}\n{name}: {self.passed}/{total} assertions passed" + + (f" ({self.failed} FAILED)" if self.failed else " — ALL GREEN") + + f"\n{'=' * 60}") + return 1 if self.failed else 0 + + +def bootstrap(description: str): + """Standard entry point: parse args, log in, return (client, checker, args).""" + args = parse_args(description) + token = login(args.auth, args.user, args.password) + return Client(args.api, token), Checker(), args + + +# --- stock fixtures ---------------------------------------------------------- +# +# Seeding matters more than it looks. A positive stock ADJUSTMENT is the obvious way to +# create on-hand, but StockMutator's inbound path costs it at *last cost*, which is 0.00 +# when the item has no prior layers. Stock seeded that way makes every cost-pool assertion +# pass trivially against zeros and proves nothing. A direct (no-PO) GRN lets us state the +# unit cost explicitly, so consumption produces a real, checkable value. + + +def drain_stock(c, warehouse_id: int) -> list: + """ + Zero out every item's on-hand in a warehouse via one negative adjustment. + + Needed because these scripts are re-runnable and FIFO is oldest-first: stock left behind + by a previous execution is consumed *before* anything seeded now. If an earlier run left + zero-cost layers (as an adjustment-based seed does), a later run's cost assertions would + silently read 0.00 and pass against nothing. Draining first makes each execution start + from a known-empty warehouse. + """ + rows = c.get(f"/stock/on-hand/list?warehouseId={warehouse_id}&pageSize=200").body["items"] + lines = [{"itemId": r["itemId"], "qtyDelta": -float(r["onHand"])} + for r in rows if float(r["onHand"]) > 0] + if not lines: + return [] + + reasons = c.get("/reason-codes?context=Adjustment&pageSize=5").body["items"] + if not reasons: + sys.exit("FATAL: no Adjustment reason codes seeded.") + + res = c.post("/stock-adjustments", { + "warehouseId": warehouse_id, + "reasonCodeId": reasons[0]["reasonCodeId"], + "lines": lines, + }) + if res.status != 201: + sys.exit(f"FATAL: could not drain the smoke warehouse: {res.status} {res.body}") + return lines + + +def ensure_vendor(c) -> int: + existing = c.get("/vendors?pageSize=1").body["items"] + if existing: + return existing[0]["vendorId"] + created = c.post("/vendors", {"code": "SMOKE-V", "name": "Smoke vendor"}) + if created.status != 201: + sys.exit(f"FATAL: could not create a vendor: {created.status} {created.body}") + return created.body["vendorId"] + + +def seed_costed_stock(c, warehouse_id: int, lines, vendor_id: int | None = None) -> None: + """ + Create on-hand at explicit unit costs via a direct GRN + confirm. + + `lines` is an iterable of (item_id, uom_id, qty, unit_cost). + """ + vendor_id = vendor_id or ensure_vendor(c) + grn = c.post("/grns", { + "vendorId": vendor_id, + "warehouseId": warehouse_id, + "lines": [ + {"itemId": i, "uomId": u, "qty": q, "unitCost": cost, "discountPct": 0, "vatPct": 0} + for (i, u, q, cost) in lines + ], + }) + if grn.status != 201: + sys.exit(f"FATAL: could not create the seeding GRN: {grn.status} {grn.body}") + + confirmed = c.post(f"/grns/{grn.body['grnId']}/confirm") + if confirmed.status != 200: + sys.exit(f"FATAL: could not confirm the seeding GRN: {confirmed.status} {confirmed.body}") diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 1b76f10..90d3a13 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -27,7 +27,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Forgot password — change password screen — UI built (`app/login/forgot/reset`); not yet wired to API ## 2. Master Data screens -- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. **2026-07-15, rebuilt twice this session — see the two same-day entries below for the full history; current state:** `/new` is now Category → Subcategory (always visible, disabled with "No subcategories" when the category has none) → Brand → a checkbox-driven **variant builder** sourced live from `variantCategoriesApi`, with no SKU/Name/Description/Default vendor/Tax class/Item type/Tracking mode/Base UOM fields left on the form at all (Item type/tracking mode/base UOM are now fixed constants — `"Stocked"`/`"None"`/`uomId 1` — baked into the submit call, not user-facing). Checking a category (Color, Size, or any custom one) reveals its value-entry UI; Color gets a native color picker + a required name field (stored internally as `"name|hex"`, decoded everywhere it's displayed/used for SKU) while every other category is free-text chips. A flat table (one column per checked category + SKU + Quantity) generalizes to any number of active categories via a cartesian-product `useMemo`, replacing the earlier hardcoded 2-column Color×Size matrix. Submitting loops `itemsApi.create()` once per combination; SKU is `--...`; item name is ` - /...`. Added optional `brandId`/`initialQty` to `Item`/`CreateItemRequest`/`ItemListItem` (`types/master-data.ts`) — **deviation:** neither field is in the documented Item DTO (`docs/11-BACKEND-PHASE1.md` §2.1); `initialQty` is captured but not wired into the Stock Core ledger (informational only, no warehouse/GRN behind it). +- [~] Items (`app/dashboard/products` list + filters, `/new` create, `/[id]` full edit + ETag/If-Match with 412-conflict handling + activate/deactivate) — FR-MD-01/08. **2026-07-22:** `/new` gained a **"Fixed price / Use stock value" sale-price toggle** — see the 2026-07-22 entry. Reused the pre-existing "Products" sidebar entry/stub route rather than adding a new nav item. **2026-07-15, rebuilt twice this session — see the two same-day entries below for the full history; current state:** `/new` is now Category → Subcategory (always visible, disabled with "No subcategories" when the category has none) → Brand → a checkbox-driven **variant builder** sourced live from `variantCategoriesApi`, with no SKU/Name/Description/Default vendor/Tax class/Item type/Tracking mode/Base UOM fields left on the form at all (Item type/tracking mode/base UOM are now fixed constants — `"Stocked"`/`"None"`/`uomId 1` — baked into the submit call, not user-facing). Checking a category (Color, Size, or any custom one) reveals its value-entry UI; Color gets a native color picker + a required name field (stored internally as `"name|hex"`, decoded everywhere it's displayed/used for SKU) while every other category is free-text chips. A flat table (one column per checked category + SKU + Quantity) generalizes to any number of active categories via a cartesian-product `useMemo`, replacing the earlier hardcoded 2-column Color×Size matrix. Submitting loops `itemsApi.create()` once per combination; SKU is `--...`; item name is ` - /...`. Added optional `brandId`/`initialQty` to `Item`/`CreateItemRequest`/`ItemListItem` (`types/master-data.ts`) — **deviation:** neither field is in the documented Item DTO (`docs/11-BACKEND-PHASE1.md` §2.1); `initialQty` is captured but not wired into the Stock Core ledger (informational only, no warehouse/GRN behind it). - [~] UOM + conversions (`app/dashboard/products/uoms` flat list + create dialog; conversions edited inline on the Item detail page via `PUT /items/{itemId}/uom-conversions`) — FR-MD-02/03 - [~] Categories (`app/dashboard/products/categories` indented tree view + create dialog with parent picker) — FR-MD-04. **2026-07-15:** added debounced search + Previous/Next pagination (`categoriesApi.list()` now takes `page`/`pageSize`/`q`/`sortOrder`, page size 5), matching the Vendor list's pagination pattern. - [~] Brands (`app/dashboard/products/brands` list + create/edit dialog + delete) — **not a documented FR/endpoint**; `lib/api/brands.ts` treats it as a standalone name-only master, same shape as Categories, since Item has no `brandId` in the doc. **2026-07-15:** added the same debounced search + pagination as Categories; `Item`/`CreateItemRequest`/`ItemListItem` gained `brandId` so the new-item variant builder (above) can attach a brand. @@ -44,7 +44,7 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** ## 4. Receiving screens - [~] GRN list (`app/dashboard/receiving/grn/page.tsx`) — loading/empty/error states, links to detail -- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. +- [~] GRN create (`app/dashboard/receiving/grn/new/page.tsx`) — "Against PO" (picks an Approved/PartiallyReceived PO, prefills open lines) and "Direct receipt" (vendor + manual lines) modes; per-line bin, qty, unit cost, **discount % / VAT %** (with live after-discount/after-VAT line total + a per-row PO-price variance hint and a document total), hold status, and batch (batchNo+expiryDate) or serial-number-list capture driven by the item's `trackingMode`. **2026-07-20:** discount/VAT/variance added — see the 2026-07-20 entry. **2026-07-22:** "Add line" now works in **PO mode** (off-PO items) + **"New item"** (opens `/dashboard/products/new` in a new tab) + **refresh** icon — see the 2026-07-22 entry. - [~] GRN confirm (`app/dashboard/receiving/grn/[id]/page.tsx`) — renders returned `createdLayers`/`ledgerRefs`/`poStatus` as a confirmation panel (20-FRONTEND §4); sends a stable `Idempotency-Key` per detail-page session - [~] Inspection hold release / reject — Release/Reject buttons per on-hold line, shown once the GRN is `Confirmed` - Sidebar: added "Receiving" nav entry (`components/Layouts/AppSidebar.tsx`) → `/dashboard/receiving/grn` @@ -87,9 +87,106 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** - [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt - [x] Transactional actions show server-returned side effects as confirmation — GRN confirm renders `createdLayers`/`ledgerRefs`/`poStatus` from the response +--- + +# HRM (Phase 2) + +Spec: `docs/21-FRONTEND-HRM.md` (flows + rules) · `docs/13-BACKEND-HRM-API.md` (API contract). + +## 8. Screens (per `21-FRONTEND-HRM.md §1`) +> **Code complete (2026-07-23).** All screens below built against the live HRM API (`lib/api/{employees,hrm-masters,hrm-master-factory,attendance,leave,payroll,hr-reports}.ts`, `types/hrm.ts`). `tsc --noEmit` clean for every new/changed file (the only remaining project-wide tsc errors are pre-existing, unrelated syntax errors in `app/dashboard/receiving/grn/new/page.tsx` — not touched this pass, not introduced by it). `eslint` clean on all new/changed files. Runtime browser verification not yet done — see the note at the end of this section. +- [x] Employees (`app/dashboard/hrm/employees/{page,[id]/page}.tsx`) — list + create dialog (department/designation/employment-type/work-shift selects) + detail page with an Overview/Bank Details/Documents/Salary & Loans tab switcher (plain button-based tabs — no `Tabs` primitive exists in `components/ui/` yet). Email-lookup cross-link suggestion chip on the create dialog's email field (`onBlur` → `employeesApi.emailLookup`), never auto-linking — the human must have already seen the match before `linkUserId` is set on submit. +- [x] **Salary & Loans tab** (added same session, follow-up to the initial pass) — Salary Structure section shows effective-dated history (`employeesApi.salaryStructureHistory`) + a "New Structure" dialog (effective date, basic salary, dynamic allowance/deduction lines picked from `salaryComponentsApi`, `employeesApi.createSalaryStructure`); Loans & Advances section shows the loan list (`employeesApi.listLoans`) + a "New Loan" dialog (Loan/Advance, principal, installment amount, count, start year/month, `employeesApi.createLoan`). +- [x] Users screen (`app/dashboard/settings/users/page.tsx`) extended with the same cross-link suggestion chip in reverse (`employeeCrossLinkApi.findStaffByEmail` on email blur), setting `linkEmployeeId` on submit. `ManagedUser`/`CreateUserRequest` types extended with `email`/`linkEmployeeId` to match the backend DTO changes. +- [x] Attendance (`app/dashboard/hrm/attendance/{page,[id]/page}.tsx`) — batch list + upload dialog (period start/end + file picker, `.xlsx`/`.csv`) + template download link (`attendanceTemplateUrl()`) + detail page rendering the Working Hours/Late/OT preview table with per-row validation-status badges, Validate/Confirm/Unlock actions gated on batch status, and Keep/Discard duplicate-resolution buttons (Draft only). +- [x] Leave (`app/dashboard/hrm/leave/page.tsx`) — single-page request list + create dialog (employee/leave-type selects, immediately submits after create) + inline Approve/Reject actions on Submitted rows (reject via a `window.prompt` for the reason — the simplest correct UX given the time budget; a proper dialog is a nicer follow-up, not a correctness gap). +- [x] Payroll (`app/dashboard/hrm/payroll/{page,[id]/page,[id]/lines/[lineId]/page}.tsx`) — run list + Generate dialog (year/month), detail page rendering the exact Basic/OT/Allowances/Deductions/Net preview table plus Approve/Lock/Unlock(with mandatory reason)/Generate Payslips actions gated on `status`, and a line-breakdown page matching the user's exact Earnings/Deductions/Employer-Contributions layout (EPF-employer/ETF explicitly labeled "informational — not deducted"). **Backend gap found and fixed during this pass**: there was no endpoint to list all `PayrollLine`s for a run (only single-line lookup existed) — added `GET /payroll-runs/{id}/lines` (`IPayrollRunService.ListLinesAsync`, `PayrollRunsController.ListLines`) since the Payroll Preview table genuinely needs it; documented in `docs/13-BACKEND-HRM-API.md §6`. +- [x] Reports (`app/dashboard/hrm/reports/page.tsx`) — single page, a report-type select switches which filter fields + table are shown (Attendance Summary / Overtime / Late Arrivals / Payroll Register / Salary History / Leave Balances / Document Expiry), each calling its own `hrReportsApi` method on demand. +- [x] Settings screens (`app/dashboard/hrm/settings/{page,branches,departments,designations,employment-types,work-shifts,document-types,leave-types,salary-components,statutory}/page.tsx`) — a hub page linking to 9 sub-screens. `components/hrm/CodeNameMasterPage.tsx` is a shared generic component for the three byte-identical "code + name" masters (Branch, Designation, EmploymentType) — the other masters (Department's parent/branch selects, WorkShift's many fields + working-days bitmask, HrDocumentType's category enum, LeaveType's paid/no-pay/carry-forward flags, SalaryComponent's type enum) each have their own page since their forms genuinely differ, matching this codebase's own existing convention of one file per master rather than a forced one-size-fits-all abstraction. Statutory settings page covers both `PayrollStatutorySetting` and `TaxSlab` (list + create, no edit — both are effective-dated/append-only by design). +- [x] Sidebar (`components/Layouts/AppSidebar.tsx`) — new "HRM" section with 6 children (Employees/Attendance/Leave/Payroll/Reports/Settings). **Deviation, matching existing precedent**: no backend `NavItem`/`SubNavItem` seed exists for `hrm`/`hrm.*` codes yet, so — exactly like the pre-existing `procurement` bypass — `hrm` is added to the same frontend-only `bypassCodes` set that skips the `navCodes` visibility check. This is also the AR-09 sidebar-visibility stopgap called out in `02-SECURITY.md §C.8`: it hides HRM from the UI for now but enforces nothing server-side. Remove the bypass once a real nav/permission seed exists. +- [x] `lib/api-client.ts` extended to support `FormData` request bodies (attendance file upload, staff document upload) — previously every request body was unconditionally `JSON.stringify`'d; now a `FormData` body skips both that and the `Content-Type` header (the browser sets its own multipart boundary). + +## 9. Validation posture (HRM specifics, per `21-FRONTEND-HRM.md §3`) +- [x] Client format/required checks on the Employee create dialog (code/name/hire-date/department/designation/employment-type/work-shift) and Attendance upload (period dates, file presence) — UX only, per `20-FRONTEND.md §3` +- [x] Server-authoritative, never assumed client-side: employee-code uniqueness, email-lookup match existence, one-User-per-Employee, attendance duplicate detection (within-batch/cross-batch), attendance batch lock state, payroll generation's attendance-confirmed precondition, payroll run lock state, and every calculated amount (Gross/Net/Tax/EPF/ETF/OT/Late/No-Pay) — the client never computes or previews these independently of what the server returns; all payroll tables render server-supplied numbers verbatim. + +**Not yet done, flagged rather than silently skipped:** +- **Runtime/browser verification.** Every screen above type-checks and lints clean, and was built directly against the live API contract confirmed by the backend smoke test (71+ registered routes, correct 401 gating), but no screen has been driven in an actual browser this pass — that needs a running AuthHex session (see `Backend/PROGRESS.md`'s sub-phase 2.1 note on why deep functional testing was deferred) to get past the login wall. +- **Leave reject uses a native `window.prompt`** instead of a dialog — functionally correct, but a lower-fidelity UX than the rest of the app's dialog-based patterns. +- **A pre-existing, unrelated syntax error in `app/dashboard/receiving/grn/new/page.tsx`** (unclosed JSX, last touched 2026-07-23 before this HRM pass started) blocks a clean whole-project `tsc --noEmit` run. Not introduced by this work and not fixed by it — confirmed via `git status`/`git log` that this file was untouched this session; scoped `eslint`/`tsc` checks against every HRM file individually (and the fact this is the *only* file `tsc` reports) confirm the HRM additions themselves are clean. + +--- + +# Manufacturing — Production Lines (Phase 2) + +Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND-PHASE2.md` (§D.1–D.3). Validation posture: `docs/20-FRONTEND.md §3` — client checks are UX only. + +> **Every production screen now runs on the real API. Both mock modules are deleted.** `npx tsc --noEmit` reports 0 errors in this module (the only 4 project errors are pre-existing HRM ones — see the note at the end of §13), and `npx next build` reports **"Compiled successfully"** before failing type-check on those same HRM files. **Nothing has been driven in a browser** — see the honesty note at the end. + +## 11. Contract layer (F1) — DONE +- [x] `types/production.ts` **fully rewritten** against docs/30 Part D — every request/response DTO, all six enums, and the stage-action result shapes. Replaces the frontend-only placeholder shapes entirely +- [x] Three contract corrections carried through: templates are keyed by **`code`** (not `docNo` — only runs get a document number); quantities use **`itemId`/`uomId`/`qtyPerBatch`** numeric FKs (not free-text uom/qty); stages carry **`posX`/`posY`**, so canvas layout round-trips through the server +- [x] `lib/api/production-templates.ts` — list/get/create/update/updateStatus with ETag + `If-Match` +- [x] `lib/api/production-runs.ts` — the full §D.3 surface (start, complete, approve, transfer, reject-intake, reject, return-leftover, cancel, quantities), every action taking an `idempotencyKey`; plus `isStaleStageError()` for the docs/21 §6 "409 on a stage-status code → refetch silently" rule +- [x] `lib/error-map.ts` — all 17 docs/30 §D.4 codes. **Also fixed a real mechanism gap:** `errorMessage()` let any mapped domain code override the server's `detail`, which would have thrown away the specifics the user needs — the graph validator names the offending stages, and the transfer/leftover guards quote the actual figures. Added `DETAIL_PREFERRED_CODES` so those eight codes let `detail` win and keep their map entry as a fallback + +## 12. Screens (F2–F5) — DONE +- [x] **Template overview** (`app/dashboard/production/templates/page.tsx`) — real `productionTemplatesApi.list` with a 300 ms debounced search, status filter, pagination and real `activeRunCount`. One-row-per-template canvas labelled from live data +- [x] **Template builder** (`templates/[id]/page.tsx`) — **fully rewired.** GETs the graph, holds the ETag, and the former `handleSave()` toast stub is now a real create/update. Node ids **are** the server's stage keys (`tmp-` for stages drawn this session), so a PUT diffs stages in place and keeps historical runs linked; `node.position` persists as `posX`/`posY`; real `itemsApi`/`uomsApi` pickers replaced `MOCK_ITEMS`; `/templates/new` renders an unsaved draft seeded from the overview dialog's query params and swaps its URL on first save. Also gained a Deactivate/Activate control — `productionTemplatesApi.updateStatus` previously had no UI path at all +- [x] **Run board** (`runs/page.tsx`) — real list with debounced doc-no search, template/warehouse/status filters and pagination. Start dialog posts `productionRunsApi.create` and **navigates to the run** +- [x] **Run detail** (`runs/[id]/page.tsx`) — canvas built from the run's own `posX`/`posY` and run edges, with per-stage intake (`delivered/planned`) and available-to-transfer badges, live cost pool, and a `stageSummary` computed from real stage statuses +- [x] **Stage drawer** (`runs/[id]/StageDrawer.tsx`) — the whole of docs/21 §5: per-status bodies (Waiting → explanation · Ready → editable planned quantities + Start · InProgress → produced/scrapped per output with a required Production reason + custom fields + Complete · Done non-terminal → approve with optional partial transfer · Done terminal → receipt preview + Approve & receive + Reject for rework · Approved → transfer remainder), reject-intake from Ready **or** Waiting-with-deliveries, and the per-stage event timeline +- [x] **Runtime custom-field renderer** (`runs/[id]/CustomFieldForm.tsx`) — `fieldDefs` → typed inputs for all five types, plus `missingRequiredFields()` which **mirrors the server's rule exactly**, including the part that surprises people: an unchecked Checkbox counts as *provided* (`false`), so a required checkbox does not force a tick +- [x] **Run-level actions** (`runs/[id]/RunActions.tsx`) — Return leftover (per consumed Stock input, showing consumed/returned/weighted cost, in base UOM and capped at the unreturned remainder) and Cancel run (previewing what goes back to stock). Both hidden once the run leaves InProgress, because `RUN_COST_CLOSED`/`RUN_NOT_CANCELLABLE` mean offering them could only produce an error +- [x] `lib/production-status-colors.ts` kept untouched — it already matches docs/21 §3 exactly and is the single source for status colour everywhere +- [x] **Deleted `lib/production-mock-runs.ts` and `lib/production-mock-templates.ts`**, including `buildStagePlan()` + +**Deviations / decisions (recorded):** +- **The drawer is one file, not the seven the plan sketched.** Each per-status panel is ~30 lines and they all share the same lookup helpers, `submit()` wrapper and error handling; splitting them would mean threading that shared context through seven prop lists for no isolation benefit. `CustomFieldForm` and `RunActions` *are* separate, because both stand alone and neither needs the drawer's form state. +- **The board shows per-status counts, not named stages.** The list projection carries `stageSummary` only, so naming stages there would mean guessing which stage holds which count — exactly what the deleted `buildStagePlan()` did. Named per-stage state lives on the run detail, where the server actually returns it. +- **Non-terminal outputs have their `itemId` stripped on save, not rejected.** A stage that *was* terminal and then gained a child keeps its picked item in local state with the field no longer rendered; an issue-list message about an invisible field would be unactionable, so the builder drops it silently (FR-MFG-05 forbids it on a WIP output anyway). +- **The terminal receipt preview is computed client-side.** There is no preview endpoint and every input (cost pool, produced, scrapped) is already on the page, so the drawer mirrors the server's arithmetic to show the layer *before* creating it. Preview only — the server recomputes. +- **A status toggle re-reads the ETag.** `PATCH /status` bumps the row's `xmin`, invalidating the token the builder holds. It re-GETs and takes *only* the etag and status, deliberately not reloading the canvas, because a full reload there would silently discard unsaved edits. +- **"New Template" opens an unsaved draft rather than creating immediately.** A template cannot exist without a valid graph — the server requires ≥1 stage and a terminal output naming a real item (FR-MFG-02/05) — so there is nothing sensible to POST from a name alone. +- **`templateGraphToSaveRequest()` was deleted from `lib/api/production-templates.ts`.** It converted a fetched graph into a save payload, but the builder's canvas — not the last GET — is the source of truth for what gets saved, so it had no caller and would have drifted. + +**Backend additions made for these screens** (all amended into docs/30 as built): +- **`TemplateSummaryDto.stageNames`, in flow order.** The overview draws each template as a line left-to-right and needs the names for every row; without the field the client would fetch every template's full graph just to label boxes. Ordering by stage id turned out to be insertion order, which put the *terminal* stage first and drew lines backwards — so the server toposorts (Kahn, tie-broken by id for stability, falling back to id order if the graph is ever cyclic so a listing can't fail on bad data). +- **`TemplateGraphDto.activeRunCount`.** The builder reads its edit-locked state straight off the graph; without it, it would need a second request to the list endpoint purely to know whether to disable itself. +- **`production_templates.Annotations` (jsonb) + `SaveTemplateRequest.annotations`.** The canvas already drew grouping boxes and divider lines and the contract had nowhere to keep them, so every save would have silently discarded the user's layout notes. Round-tripped verbatim, capped at 200 entries, `kind` validated to `box`/`line`, and invisible to the graph validator. Migration `AddTemplateCanvasAnnotations`. Note the flip side, pinned by a smoke assertion: replacement is wholesale, so a client that forgets to echo `annotations` back on a PUT clears them. + +## 13. Validation posture (F6) — DONE +- [x] Domain-code → message map complete (§11), with `detail` preferred where the server is more specific +- [x] `412 CONCURRENCY_CONFLICT` amber conflict banner + Reload on the builder (the `app/dashboard/vendors/[id]/page.tsx` pattern) +- [x] `409 TEMPLATE_IN_USE` edit-lock banner driven by the server. Two distinct messages: locked on load (`activeRunCount > 0`) versus locked *while editing* — the FR-MFG-06 TOCTOU, where a run starts between the GET and the PUT. The second locks the canvas rather than reloading, so nothing the user just drew disappears without an explanation +- [x] `422 GRAPH_*` focuses the offending stage — a best-effort substring match of the server's `detail` against stage names, which is why the validator quotes them. Advisory by design: the full message is always in the banner too, so an ambiguous name costs a highlight, never the explanation +- [x] `Idempotency-Key` per action (`useRef(crypto.randomUUID())`, re-minted after each success and whenever the drawer switches stage) +- [x] Silent refetch on stage-status 409s — `submit()` in the drawer routes every action through `isStaleStageError()` + +**Not done, flagged rather than silently skipped:** +- **No browser verification of any production screen, and no live end-to-end run.** The contract layer is written against a backend whose every endpoint is smoke-verified, the tree type-checks and Turbopack compiles it, but **nothing has been clicked.** Blocked on AuthHex: its configured MySQL host (`187.127.102.190:3306`) is unreachable from this machine, so no token can be issued — which also means the backend smoke suite could not be re-run after this pass's backend additions. +- **`components/Layouts/AppSidebar.tsx:351` still lists `"production"` in `bypassCodes`.** Correct for now — no role is seeded with a `NAV:production` permission, so removing the bypass would hide the section from everyone. Seeding that permission is the real fix (same outstanding item as `procurement`/`hrm`). +- **4 pre-existing `tsc` errors, unrelated to this work — and they block `next build` for the whole app:** `hrm/employees/[id]/page.tsx` (`UpdateEmployeeRequest` missing `hireDate`) and three `hrm/settings/*` pages (an `Api` generic expecting `{value}` where `ApiResult` is returned). None of these files import anything added or changed by this pass, so they were left alone rather than fixed as a side effect of manufacturing work. +- **10 `react-hooks/set-state-in-effect` lint errors across the five production files.** Same rule fires 42 times repo-wide (`app/dashboard/receiving/grn/page.tsx` included); these are the load effects, the hydration-mismatch guards and the builder's stale-upstream repair. No other rule fires in this module. + ## Done +### 2026-07-28 — Dashboard overview (`app/dashboard/page.tsx`) +- **Replaced the component-showcase placeholder with a real stats dashboard.** 7 `StatCard` tiles (Low Stock Alerts, Stock On-Hand, Pending Approval POs, Pending GRNs, Open Requisitions, Open Counts, Active RFQs), all wired to the new `GET /dashboard/stats` (`lib/api/dashboard.ts`, `types/dashboard.ts`) — see `Backend/PROGRESS.md`'s matching 2026-07-28 entry for the endpoint itself. Each tile links to its source list page. +- **Stock Valuation by Warehouse** — `BarChart` over `stats.stockValuationByWarehouse`, warehouse codes resolved via `warehousesApi.list()`. +- **Stock Movement Trend** — `LineChart`, 14-day In/Out totals bucketed client-side from `GET /stock/ledger?from=...&pageSize=200`. **Falls back to a hardcoded sample series (`SAMPLE_TREND_IN`/`OUT`) when the real ledger has no activity in that window**, so the chart isn't a flat zero line on a fresh/demo database — real data always wins when present. (An equivalent fallback was added to the Recent Stock Movements table during this pass and then explicitly removed at the user's request — that table shows only real data + an empty state.) +- **Recent Stock Movements** — table of the latest 5 ledger entries; shows `#itemId` rather than the item SKU, deliberately, to avoid a hard dependency on `GET /items` (see the bug below). +- **`StatCard` (`components/ui/stat-card.tsx`) fixed to use theme tokens** — it previously hardcoded `bg-white`/`text-slate-900`/`text-indigo-600`/`ring-black/5`, which was invisible-on-dark once the Dark/Vibrant themes existed. Now `bg-card`/`text-foreground`/`text-primary`/`ring-foreground/10`. +- **Bug found — `GET /items` 500s on every call** (`column i.SalePrice does not exist`) — this is why the dashboard and the movements table avoid `itemsApi` entirely. Root cause + fix status tracked in `Backend/PROGRESS.md`'s 2026-07-28 entry; **not yet fixed** as of this entry. +- **Chart color gotcha (found and fixed twice this session):** passing a CSS custom property or `color-mix()` string (e.g. `"var(--color-primary)"`) as a Chart.js `borderColor`/`backgroundColor` silently renders **black**, because a `` 2D context cannot resolve CSS variables — it's not a themeable value, it's an invalid string that falls back to the default. Every chart on this page uses real static hex colors instead (`#6366f1`, `#22c55e`, `#ef4444`). +- **Verified:** `tsc --noEmit` clean throughout. Runtime verification blocked for most of this session by the dev backend running under an active Visual Studio debug session — killing the process externally just triggers VS's own auto-relaunch of the **stale** build (observed repeatedly; confirmed via process start-time checks), so `dotnet build`/`dotnet ef` against the live `bin/`/`obj/` failed on file locks. Worked around by building to an isolated `-o` output directory to verify compilation without touching the locked live build; **actually deploying a rebuild still requires stopping debugging inside Visual Studio itself** (not just closing a console window) — this blocked full end-to-end verification of `/dashboard/stats` until the user did that. + +### 2026-07-22 — Item fixed sale price + GRN off-PO items / inline create +- **Item sale-price toggle** (`app/dashboard/products/new/page.tsx`). New "Fixed price / Use stock value" segmented toggle (default **stock**). **Stock** sends `salePrice: null` on every created item. **Fixed** reveals a top "fix value" input that pre-fills a per-variant **Sale price** column (`priceFor(key) = pricesByKey[key] ?? fixValue`, so editing a row overrides only it while the rest follow the shared value); submit is blocked until **every** generated variant has a price `> 0` (`validateVariantPrices` in `lib/validations/master-data.ts`). Each variant's price rides its own `POST /items` in the existing non-transactional create loop. `types/master-data.ts`: `salePrice` added to `CreateItemRequest` (optional) and `Item`/`ItemListItem` (`number|null`). +- **GRN off-PO items + inline create** (`app/dashboard/receiving/grn/new/page.tsx`). "Add line" is now shown in **both** PO and direct mode — an added PO-mode line has `poLineId: null` (editable item/UOM, `unitCost` required) and the server receives it as a direct line. New **"New item"** button opens `/dashboard/products/new` in a new browser tab (`window.open(..., "_blank", "noopener,noreferrer")` — the first new-tab pattern in the app), and a **refresh** icon (`refreshItems`) re-pulls `GET /items?status=Active` so the new item is selectable without reloading the in-progress GRN. Existing `validateLine` covers off-PO lines unchanged. +- **Verified:** `tsc --noEmit` clean. Runtime browser verification (create fixed-priced variants; add an off-PO line + inline item on a PO GRN) is the next step. + ### 2026-07-20 — PO draft lifecycle + GRN discount/VAT/price-override (backend + frontend, same pass) - **PO draft lifecycle.** `lib/api/purchase-orders.ts`: `isPoEditable` narrowed to `status === "Draft"` (was the three-status exclusion); added `submit(poId)` and `remove(poId)`. `types/procurement.ts`: `saveAsDraft?` on `CreatePurchaseOrderRequest`; `UpdatePurchaseOrderRequest` now `Omit`s it. `/new`: the single "Create PO" button split into **Save as draft** / **Create & submit**. `/[id]`: **Draft** shows the editable line grid + **Submit** + **Delete draft**; an issued-but-open PO (`Approved`/`PartiallyReceived`) is read-only with **Cancel PO** (with reason, disabled once receipts exist); closed/cancelled are terminal. The old Cancel button was gated on `isPoEditable` — that would have shown Cancel only for Draft, so the affordances were re-split into `editable` (Draft) vs `cancellable` (Approved/PartiallyReceived). - **GRN discount/VAT/variance.** `types/grn.ts`: `discountPct`/`vatPct` on `CreateGrnLineInput`; `poUnitPrice`/`discountPct`/`netUnitCost`/`vatPct`/`vatAmount`/`lineTotal`/`priceVariance` on `GrnLine`. `/new`: per-line Disc %/VAT % inputs, a client mirror of the server arithmetic (`computeLine`, display-only — server stays authoritative) driving a live Line total + document total, and a PO-price variance hint when the entered unit cost differs from the prefilled PO price. `/[id]`: renders the full cost breakdown (unit cost + variance, disc %, net cost, received value, VAT, line total) and a stock-value/VAT/document-total footer. `lib/validations/grn.ts`: 0–100 range checks on the two percentages. diff --git a/Frontend/erp-system/app/dashboard/hrm/attendance/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/attendance/[id]/page.tsx new file mode 100644 index 0000000..8ff207e --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/attendance/[id]/page.tsx @@ -0,0 +1,194 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" + +import { attendanceApi } from "@/lib/api/attendance" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { AttendanceRecord, AttendanceUploadBatch } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const ROW_STATUS_STYLE: Record = { + Valid: "bg-success/10 text-success", + DuplicateWithinBatch: "bg-warning/10 text-warning", + DuplicateConfirmed: "bg-warning/10 text-warning", + EmployeeNotFound: "bg-destructive/10 text-destructive", + InvalidDateTime: "bg-destructive/10 text-destructive", + Error: "bg-destructive/10 text-destructive", +} + +function minutesLabel(m: number): string { + if (m <= 0) return "0" + const h = Math.floor(m / 60) + const mm = m % 60 + return h > 0 ? `${h}h ${mm}m` : `${mm}m` +} + +export default function AttendanceBatchDetailPage() { + const params = useParams<{ id: string }>() + const batchId = Number(params.id) + + const [batch, setBatch] = useState(null) + const [records, setRecords] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [unlockOpen, setUnlockOpen] = useState(false) + const [unlockReason, setUnlockReason] = useState("") + + function load() { + Promise.all([attendanceApi.get(batchId), attendanceApi.listRecords(batchId)]) + .then(([b, r]) => { setBatch(b); setRecords(r) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [batchId]) + + const locked = batch?.status === "Confirmed" || batch?.status === "UsedInPayroll" + + async function resolve(recordId: number, action: "keep" | "discard" | "supersede") { + try { + await attendanceApi.resolveDuplicate(batchId, recordId, action) + toast.success("Resolved") + load() + } catch (err) { + toast.error("Could not resolve", errorMessage(err)) + } + } + + async function validate() { + setBusy(true) + try { + const b = await attendanceApi.validate(batchId) + setBatch(b) + toast.success("Batch validated") + } catch (err) { + toast.error("Could not validate", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function confirm() { + setBusy(true) + try { + const b = await attendanceApi.confirm(batchId) + setBatch(b) + toast.success("Batch confirmed", "This is now the source of truth for payroll.") + } catch (err) { + toast.error("Could not confirm", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function unlock() { + if (!unlockReason.trim()) { toast.error("A reason is required"); return } + setBusy(true) + try { + const b = await attendanceApi.unlock(batchId, unlockReason.trim()) + setBatch(b) + setUnlockOpen(false) + setUnlockReason("") + toast.success("Batch unlocked") + } catch (err) { + toast.error("Could not unlock", errorMessage(err)) + } finally { + setBusy(false) + } + } + + if (error) return
{error}
+ if (!batch || !records) return
{Array.from({ length: 5 }).map((_, i) => )}
+ + const unresolved = records.filter((r) => r.rowValidationStatus !== "Valid") + + return ( +
+
+
+

{batch.docNo}

+

{new Date(batch.periodStart).toLocaleDateString()} – {new Date(batch.periodEnd).toLocaleDateString()} · {batch.rowCountTotal} rows

+
+
+ {batch.status} + {batch.status === "Draft" && } + {batch.status === "Validated" && } + {batch.status === "Confirmed" && ( + + Unlock} /> + + + Unlock batch + Requires a reason and reverts to Validated for editing. + + + Reason setUnlockReason(e.target.value)} /> + +
+ + +
+
+
+ )} +
+
+ + {unresolved.length > 0 && batch.status === "Draft" && ( +
+ {unresolved.length} record(s) have unresolved errors or duplicates — resolve them before validating. +
+ )} + + + + + Employee + Date + In / Out + Working Hours + Late + OT + Status + Row + {!locked && Resolve} + + + + {records.map((r) => ( + + {r.employeeName ?? r.employeeCode ?? "Unknown"} + {new Date(r.attendanceDate).toLocaleDateString()} + {r.checkIn?.slice(0, 5) ?? "—"} / {r.checkOut?.slice(0, 5) ?? "—"} + {minutesLabel(r.workingMinutes)} + {r.lateMinutes > 0 ? `${r.lateMinutes} min` : "0"} + {r.overtimeMinutes > 0 ? minutesLabel(r.overtimeMinutes) : "0"} + {r.attendanceStatus} + + {r.rowValidationStatus} + + {!locked && ( + + {r.rowValidationStatus !== "Valid" && r.rowValidationStatus !== "EmployeeNotFound" && ( +
+ + +
+ )} +
+ )} +
+ ))} +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx b/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx new file mode 100644 index 0000000..97d18c9 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/attendance/page.tsx @@ -0,0 +1,152 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Download, Eye, Plus, Upload } from "lucide-react" + +import { attendanceApi, attendanceTemplateUrl } from "@/lib/api/attendance" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { AttendanceUploadBatch } from "@/types/hrm" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +const STATUS_STYLE: Record = { + Draft: "bg-muted text-muted-foreground", + Validated: "bg-warning/10 text-warning", + Confirmed: "bg-success/10 text-success", + UsedInPayroll: "bg-primary/10 text-primary", +} + +export default function AttendanceBatchesPage() { + const [batches, setBatches] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [periodStart, setPeriodStart] = useState("") + const [periodEnd, setPeriodEnd] = useState("") + const [file, setFile] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function load() { + attendanceApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setBatches(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleUpload() { + if (!file) { toast.error("Choose an Excel or CSV file first"); return } + if (!periodStart || !periodEnd) { toast.error("Period start/end are required"); return } + setSubmitting(true) + try { + await attendanceApi.upload(file, periodStart, periodEnd) + toast.success("Attendance uploaded", "Review the preview and confirm when ready.") + setOpen(false) + setFile(null) + setPeriodStart("") + setPeriodEnd("") + load() + } catch (err) { + toast.error("Could not upload attendance", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

Attendance

+

Upload → Preview → Confirm. Once Confirmed, a batch becomes payroll's source of truth.

+
+
+ + Download template + + + Upload} /> + + + Upload Attendance + Columns: Employee Code, Date, Check In, Check Out. + + +
+ Period start setPeriodStart(e.target.value)} /> + Period end setPeriodEnd(e.target.value)} /> +
+ + File (.xlsx or .csv) + setFile(e.target.files?.[0] ?? null)} /> + +
+
+ + +
+
+
+
+
+ + {error &&
{error}
} + {!error && batches === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && batches !== null && batches.length === 0 && ( +

No attendance batches yet.

+ )} + {!error && batches !== null && batches.length > 0 && ( + + + + Doc No + Period + Rows + Duplicates/Errors + Status + Actions + + + + {batches.map((b) => ( + + {b.docNo} + {new Date(b.periodStart).toLocaleDateString()} – {new Date(b.periodEnd).toLocaleDateString()} + {b.rowCountTotal} + {b.rowCountDuplicate} / {b.rowCountError} + {b.status} + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx new file mode 100644 index 0000000..d1e79a6 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/employees/[id]/page.tsx @@ -0,0 +1,485 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" +import { Link2, Plus, Upload } from "lucide-react" + +import { CreateEmployeeLoanRequest, CreateSalaryStructureRequest, employeesApi } from "@/lib/api/employees" +import { departmentsApi, designationsApi, employmentTypesApi, hrDocumentTypesApi, salaryComponentsApi, workShiftsApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { + Department, + Designation, + EmployeeBankDetail, + EmployeeDetail, + EmployeeDocument, + EmployeeLoan, + EmployeeSalaryStructure, + EmploymentType, + HrDocumentType, + LoanKind, + SalaryComponent, + WorkShift, +} from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const TABS = ["Overview", "Bank Details", "Documents", "Salary & Loans"] as const +type Tab = (typeof TABS)[number] + +function money(n: number): string { + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +export default function EmployeeDetailPage() { + const params = useParams<{ id: string }>() + const employeeId = Number(params.id) + + const [tab, setTab] = useState("Overview") + const [employee, setEmployee] = useState(null) + const [etag, setEtag] = useState(null) + const [error, setError] = useState(null) + + const [departments, setDepartments] = useState([]) + const [designations, setDesignations] = useState([]) + const [employmentTypes, setEmploymentTypes] = useState([]) + const [workShifts, setWorkShifts] = useState([]) + const [saving, setSaving] = useState(false) + + const [bankDetails, setBankDetails] = useState([]) + const [documents, setDocuments] = useState([]) + const [documentTypes, setDocumentTypes] = useState([]) + const [uploadTypeId, setUploadTypeId] = useState("") + + const [salaryStructures, setSalaryStructures] = useState([]) + const [salaryComponents, setSalaryComponents] = useState([]) + const [loans, setLoans] = useState([]) + + const [structureOpen, setStructureOpen] = useState(false) + const [structureEffectiveFrom, setStructureEffectiveFrom] = useState("") + const [structureBasic, setStructureBasic] = useState(0) + const [structureLines, setStructureLines] = useState<{ salaryComponentId: string; amount: number }[]>([]) + + const [loanOpen, setLoanOpen] = useState(false) + const [loanKind, setLoanKind] = useState("Loan") + const [loanPrincipal, setLoanPrincipal] = useState(0) + const [loanInstallmentAmount, setLoanInstallmentAmount] = useState(0) + const [loanCount, setLoanCount] = useState(1) + const now = new Date() + const [loanStartYear, setLoanStartYear] = useState(now.getFullYear()) + const [loanStartMonth, setLoanStartMonth] = useState(now.getMonth() + 1) + const [busy, setBusy] = useState(false) + + function load() { + employeesApi.get(employeeId) + .then((res) => { setEmployee(res.data); setEtag(res.etag) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [employeeId]) + + useEffect(() => { + departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([])) + designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([])) + employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([])) + workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([])) + hrDocumentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setDocumentTypes(r.items)).catch(() => setDocumentTypes([])) + }, []) + + useEffect(() => { + if (tab === "Bank Details") employeesApi.listBankDetails(employeeId).then(setBankDetails).catch((err) => toast.error("Could not load bank details", errorMessage(err))) + if (tab === "Documents") employeesApi.listDocuments(employeeId).then(setDocuments).catch((err) => toast.error("Could not load documents", errorMessage(err))) + if (tab === "Salary & Loans") { + employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures).catch((err) => toast.error("Could not load salary history", errorMessage(err))) + employeesApi.listLoans(employeeId).then(setLoans).catch((err) => toast.error("Could not load loans", errorMessage(err))) + salaryComponentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setSalaryComponents(r.items)).catch(() => setSalaryComponents([])) + } + }, [tab, employeeId]) + + function addStructureLine() { + setStructureLines((prev) => [...prev, { salaryComponentId: "", amount: 0 }]) + } + + async function createStructure() { + if (!structureEffectiveFrom) { toast.error("Effective date is required"); return } + const lines = structureLines.filter((l) => l.salaryComponentId).map((l) => ({ salaryComponentId: Number(l.salaryComponentId), amount: l.amount })) + const request: CreateSalaryStructureRequest = { effectiveFrom: structureEffectiveFrom, basicSalary: structureBasic, lines } + setBusy(true) + try { + await employeesApi.createSalaryStructure(employeeId, request) + toast.success("Salary structure saved") + setStructureOpen(false) + setStructureEffectiveFrom("") + setStructureBasic(0) + setStructureLines([]) + employeesApi.salaryStructureHistory(employeeId).then(setSalaryStructures) + } catch (err) { + toast.error("Could not save salary structure", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function createLoan() { + if (loanPrincipal <= 0 || loanInstallmentAmount <= 0 || loanCount <= 0) { toast.error("Principal, installment amount, and count must be positive"); return } + const request: CreateEmployeeLoanRequest = { + loanKind, principalAmount: loanPrincipal, interestRate: 0, installmentAmount: loanInstallmentAmount, + numberOfInstallments: loanCount, startYear: loanStartYear, startMonth: loanStartMonth, + } + setBusy(true) + try { + await employeesApi.createLoan(employeeId, request) + toast.success("Loan created") + setLoanOpen(false) + setLoanPrincipal(0) + setLoanInstallmentAmount(0) + setLoanCount(1) + employeesApi.listLoans(employeeId).then(setLoans) + } catch (err) { + toast.error("Could not create loan", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function handleSave() { + if (!employee || !etag) return + setSaving(true) + try { + const res = await employeesApi.update(employeeId, { + fullName: employee.fullName, + nic: employee.nic, + dateOfBirth: employee.dateOfBirth, + gender: employee.gender, + nationality: employee.nationality, + email: employee.email, + personalMobile: employee.personalMobile, + addressLine1: employee.addressLine1, + addressLine2: employee.addressLine2, + city: employee.city, + postalCode: employee.postalCode, + country: employee.country, + emergencyContactName: employee.emergencyContactName, + emergencyContactRelationship: employee.emergencyContactRelationship, + emergencyContactPhone: employee.emergencyContactPhone, + confirmationDate: employee.confirmationDate, + lastWorkingDate: employee.lastWorkingDate, + departmentId: employee.departmentId, + designationId: employee.designationId, + employmentTypeId: employee.employmentTypeId, + branchId: employee.branchId, + workShiftId: employee.workShiftId, + reportingManagerId: employee.reportingManagerId, + epfNumber: employee.epfNumber, + etfNumber: employee.etfNumber, + taxIdentificationNumber: employee.taxIdentificationNumber, + }, etag) + setEmployee(res.data) + setEtag(res.etag) + toast.success("Employee updated") + } catch (err) { + toast.error("Could not save", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleUpload(file: File) { + if (!uploadTypeId) { toast.error("Select a document type first"); return } + try { + const doc = await employeesApi.uploadDocument(employeeId, file, { hrDocumentTypeId: Number(uploadTypeId) }) + setDocuments((prev) => [doc, ...prev]) + toast.success("Document uploaded") + } catch (err) { + toast.error("Could not upload document", errorMessage(err)) + } + } + + if (error) return
{error}
+ if (!employee) return
{Array.from({ length: 5 }).map((_, i) => )}
+ + return ( +
+
+
+

{employee.fullName}

+

+ {employee.employeeCode} + {employee.userId ? ( + Linked to a system user + ) : ( + No system login + )} +

+
+ {employee.status} +
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "Overview" && ( + +
+ Full name setEmployee({ ...employee, fullName: e.target.value })} /> + Email setEmployee({ ...employee, email: e.target.value })} /> + NIC setEmployee({ ...employee, nic: e.target.value })} /> + Personal mobile setEmployee({ ...employee, personalMobile: e.target.value })} /> + + Department + + + + Designation + + + + Employment type + + + + Work shift + + + EPF number setEmployee({ ...employee, epfNumber: e.target.value })} /> + ETF number setEmployee({ ...employee, etfNumber: e.target.value })} /> +
+
+ +
+
+ )} + + {tab === "Bank Details" && ( +
+ + + + Bank + Branch + Account + Primary + + + + {bankDetails.map((b, i) => ( + + {b.bankName} + {b.branchName} + {b.accountNumber} + {b.isPrimary ? "Yes" : "—"} + + ))} + {bankDetails.length === 0 && ( + No bank details on file. + )} + +
+
+ )} + + {tab === "Documents" && ( +
+
+ + +
+ + + + File + Type + Uploaded + Status + Download + + + + {documents.map((d) => ( + + {d.originalFileName} + {d.hrDocumentTypeName ?? "—"} + {new Date(d.uploadedAt).toLocaleDateString()} + {d.status} + + Download + + + ))} + {documents.length === 0 && ( + No documents uploaded yet. + )} + +
+
+ )} + + {tab === "Salary & Loans" && ( +
+
+
+

Salary Structure

+ { setStructureOpen(v); if (v && structureLines.length === 0) addStructureLine() }}> + New Structure} /> + + + New Salary Structure + Supersedes the current open-ended structure from this date. + + +
+ Effective from setStructureEffectiveFrom(e.target.value)} /> + Basic salary setStructureBasic(Number(e.target.value))} /> +
+ + Allowances / other deductions +
+ {structureLines.map((line, i) => ( +
+ + setStructureLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, amount: Number(e.target.value) } : l)))} /> +
+ ))} + +
+
+
+
+ + +
+
+
+
+ + + + Effective from + Effective to + Basic + Status + + + + {salaryStructures.map((s) => ( + + {new Date(s.effectiveFrom).toLocaleDateString()} + {s.effectiveTo ? new Date(s.effectiveTo).toLocaleDateString() : "Current"} + {money(s.basicSalary)} + {s.status} + + ))} + {salaryStructures.length === 0 && ( + No salary structure set yet. + )} + +
+
+ +
+
+

Loans & Advances

+ + New Loan} /> + + + New Loan / Advance + Generates the full installment schedule up front. + + + + Type + + +
+ Principal setLoanPrincipal(Number(e.target.value))} /> + Installment amount setLoanInstallmentAmount(Number(e.target.value))} /> + # installments setLoanCount(Number(e.target.value))} /> + Start year/month +
+ setLoanStartYear(Number(e.target.value))} /> + setLoanStartMonth(Number(e.target.value))} /> +
+
+
+
+
+ + +
+
+
+
+ + + + Doc No + Type + Principal + Outstanding + Status + + + + {loans.map((l) => ( + + {l.docNo} + {l.loanKind} + {money(l.principalAmount)} + {money(l.outstandingBalance)} + {l.status} + + ))} + {loans.length === 0 && ( + No loans or advances on file. + )} + +
+
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx b/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx new file mode 100644 index 0000000..f38ce2e --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/employees/page.tsx @@ -0,0 +1,284 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react" + +import { employeesApi } from "@/lib/api/employees" +import { departmentsApi, designationsApi, employmentTypesApi, workShiftsApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { CreateEmployeeRequest, Department, Designation, EmployeeListItem, EmploymentType, UserMatch, WorkShift } from "@/types/hrm" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +const emptyForm: CreateEmployeeRequest = { + employeeCode: "", + fullName: "", + email: "", + hireDate: new Date().toISOString().slice(0, 10), + departmentId: 0, + designationId: 0, + employmentTypeId: 0, + workShiftId: 0, +} + +export default function EmployeesPage() { + const [employees, setEmployees] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + const [q, setQ] = useState("") + + const [departments, setDepartments] = useState([]) + const [designations, setDesignations] = useState([]) + const [employmentTypes, setEmploymentTypes] = useState([]) + const [workShifts, setWorkShifts] = useState([]) + + const [open, setOpen] = useState(false) + const [form, setForm] = useState(emptyForm) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + // Advisory cross-link suggestion: does a System User already exist with this email? + const [userMatch, setUserMatch] = useState(null) + const [checkingEmail, setCheckingEmail] = useState(false) + + function load() { + employeesApi + .list({ page, pageSize: PAGE_SIZE, q: q || undefined }) + .then((res) => { setEmployees(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page, q]) + + useEffect(() => { + departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([])) + designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([])) + employmentTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmploymentTypes(r.items)).catch(() => setEmploymentTypes([])) + workShiftsApi.list({ pageSize: 200, status: "Active" }).then((r) => setWorkShifts(r.items)).catch(() => setWorkShifts([])) + }, []) + + async function checkEmail(email: string) { + if (!email.trim()) { setUserMatch(null); return } + setCheckingEmail(true) + try { + const { match } = await employeesApi.emailLookup(email.trim()) + setUserMatch(match) + } catch { + setUserMatch(null) + } finally { + setCheckingEmail(false) + } + } + + function resetForm() { + setForm(emptyForm) + setUserMatch(null) + setErrors({}) + } + + async function handleCreate() { + const nextErrors: Record = {} + if (!form.employeeCode.trim()) nextErrors.employeeCode = "Employee code is required" + if (!form.fullName.trim()) nextErrors.fullName = "Full name is required" + if (!form.hireDate) nextErrors.hireDate = "Hire date is required" + if (!form.departmentId) nextErrors.departmentId = "Department is required" + if (!form.designationId) nextErrors.designationId = "Designation is required" + if (!form.employmentTypeId) nextErrors.employmentTypeId = "Employment type is required" + if (!form.workShiftId) nextErrors.workShiftId = "Work shift is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await employeesApi.create({ + ...form, + email: form.email || null, + linkUserId: userMatch ? userMatch.userId : null, + }) + toast.success("Employee created") + setOpen(false) + resetForm() + load() + } catch (err) { + toast.error("Could not create employee", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

Employees

+

Staff records — separate from system login accounts (see the link chip below).

+
+ { setOpen(v); if (!v) resetForm() }}> + New Employee} /> + + + New Employee + Not every employee needs a login — a system user account is optional and separate. + + +
+ + Employee code + setForm((f) => ({ ...f, employeeCode: e.target.value }))} aria-invalid={!!errors.employeeCode} /> + + + + Hire date + setForm((f) => ({ ...f, hireDate: e.target.value }))} aria-invalid={!!errors.hireDate} /> + + +
+ + Full name + setForm((f) => ({ ...f, fullName: e.target.value }))} aria-invalid={!!errors.fullName} /> + + + + Email (optional) + setForm((f) => ({ ...f, email: e.target.value }))} + onBlur={(e) => checkEmail(e.target.value)} + /> + {checkingEmail &&

Checking for an existing system user…

} + {userMatch && ( +
+ + + System user {userMatch.username} ({userMatch.displayName}) matches this email — it will be linked to this employee. + +
+ )} +
+
+ + Department + + + + + Designation + + + + + Employment type + + + + + Work shift + + + +
+
+
+ + +
+
+
+
+ + { setPage(1); setQ(e.target.value) }} className="max-w-sm" /> + + {error &&
{error}
} + + {!error && employees === null && ( +
{Array.from({ length: 4 }).map((_, i) => )}
+ )} + + {!error && employees !== null && employees.length === 0 && ( +
+ +

No employees yet.

+
+ )} + + {!error && employees !== null && employees.length > 0 && ( + + + + Code + Name + Department + Designation + Login + Status + Actions + + + + {employees.map((e) => ( + + {e.employeeCode} + {e.fullName} + {e.departmentName ?? "—"} + {e.designationName ?? "—"} + + {e.hasUserLink ? ( + Linked + ) : ( + No login + )} + + + {e.status} + + + + + + + + ))} + +
+ )} + + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx b/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx new file mode 100644 index 0000000..e2d9a05 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/leave/page.tsx @@ -0,0 +1,198 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { employeesApi } from "@/lib/api/employees" +import { leaveTypesApi } from "@/lib/api/hrm-masters" +import { leaveRequestsApi } from "@/lib/api/leave" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +const STATUS_STYLE: Record = { + Draft: "bg-muted text-muted-foreground", + Submitted: "bg-warning/10 text-warning", + Approved: "bg-success/10 text-success", + Rejected: "bg-destructive/10 text-destructive", + Cancelled: "bg-muted text-muted-foreground", +} + +export default function LeaveRequestsPage() { + const [requests, setRequests] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [employees, setEmployees] = useState([]) + const [leaveTypes, setLeaveTypes] = useState([]) + + const [open, setOpen] = useState(false) + const [employeeId, setEmployeeId] = useState("") + const [leaveTypeId, setLeaveTypeId] = useState("") + const [startDate, setStartDate] = useState("") + const [endDate, setEndDate] = useState("") + const [reason, setReason] = useState("") + const [submitting, setSubmitting] = useState(false) + + function load() { + leaveRequestsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setRequests(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + useEffect(() => { + employeesApi.list({ pageSize: 200, status: "Active" }).then((r) => setEmployees(r.items)).catch(() => setEmployees([])) + leaveTypesApi.list({ pageSize: 200, status: "Active" }).then((r) => setLeaveTypes(r.items)).catch(() => setLeaveTypes([])) + }, []) + + async function handleCreate() { + if (!employeeId || !leaveTypeId || !startDate || !endDate) { toast.error("All fields except reason are required"); return } + setSubmitting(true) + try { + const created = await leaveRequestsApi.create({ employeeId: Number(employeeId), leaveTypeId: Number(leaveTypeId), startDate, endDate, reason: reason || null }) + await leaveRequestsApi.submit(created.leaveRequestId) + toast.success("Leave request submitted") + setOpen(false) + setEmployeeId(""); setLeaveTypeId(""); setStartDate(""); setEndDate(""); setReason("") + load() + } catch (err) { + toast.error("Could not submit leave request", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function approve(id: number) { + try { + await leaveRequestsApi.approve(id) + toast.success("Leave approved") + load() + } catch (err) { + toast.error("Could not approve", errorMessage(err)) + } + } + + async function reject(id: number) { + const reasonText = window.prompt("Reason for rejection:") + if (!reasonText) return + try { + await leaveRequestsApi.reject(id, reasonText) + toast.success("Leave rejected") + load() + } catch (err) { + toast.error("Could not reject", errorMessage(err)) + } + } + + return ( +
+
+
+

Leave

+

Approved leave feeds Attendance's OnLeave status and Payroll's No-Pay calculation.

+
+ + New Request} /> + + + New Leave Request + Submitted immediately for approval. + + + + Employee + + + + Leave type + + +
+ Start date setStartDate(e.target.value)} /> + End date setEndDate(e.target.value)} /> +
+ Reason (optional) setReason(e.target.value)} /> +
+
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && requests === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && requests !== null && requests.length === 0 && ( +

No leave requests yet.

+ )} + {!error && requests !== null && requests.length > 0 && ( + + + + Doc No + Employee + Type + Dates + Days + Status + Actions + + + + {requests.map((r) => ( + + {r.docNo} + {r.employeeName ?? "—"} + {r.leaveTypeName ?? "—"} + {new Date(r.startDate).toLocaleDateString()} – {new Date(r.endDate).toLocaleDateString()} + {r.daysCount} + {r.status} + + {r.status === "Submitted" && ( +
+ + +
+ )} +
+
+ ))} +
+
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/lines/[lineId]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/lines/[lineId]/page.tsx new file mode 100644 index 0000000..0f3c797 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/lines/[lineId]/page.tsx @@ -0,0 +1,76 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams } from "next/navigation" + +import { payrollRunsApi } from "@/lib/api/payroll" +import { errorMessage } from "@/lib/error-map" +import { PayrollLineDetail } from "@/types/hrm" + +import { Skeleton } from "@/components/ui/skeleton" + +function money(n: number): string { + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +export default function PayrollLineDetailPage() { + const params = useParams<{ id: string; lineId: string }>() + const runId = Number(params.id) + const lineId = Number(params.lineId) + + const [detail, setDetail] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + payrollRunsApi.getLine(runId, lineId).then(setDetail).catch((err) => setError(errorMessage(err))) + }, [runId, lineId]) + + if (error) return
{error}
+ if (!detail) return
{Array.from({ length: 6 }).map((_, i) => )}
+ + const { line, components } = detail + const earnings = components.filter((c) => c.componentCategory === "Earning") + const deductions = components.filter((c) => c.componentCategory === "Deduction") + const employerContributions = components.filter((c) => c.componentCategory === "EmployerContribution") + + return ( +
+
+

Salary Breakdown

+

{line.employeeName} ({line.employeeCode})

+
+ +
+ + + {earnings.map((c, i) => ( + + ))} + + + {deductions.map((c, i) => ( + + ))} + + {employerContributions.length > 0 && ( + <> + + {employerContributions.map((c, i) => ( + + ))} + + )} + +
{c.label}{money(c.amount)}
Gross Salary{money(line.grossSalary)}
Deductions
{c.label}{money(c.amount)}
Net Salary{money(line.netSalary)}
Employer Contributions (informational — not deducted)
{c.label}{money(c.amount)}
+
+ +
+
Present days: {line.presentDays}
+
Absent days: {line.absentDays}
+
Leave days: {line.leaveDays}
+
OT minutes: {line.otMinutesTotal}
+
Late minutes: {line.lateMinutesTotal}
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx new file mode 100644 index 0000000..c7d3005 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/payroll/[id]/page.tsx @@ -0,0 +1,170 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useParams } from "next/navigation" + +import { payrollRunsApi, payslipViewUrl } from "@/lib/api/payroll" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PayrollLine, PayrollRun, Payslip } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +function money(n: number): string { + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +export default function PayrollRunDetailPage() { + const params = useParams<{ id: string }>() + const runId = Number(params.id) + + const [run, setRun] = useState(null) + const [lines, setLines] = useState(null) + const [payslips, setPayslips] = useState([]) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const [unlockOpen, setUnlockOpen] = useState(false) + const [unlockReason, setUnlockReason] = useState("") + + function load() { + Promise.all([payrollRunsApi.get(runId), payrollRunsApi.listLines(runId)]) + .then(([r, l]) => { setRun(r); setLines(l) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [runId]) + + async function approve() { + setBusy(true) + try { setRun(await payrollRunsApi.approve(runId)); toast.success("Payroll approved") } + catch (err) { toast.error("Could not approve", errorMessage(err)) } + finally { setBusy(false) } + } + + async function lock() { + setBusy(true) + try { setRun(await payrollRunsApi.lock(runId)); toast.success("Payroll locked") } + catch (err) { toast.error("Could not lock", errorMessage(err)) } + finally { setBusy(false) } + } + + async function unlock() { + if (!unlockReason.trim()) { toast.error("A reason is required"); return } + setBusy(true) + try { + setRun(await payrollRunsApi.unlock(runId, unlockReason.trim())) + setUnlockOpen(false) + setUnlockReason("") + toast.success("Payroll unlocked") + } catch (err) { + toast.error("Could not unlock", errorMessage(err)) + } finally { + setBusy(false) + } + } + + async function generatePayslips() { + setBusy(true) + try { + const result = await payrollRunsApi.generatePayslips(runId) + setPayslips(result) + toast.success("Payslips generated") + } catch (err) { + toast.error("Could not generate payslips", errorMessage(err)) + } finally { + setBusy(false) + } + } + + if (error) return
{error}
+ if (!run || !lines) return
{Array.from({ length: 5 }).map((_, i) => )}
+ + return ( +
+
+
+

{run.docNo}

+

{run.periodMonth.toString().padStart(2, "0")}/{run.periodYear} · {run.employeeCount} employees

+
+
+ {run.status} + {run.status === "Draft" && } + {run.status === "Approved" && } + {run.status === "Locked" && ( + <> + + + Unlock} /> + + + Unlock payroll + The highest-risk action in this module — requires a reason and is fully audited. + + + Reason setUnlockReason(e.target.value)} /> + +
+ + +
+
+
+ + )} +
+
+ +
+

Gross

{money(run.totalGross)}

+

Net

{money(run.totalNet)}

+

Employees

{run.employeeCount}

+

Deductions

{money(run.totalGross - run.totalNet)}

+
+ + + + + Employee + Basic + OT + Allowances + Deductions + Net Salary + Details + + + + {lines.map((l) => { + const totalDeductions = l.grossSalary - l.netSalary + const payslip = payslips.find((p) => p.payrollLineId === l.payrollLineId) + return ( + + {l.employeeName} ({l.employeeCode}) + {money(l.basicSalary)} + {money(l.overtimeAmount)} + {money(l.totalAllowances)} + {money(totalDeductions)} + {money(l.netSalary)} + +
+ Breakdown + {payslip && ( + Payslip + )} +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx b/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx new file mode 100644 index 0000000..f4d7f20 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/payroll/page.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Eye, Plus } from "lucide-react" + +import { payrollRunsApi } from "@/lib/api/payroll" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { PayrollRun, PayrollRunStatus } from "@/types/hrm" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +const STATUS_STYLE: Record = { + Draft: "bg-muted text-muted-foreground", + Approved: "bg-warning/10 text-warning", + Locked: "bg-success/10 text-success", +} + +export default function PayrollRunsPage() { + const [runs, setRuns] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const now = new Date() + const [periodYear, setPeriodYear] = useState(now.getFullYear()) + const [periodMonth, setPeriodMonth] = useState(now.getMonth() + 1) + const [submitting, setSubmitting] = useState(false) + + function load() { + payrollRunsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setRuns(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleGenerate() { + setSubmitting(true) + try { + await payrollRunsApi.generate({ periodYear, periodMonth }) + toast.success("Payroll run generated") + setOpen(false) + load() + } catch (err) { + toast.error("Could not generate payroll", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

Payroll

+

Generate → Review → Approve → Lock → Payslips.

+
+ + Generate Payroll} /> + + + Generate Payroll Run + Blocked if attendance for this period isn't fully Confirmed yet. + + +
+ Year setPeriodYear(Number(e.target.value))} /> + Month setPeriodMonth(Number(e.target.value))} /> +
+
+
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && runs === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && runs !== null && runs.length === 0 && ( +

No payroll runs yet.

+ )} + {!error && runs !== null && runs.length > 0 && ( + + + + Doc No + Period + Employees + Gross + Net + Status + Actions + + + + {runs.map((r) => ( + + {r.docNo} + {r.periodMonth.toString().padStart(2, "0")}/{r.periodYear} + {r.employeeCount} + {r.totalGross.toLocaleString(undefined, { minimumFractionDigits: 2 })} + {r.totalNet.toLocaleString(undefined, { minimumFractionDigits: 2 })} + {r.status} + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/reports/page.tsx b/Frontend/erp-system/app/dashboard/hrm/reports/page.tsx new file mode 100644 index 0000000..b97e586 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/reports/page.tsx @@ -0,0 +1,175 @@ +"use client" + +import { useState } from "react" + +import { hrReportsApi } from "@/lib/api/hr-reports" +import { errorMessage } from "@/lib/error-map" +import { + AttendanceSummaryRow, + DocumentExpiryReportRow, + LateArrivalReportRow, + LeaveBalanceReportRow, + OvertimeReportRow, + PayrollRegisterRow, + SalaryHistoryRow, +} from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const REPORTS = [ + "Attendance Summary", + "Overtime", + "Late Arrivals", + "Payroll Register", + "Salary History", + "Leave Balances", + "Document Expiry", +] as const +type ReportName = (typeof REPORTS)[number] + +const now = new Date() + +export default function HrReportsPage() { + const [report, setReport] = useState("Attendance Summary") + const [periodYear, setPeriodYear] = useState(now.getFullYear()) + const [periodMonth, setPeriodMonth] = useState(now.getMonth() + 1) + const [payrollRunId, setPayrollRunId] = useState("") + const [employeeId, setEmployeeId] = useState("") + const [year, setYear] = useState(now.getFullYear()) + const [withinDays, setWithinDays] = useState(30) + const [loading, setLoading] = useState(false) + + const [attendanceRows, setAttendanceRows] = useState([]) + const [otRows, setOtRows] = useState([]) + const [lateRows, setLateRows] = useState([]) + const [payrollRows, setPayrollRows] = useState([]) + const [salaryRows, setSalaryRows] = useState([]) + const [leaveRows, setLeaveRows] = useState([]) + const [expiryRows, setExpiryRows] = useState([]) + + async function run() { + setLoading(true) + try { + switch (report) { + case "Attendance Summary": setAttendanceRows(await hrReportsApi.attendanceSummary(periodYear, periodMonth)); break + case "Overtime": setOtRows(await hrReportsApi.overtime(periodYear, periodMonth)); break + case "Late Arrivals": setLateRows(await hrReportsApi.lateArrivals(periodYear, periodMonth)); break + case "Payroll Register": setPayrollRows(await hrReportsApi.payrollRegister(Number(payrollRunId))); break + case "Salary History": setSalaryRows(await hrReportsApi.salaryHistory(Number(employeeId))); break + case "Leave Balances": setLeaveRows(await hrReportsApi.leaveBalances(year)); break + case "Document Expiry": setExpiryRows(await hrReportsApi.documentExpiry(withinDays)); break + } + } catch (err) { + toast.error("Could not load report", errorMessage(err)) + } finally { + setLoading(false) + } + } + + return ( +
+
+

Reports

+

Read-only views over Attendance, Payroll, Leave, and Documents.

+
+ +
+
+ + +
+ + {(report === "Attendance Summary" || report === "Overtime" || report === "Late Arrivals") && ( + <> +
setPeriodYear(Number(e.target.value))} />
+
setPeriodMonth(Number(e.target.value))} />
+ + )} + {report === "Payroll Register" && ( +
setPayrollRunId(e.target.value)} />
+ )} + {report === "Salary History" && ( +
setEmployeeId(e.target.value)} />
+ )} + {report === "Leave Balances" && ( +
setYear(Number(e.target.value))} />
+ )} + {report === "Document Expiry" && ( +
setWithinDays(Number(e.target.value))} />
+ )} + + +
+ + {report === "Attendance Summary" && ( + + EmployeeDeptPresentAbsentLeaveOT (min)Late (min) + {attendanceRows.map((r) => ( + {r.employeeName} ({r.employeeCode}){r.departmentName ?? "—"}{r.presentDays}{r.absentDays}{r.leaveDays}{r.otMinutesTotal}{r.lateMinutesTotal} + ))} +
+ )} + + {report === "Overtime" && ( + + EmployeeDateOT (min) + {otRows.map((r, i) => ( + {r.employeeName} ({r.employeeCode}){new Date(r.attendanceDate).toLocaleDateString()}{r.overtimeMinutes} + ))} +
+ )} + + {report === "Late Arrivals" && ( + + EmployeeDateLate (min) + {lateRows.map((r, i) => ( + {r.employeeName} ({r.employeeCode}){new Date(r.attendanceDate).toLocaleDateString()}{r.lateMinutes} + ))} +
+ )} + + {report === "Payroll Register" && ( + + EmployeeGrossDeductionsNet + {payrollRows.map((r) => ( + {r.employeeName} ({r.employeeCode}){r.grossSalary.toFixed(2)}{r.totalDeductions.toFixed(2)}{r.netSalary.toFixed(2)} + ))} +
+ )} + + {report === "Salary History" && ( + + Effective fromEffective toBasicStatus + {salaryRows.map((r) => ( + {new Date(r.effectiveFrom).toLocaleDateString()}{r.effectiveTo ? new Date(r.effectiveTo).toLocaleDateString() : "Current"}{r.basicSalary.toFixed(2)}{r.status} + ))} +
+ )} + + {report === "Leave Balances" && ( + + EmployeeLeave typeEntitledTakenRemaining + {leaveRows.map((r, i) => ( + {r.employeeName} ({r.employeeCode}){r.leaveTypeName}{r.entitledDays}{r.takenDays}{r.remainingDays} + ))} +
+ )} + + {report === "Document Expiry" && ( + + EmployeeDocument typeExpiry dateDays left + {expiryRows.map((r) => ( + {r.employeeName} ({r.employeeCode}){r.documentTypeName}{new Date(r.expiryDate).toLocaleDateString()}{r.daysUntilExpiry} + ))} +
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/branches/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/branches/page.tsx new file mode 100644 index 0000000..01ee9a9 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/branches/page.tsx @@ -0,0 +1,16 @@ +"use client" + +import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage" +import { branchesHrmApi } from "@/lib/api/hrm-masters" +import { Branch } from "@/types/hrm" + +export default function BranchesPage() { + return ( + + title="Branches" + description="Company locations/branches — used for multi-branch employee and payroll scoping." + idOf={(b) => b.branchId} + api={branchesHrmApi} + /> + ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx new file mode 100644 index 0000000..6e7cea4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/departments/page.tsx @@ -0,0 +1,198 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { departmentsApi, branchesHrmApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { Branch, Department } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 +const NONE = "__none__" + +export default function DepartmentsPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [branches, setBranches] = useState([]) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [parentDepartmentId, setParentDepartmentId] = useState(NONE) + const [branchId, setBranchId] = useState(NONE) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + departmentsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { + setItems(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page]) + useEffect(() => { + branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((res) => setBranches(res.items)).catch(() => setBranches([])) + }, []) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await departmentsApi.create({ + code: code.trim(), + name: name.trim(), + parentDepartmentId: parentDepartmentId === NONE ? null : Number(parentDepartmentId), + branchId: branchId === NONE ? null : Number(branchId), + }) + toast.success("Department created") + setOpen(false) + setCode("") + setName("") + setParentDepartmentId(NONE) + setBranchId(NONE) + load() + } catch (err) { + toast.error("Could not create department", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: Department) { + try { + await departmentsApi.updateStatus(item.departmentId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Departments

+

Org structure — unlimited nesting, optionally scoped to a branch.

+
+ + New Department} /> + + + New Department + Set a parent department for a sub-department, or leave it top-level. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + + Parent department (optional) + + + + Branch (optional) + + + +
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +
+

No departments yet.

+
+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Parent + Status + Actions + + + + {items.map((d) => ( + + {d.code} + {d.name} + {items.find((p) => p.departmentId === d.parentDepartmentId)?.name ?? "—"} + + {d.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/designations/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/designations/page.tsx new file mode 100644 index 0000000..b51b551 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/designations/page.tsx @@ -0,0 +1,16 @@ +"use client" + +import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage" +import { designationsApi } from "@/lib/api/hrm-masters" +import { Designation } from "@/types/hrm" + +export default function DesignationsPage() { + return ( + + title="Designations" + description="Job titles — standalone, reusable across departments." + idOf={(d) => d.designationId} + api={designationsApi} + /> + ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx new file mode 100644 index 0000000..63d0c9c --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/document-types/page.tsx @@ -0,0 +1,176 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { hrDocumentTypesApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { HrDocumentCategory, HrDocumentType } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 +const CATEGORIES: HrDocumentCategory[] = ["Identity", "Educational", "Contract", "Certification", "Statutory", "Other"] + +export default function DocumentTypesPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [category, setCategory] = useState("Identity") + const [requiredAtOnboarding, setRequiredAtOnboarding] = useState(false) + const [expiryTracked, setExpiryTracked] = useState(false) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + hrDocumentTypesApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setItems(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await hrDocumentTypesApi.create({ code: code.trim(), name: name.trim(), category, requiredAtOnboarding, expiryTracked }) + toast.success("Document type created") + setOpen(false) + setCode("") + setName("") + load() + } catch (err) { + toast.error("Could not create document type", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: HrDocumentType) { + try { + await hrDocumentTypesApi.updateStatus(item.hrDocumentTypeId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Document Types

+

The staff document catalog — NIC, contracts, certificates, etc.

+
+ + New Type} /> + + + New Document Type + Categorize how this document is used, not the file itself. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + + Category + + + + setRequiredAtOnboarding(v === true)} /> + Required at onboarding + + + setExpiryTracked(v === true)} /> + Track expiry date (e.g. passport, visa) + + +
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +

No document types yet.

+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Category + Status + Actions + + + + {items.map((t) => ( + + {t.code} + {t.name} + {t.category} + + {t.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/employment-types/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/employment-types/page.tsx new file mode 100644 index 0000000..269d789 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/employment-types/page.tsx @@ -0,0 +1,16 @@ +"use client" + +import { CodeNameMasterPage } from "@/components/hrm/CodeNameMasterPage" +import { employmentTypesApi } from "@/lib/api/hrm-masters" +import { EmploymentType } from "@/types/hrm" + +export default function EmploymentTypesPage() { + return ( + + title="Employment Types" + description="Labor categories — Permanent, Probation, Contract, etc." + idOf={(e) => e.employmentTypeId} + api={employmentTypesApi} + /> + ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx new file mode 100644 index 0000000..7b81dd0 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/leave-types/page.tsx @@ -0,0 +1,179 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { leaveTypesApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { LeaveType } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +export default function LeaveTypesPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [isPaid, setIsPaid] = useState(true) + const [countsAsNoPay, setCountsAsNoPay] = useState(false) + const [accrualPerYear, setAccrualPerYear] = useState(14) + const [carryForwardAllowed, setCarryForwardAllowed] = useState(false) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + leaveTypesApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setItems(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await leaveTypesApi.create({ + code: code.trim(), name: name.trim(), isPaid, countsAsNoPay, accrualPerYear, + carryForwardAllowed, requiresApproval: true, + }) + toast.success("Leave type created") + setOpen(false) + setCode("") + setName("") + load() + } catch (err) { + toast.error("Could not create leave type", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: LeaveType) { + try { + await leaveTypesApi.updateStatus(item.leaveTypeId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Leave Types

+

Annual, Casual, Medical, Unpaid, etc. — drives Attendance's OnLeave classification and Payroll's No-Pay calc.

+
+ + New Leave Type} /> + + + New Leave Type + Whether it's paid affects payroll's No-Pay deduction. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + + Days per year + setAccrualPerYear(Number(e.target.value))} /> + + + setIsPaid(v === true)} /> + Paid leave + + + setCountsAsNoPay(v === true)} /> + Counts as No-Pay in payroll + + + setCarryForwardAllowed(v === true)} /> + Carry-forward allowed + + +
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +

No leave types yet.

+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Days/yr + Paid + Status + Actions + + + + {items.map((t) => ( + + {t.code} + {t.name} + {t.accrualPerYear} + {t.isPaid ? "Yes" : "No"} + + {t.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/page.tsx new file mode 100644 index 0000000..7e6c5b7 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/page.tsx @@ -0,0 +1,44 @@ +"use client" + +import Link from "next/link" +import { Building2, CalendarClock, FileText, ListTree, Percent, Sigma, Users } from "lucide-react" + +const cards = [ + { title: "Branches", href: "/dashboard/hrm/settings/branches", icon: Building2, desc: "Company locations" }, + { title: "Departments", href: "/dashboard/hrm/settings/departments", icon: ListTree, desc: "Org structure" }, + { title: "Designations", href: "/dashboard/hrm/settings/designations", icon: Users, desc: "Job titles" }, + { title: "Employment Types", href: "/dashboard/hrm/settings/employment-types", icon: Users, desc: "Permanent, Contract, etc." }, + { title: "Work Shifts", href: "/dashboard/hrm/settings/work-shifts", icon: CalendarClock, desc: "Attendance baseline" }, + { title: "Document Types", href: "/dashboard/hrm/settings/document-types", icon: FileText, desc: "Staff document catalog" }, + { title: "Leave Types", href: "/dashboard/hrm/settings/leave-types", icon: CalendarClock, desc: "Annual, Casual, Medical…" }, + { title: "Salary Components", href: "/dashboard/hrm/settings/salary-components", icon: Sigma, desc: "Allowances & deductions" }, + { title: "Statutory Settings", href: "/dashboard/hrm/settings/statutory", icon: Percent, desc: "EPF/ETF rates & tax slabs" }, +] + +export default function HrmSettingsPage() { + return ( +
+
+

HRM Settings

+

Masters and configuration used across Employees, Attendance, Leave, and Payroll.

+
+
+ {cards.map((c) => ( + +
+ +
+
+

{c.title}

+

{c.desc}

+
+ + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx new file mode 100644 index 0000000..df555ad --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/salary-components/page.tsx @@ -0,0 +1,176 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { salaryComponentsApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { SalaryComponent, SalaryComponentType } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +export default function SalaryComponentsPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [componentType, setComponentType] = useState("Earning") + const [isTaxable, setIsTaxable] = useState(true) + const [isEpfEtfApplicable, setIsEpfEtfApplicable] = useState(true) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + salaryComponentsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { setItems(res.items); setPagination(res.pagination) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, [page]) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + await salaryComponentsApi.create({ code: code.trim(), name: name.trim(), componentType, isTaxable, isEpfEtfApplicable }) + toast.success("Salary component created") + setOpen(false) + setCode("") + setName("") + load() + } catch (err) { + toast.error("Could not create salary component", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: SalaryComponent) { + try { + await salaryComponentsApi.updateStatus(item.salaryComponentId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Salary Components

+

Allowances and ad hoc other deductions — OT/Late/No-Pay/Loan/EPF/ETF/Tax are computed automatically, not components.

+
+ + New Component} /> + + + New Salary Component + e.g. Transport Allowance, Meal Allowance, or an ad hoc deduction. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + + + Type + + + + setIsTaxable(v === true)} /> + Taxable + + + setIsEpfEtfApplicable(v === true)} /> + EPF/ETF applicable + + +
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +

No salary components yet.

+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Type + Status + Actions + + + + {items.map((c) => ( + + {c.code} + {c.name} + {c.componentType} + + {c.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx new file mode 100644 index 0000000..5cda7d8 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/statutory/page.tsx @@ -0,0 +1,179 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { payrollStatutorySettingsApi, taxSlabsApi } from "@/lib/api/payroll" +import { errorMessage } from "@/lib/error-map" +import { PayrollStatutorySetting, TaxSlab } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +export default function StatutorySettingsPage() { + const [settings, setSettings] = useState([]) + const [slabs, setSlabs] = useState([]) + const [error, setError] = useState(null) + + const [settingOpen, setSettingOpen] = useState(false) + const [epfEmployeeRate, setEpfEmployeeRate] = useState(0.08) + const [epfEmployerRate, setEpfEmployerRate] = useState(0.12) + const [etfEmployerRate, setEtfEmployerRate] = useState(0.03) + const [otMultiplierDefault, setOtMultiplierDefault] = useState(1.5) + const [effectiveFrom, setEffectiveFrom] = useState("") + + const [slabOpen, setSlabOpen] = useState(false) + const [slabEffectiveFrom, setSlabEffectiveFrom] = useState("") + const [lowerBound, setLowerBound] = useState(0) + const [upperBound, setUpperBound] = useState("") + const [rate, setRate] = useState(0.06) + const [submitting, setSubmitting] = useState(false) + + function load() { + Promise.all([payrollStatutorySettingsApi.list(), taxSlabsApi.list()]) + .then(([s, t]) => { setSettings(s); setSlabs(t) }) + .catch((err) => setError(errorMessage(err))) + } + useEffect(load, []) + + async function createSetting() { + if (!effectiveFrom) { toast.error("Effective date is required"); return } + setSubmitting(true) + try { + await payrollStatutorySettingsApi.create({ epfEmployeeRate, epfEmployerRate, etfEmployerRate, otMultiplierDefault, effectiveFrom }) + toast.success("Statutory setting saved") + setSettingOpen(false) + load() + } catch (err) { + toast.error("Could not save", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function createSlab() { + if (!slabEffectiveFrom) { toast.error("Effective date is required"); return } + setSubmitting(true) + try { + await taxSlabsApi.create({ + effectiveFrom: slabEffectiveFrom, + lowerBound, + upperBound: upperBound.trim() === "" ? null : Number(upperBound), + rate, + }) + toast.success("Tax slab created") + setSlabOpen(false) + load() + } catch (err) { + toast.error("Could not create tax slab", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

Statutory Settings

+

EPF/ETF rates and tax slabs — effective-dated since these change with government policy.

+
+ + {error &&
{error}
} + +
+
+

EPF / ETF Rates

+ + New Setting} /> + + + New Statutory Setting + Supersedes the current open-ended setting from this date. + + + Effective from setEffectiveFrom(e.target.value)} /> + EPF employee rate (0-1) setEpfEmployeeRate(Number(e.target.value))} /> + EPF employer rate (0-1) setEpfEmployerRate(Number(e.target.value))} /> + ETF employer rate (0-1) setEtfEmployerRate(Number(e.target.value))} /> + Default OT multiplier setOtMultiplierDefault(Number(e.target.value))} /> + +
+ + +
+
+
+
+ + + + Effective from + EPF (Employee) + EPF (Employer) + ETF (Employer) + OT multiplier + + + + {settings.map((s) => ( + + {new Date(s.effectiveFrom).toLocaleDateString()}{s.effectiveTo ? ` – ${new Date(s.effectiveTo).toLocaleDateString()}` : " (current)"} + {(s.epfEmployeeRate * 100).toFixed(1)}% + {(s.epfEmployerRate * 100).toFixed(1)}% + {(s.etfEmployerRate * 100).toFixed(1)}% + {s.otMultiplierDefault}x + + ))} + +
+
+ +
+
+

Tax Slabs

+ + New Slab} /> + + + New Tax Slab + Leave upper bound empty for "and above". + + + Effective from setSlabEffectiveFrom(e.target.value)} /> + Lower bound setLowerBound(Number(e.target.value))} /> + Upper bound (optional) setUpperBound(e.target.value)} placeholder="And above" /> + Rate (0-1) setRate(Number(e.target.value))} /> + +
+ + +
+
+
+
+ + + + Effective from + Range + Rate + + + + {slabs.map((s) => ( + + {new Date(s.effectiveFrom).toLocaleDateString()} + {s.lowerBound.toLocaleString()} – {s.upperBound ? s.upperBound.toLocaleString() : "and above"} + {(s.rate * 100).toFixed(1)}% + + ))} + +
+
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx b/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx new file mode 100644 index 0000000..28523cd --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/settings/work-shifts/page.tsx @@ -0,0 +1,222 @@ +"use client" + +import { useEffect, useState } from "react" +import { Plus } from "lucide-react" + +import { workShiftsApi } from "@/lib/api/hrm-masters" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { WorkShift } from "@/types/hrm" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Checkbox } from "@/components/ui/checkbox" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 +const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + +export default function WorkShiftsPage() { + const [items, setItems] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [startTime, setStartTime] = useState("08:00") + const [endTime, setEndTime] = useState("17:00") + const [isOvernight, setIsOvernight] = useState(false) + const [graceMinutes, setGraceMinutes] = useState(15) + const [breakMinutes, setBreakMinutes] = useState(60) + const [standardWorkingMinutes, setStandardWorkingMinutes] = useState(480) + const [otMultiplier, setOtMultiplier] = useState(1.5) + const [workingDays, setWorkingDays] = useState([true, true, true, true, true, false, false]) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + workShiftsApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { + setItems(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page]) + + async function handleCreate() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Code is required" + if (!name.trim()) nextErrors.name = "Name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + const mask = workingDays.reduce((m, on, i) => (on ? m | (1 << i) : m), 0) + setSubmitting(true) + try { + await workShiftsApi.create({ + code: code.trim(), + name: name.trim(), + startTime: `${startTime}:00`, + endTime: `${endTime}:00`, + isOvernight, + graceMinutes, + breakMinutes, + standardWorkingMinutes, + otMultiplier, + workingDaysMask: mask, + }) + toast.success("Work shift created") + setOpen(false) + setCode("") + setName("") + load() + } catch (err) { + toast.error("Could not create work shift", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function toggleStatus(item: WorkShift) { + try { + await workShiftsApi.updateStatus(item.workShiftId, item.status === "Active" ? "Inactive" : "Active") + load() + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } + } + + return ( +
+
+
+

Work Shifts

+

The baseline Attendance computes Late/Early/OT against.

+
+ + New Shift} /> + + + New Work Shift + Standard hours, grace period, and working days for this shift. + + + + Code + setCode(e.target.value)} aria-invalid={!!errors.code} /> + + + + Name + setName(e.target.value)} aria-invalid={!!errors.name} /> + + +
+ + Start time + setStartTime(e.target.value)} /> + + + End time + setEndTime(e.target.value)} /> + +
+ + setIsOvernight(v === true)} /> + Overnight shift (end time rolls past midnight) + +
+ + Grace (minutes) + setGraceMinutes(Number(e.target.value))} /> + + + Break (minutes) + setBreakMinutes(Number(e.target.value))} /> + + + Standard working minutes + setStandardWorkingMinutes(Number(e.target.value))} /> + + + OT multiplier + setOtMultiplier(Number(e.target.value))} /> + +
+ + Working days +
+ {DAYS.map((d, i) => ( + + ))} +
+
+
+
+ + +
+
+
+
+ + {error &&
{error}
} + {!error && items === null &&
{Array.from({ length: 4 }).map((_, i) => )}
} + {!error && items !== null && items.length === 0 && ( +

No work shifts yet.

+ )} + {!error && items !== null && items.length > 0 && ( + + + + Code + Name + Hours + Status + Actions + + + + {items.map((w) => ( + + {w.code} + {w.name} + {w.startTime.slice(0, 5)}–{w.endTime.slice(0, 5)} + + {w.status} + + + + + + ))} + +
+ )} + {pagination && pagination.totalPages > 1 && ( +
+

Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/layout.tsx b/Frontend/erp-system/app/dashboard/layout.tsx index 55e4289..4ac5d87 100644 --- a/Frontend/erp-system/app/dashboard/layout.tsx +++ b/Frontend/erp-system/app/dashboard/layout.tsx @@ -13,12 +13,12 @@ export default function DashboardLayout({
-
+
-
-
+
+
-
+
{children}
diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index 45b9480..7379d48 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -1,325 +1,284 @@ "use client" -import * as React from "react" -import { CheckCircle2, DollarSign, Package, Plus, ShoppingCart, Users } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { + AlertTriangle, + BadgeDollarSign, + Boxes, + Clock, + ClipboardList, + ListChecks, + PackageCheck, + ScrollText, + Send, +} from "lucide-react" +import { dashboardApi } from "@/lib/api/dashboard" +import { stockApi } from "@/lib/api/stock" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { RecentOrdersTable } from "@/components/dashboard/recent-orders-table" -import { Button } from "@/components/ui/button" -import { DatePicker, DateRangePicker } from "@/components/ui/date-picker" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - AlertDialog, - AlertDialogContent, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog" -import { - Breadcrumb, - BreadcrumbEllipsis, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" +import { DashboardStats } from "@/types/dashboard" +import { LedgerEntry } from "@/types/stock" +import { Warehouse } from "@/types/master-data" import { StatCard } from "@/components/ui/stat-card" -import { toast } from "@/components/ui/toast" -import LineChart from "@/components/ui/line-chart" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { buttonVariants } from "@/components/ui/button" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import BarChart from "@/components/ui/bar-chart" -import PieChart from "@/components/ui/pie-chart" +import LineChart from "@/components/ui/line-chart" -const indigoButton = - "bg-primary/10 text-primary hover:bg-primary/20 focus-visible:ring-primary/40" +const TREND_DAYS = 14 + +function isoDate(d: Date) { + return d.toISOString().slice(0, 10) +} + +// Shown only when the last TREND_DAYS days have no real ledger activity, so the +// chart isn't a flat zero line before there's any real movement to plot. +const SAMPLE_TREND_IN = [42, 58, 35, 70, 64, 30, 20, 85, 46, 55, 38, 62, 48, 72] +const SAMPLE_TREND_OUT = [30, 40, 45, 38, 50, 22, 15, 60, 33, 47, 28, 44, 36, 58] export default function DashboardPage() { - const [date, setDate] = React.useState() - const [range, setRange] = React.useState<{ from: Date | undefined; to?: Date | undefined }>() + const [stats, setStats] = useState(null) + const [movements, setMovements] = useState(null) + const [trend, setTrend] = useState(null) + const [warehouses, setWarehouses] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + const from = new Date() + from.setDate(from.getDate() - (TREND_DAYS - 1)) + + Promise.all([ + dashboardApi.stats(), + stockApi.ledger({ page: 1, pageSize: 5 }), + stockApi.ledger({ from: isoDate(from), page: 1, pageSize: 200 }), + warehousesApi.list(), + ]) + .then(([statsRes, ledger, trendRes, warehousesRes]) => { + if (cancelled) return + setStats(statsRes) + setMovements(ledger.items) + setTrend(trendRes.items) + setWarehouses(warehousesRes.items) + }) + .catch((err) => { + if (!cancelled) setError(errorMessage(err)) + }) + + return () => { + cancelled = true + } + }, []) + + const warehousesById = useMemo(() => new Map((warehouses ?? []).map((w) => [w.warehouseId, w])), [warehouses]) + + // Last TREND_DAYS days, oldest first, each bucket summing In/Out qty for that date. + const movementTrend = useMemo(() => { + const days: string[] = [] + const cursor = new Date() + cursor.setDate(cursor.getDate() - (TREND_DAYS - 1)) + for (let i = 0; i < TREND_DAYS; i++) { + days.push(isoDate(cursor)) + cursor.setDate(cursor.getDate() + 1) + } + + const labels = days.map((d) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short" })) + + if (!trend || trend.length === 0) { + return { labels, inData: SAMPLE_TREND_IN, outData: SAMPLE_TREND_OUT } + } + + const inByDay = new Map(days.map((d) => [d, 0])) + const outByDay = new Map(days.map((d) => [d, 0])) + for (const entry of trend) { + const day = entry.createdAt.slice(0, 10) + const bucket = entry.direction === "In" ? inByDay : outByDay + if (bucket.has(day)) bucket.set(day, (bucket.get(day) ?? 0) + entry.qtyBase) + } + + return { + labels, + inData: days.map((d) => inByDay.get(d) ?? 0), + outData: days.map((d) => outByDay.get(d) ?? 0), + } + }, [trend]) + + const loaded = stats && movements && trend && warehouses + return (
-
-
-

Breadcrumb

-
- {/* Basic */} - - - - Home - - - - Products - - - - Product Detail - - - +
+

Dashboard

+

Overview of stock, receiving and procurement.

+
- {/* With ellipsis */} - - - - Home - - - - - - - - Products - - - - Edit - - - -
+ {error && ( +
+ {error} +
+ )} + +
+ {loaded ? ( + <> + + + + + + + + + + + + + + + + + + + + + + + ) : ( + !error && Array.from({ length: 7 }).map((_, i) => ) + )} +
+ +
+
+

+ + Stock Valuation by Warehouse +

+ + View details +
-
-

Variants

-
- - - - - - - - - -
-
- -
-

Sizes

-
- - - - - - - - - -
-
- -
-

Date Picker

-
-
- Single date - 0 ? ( +
+ warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` + )} + datasets={[ + { + label: "Stock Value (LKR)", + data: stats.stockValuationByWarehouse.map((w) => w.total), + backgroundColor: "#6366f1", + }, + ]} />
-
- Date range - -
-
- {(date || range?.from) && ( -

- {date && <>Selected: {date.toLocaleDateString()}} - {range?.from && ( - <> - {date && " · "} - Range: {range.from.toLocaleDateString()} - {range.to && <> – {range.to.toLocaleDateString()}} - - )} -

- )} -
- -
-

Modal

- - Open Modal} /> - - -
- -
- Order confirmed - - Your order has been placed successfully and is now being processed. - -
- -
- -
-
-
-
- -
-

Toast

-
- - - - - - -
-
+ ) : ( +

No stock on hand yet.

+ ) + ) : ( + !error && + )}
-
-
-

Alert Dialogs

-
- - Info} /> - toast.info("Reloading...", "Applying the latest update.")} - /> - - - - Success} /> - toast.success("Done!", "Redirecting to dashboard.")} - /> - - - - Warning} /> - toast.warning("Changes discarded")} - /> - - - - Delete} /> - toast.error("Deleted", "The record has been permanently removed.")} - /> - -
+
+
+ +

+ Stock Movement Trend (last {TREND_DAYS} days) +

-
-
- - - - -
- - - -
-
-

Sales (Line)

-
+ {loaded ? ( +
+ ) : ( + !error && + )} +
+ +
+
+

+ + Recent Stock Movements +

+ + View all +
-
-

Revenue (Bar)

-
- -
-
- -
-

Product Mix (Pie)

-
- -
-
+ {loaded ? ( + movements.length > 0 ? ( + + + + Item + Warehouse + Direction + Qty + Source + Date + + + + {movements.map((entry) => ( + + #{entry.itemId} + + {warehousesById.get(entry.warehouseId)?.code ?? `#${entry.warehouseId}`} + + + + {entry.direction} + + + {entry.qtyBase} + + {entry.sourceDocType} #{entry.sourceDocId} + + + {new Date(entry.createdAt).toLocaleDateString()} + + + ))} + +
+ ) : ( +

No stock movements yet.

+ ) + ) : ( + !error && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + )}
) diff --git a/Frontend/erp-system/app/dashboard/procurement/page.tsx b/Frontend/erp-system/app/dashboard/procurement/page.tsx index cc3a0c4..bb36515 100644 --- a/Frontend/erp-system/app/dashboard/procurement/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/page.tsx @@ -1,35 +1,3 @@ -import Link from "next/link" -import { ClipboardList, FileText, PackageX, ShoppingCart, type LucideIcon } from "lucide-react" - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" - -const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [ - { - title: "Requisitions", - description: "Raise a purchase requisition and submit it into procurement.", - href: "/dashboard/procurement/requisitions", - icon: ClipboardList, - }, - { - title: "RFQs", - description: "Request quotations from vendors, record pricing, and compare side by side.", - href: "/dashboard/procurement/rfqs", - icon: FileText, - }, - { - title: "Purchase Orders", - description: "Save as draft (editable/deletable) or submit to lock; cancel an issued PO before receipt.", - href: "/dashboard/procurement/purchase-orders", - icon: ShoppingCart, - }, - { - title: "Purchase Returns", - description: "Return received goods to a vendor, referencing the original GRN line.", - href: "/dashboard/procurement/purchase-returns", - icon: PackageX, - }, -] - export default function ProcurementHubPage() { return (
@@ -39,26 +7,6 @@ export default function ProcurementHubPage() { Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09).

- -
- {areas.map((area) => ( - - - -
-
- -
- {area.title} -
-
- -

{area.description}

-
-
- - ))} -
) } diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx index cf3db2a..65c6a6b 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-orders/[id]/page.tsx @@ -267,7 +267,7 @@ export default function PurchaseOrderDetailPage() { return (
-
+
@@ -283,7 +283,7 @@ export default function PurchaseOrderDetailPage() {
-
+
{po.status === "Draft" && ( <> + +
+ + {/* ------------------------------------------------------------- leftover */} + !open && setDialog(null)}> + + + Return leftover material + + Puts unused raw material back into stock at the cost it was consumed at, and takes it out of this + run's cost pool. + + + +
+ + Consumed material + value={selectedInputId} onValueChange={(v) => { setSelectedInputId(v); setReturnQty("") }}> + + + + + {returnable.map((r) => ( + + {itemName(r.itemId)} · {r.stageName} · {fmt(r.remaining)} left + + ))} + + + + + {selected && ( +
+
+ Consumed + + {fmt(selected.consumedQty)} · {money(selected.consumedValue)} + +
+
+ Already returned + + {fmt(selected.returnedQty)} · {money(selected.returnedValue)} + +
+
+ Weighted cost + {weightedCost === null ? "—" : money(weightedCost)} +
+
+ )} + + + + Quantity to return + {selected && (base UOM, max {fmt(selected.remaining)})} + + setReturnQty(e.target.value)} + placeholder={selected ? String(selected.remaining) : ""} + /> + + + + Reason + value={returnReasonId} onValueChange={setReturnReasonId}> + + + + + {productionReasons.map((r) => ( + + {r.code} — {r.description} + + ))} + + + + + {error && ( +
+ + {error} +
+ )} +
+ +
+ + +
+
+
+ + {/* --------------------------------------------------------------- cancel */} + !open && setDialog(null)}> + + + Cancel this run? + + Everything consumed and not yet returned goes back into stock at its consumed cost. Scrapped output is + written off — it never entered stock. This cannot be undone. + + + +
+ {returnable.length > 0 && ( +
+

Will be returned to stock

+ {returnable.map((r) => ( +
+ {itemName(r.itemId)} + {fmt(r.remaining)} +
+ ))} +
+ )} + + + Reason + value={cancelReasonId} onValueChange={setCancelReasonId}> + + + + + {productionReasons.map((r) => ( + + {r.code} — {r.description} + + ))} + + + + + + Note (optional) + setCancelNote(e.target.value)} /> + + + {error && ( +
+ + {error} +
+ )} +
+ +
+ + +
+
+
+ + ) +} diff --git a/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx b/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx new file mode 100644 index 0000000..ba69571 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/runs/[id]/StageDrawer.tsx @@ -0,0 +1,739 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import { AlertTriangle, Ban, CheckCircle2, PackageCheck, Play, Send, Undo2 } from "lucide-react" + +import { cn } from "@/lib/utils" +import { isStaleStageError, productionRunsApi } from "@/lib/api/production-runs" +import { errorMessage } from "@/lib/error-map" +import { CustomFieldForm, missingRequiredFields } from "./CustomFieldForm" +import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors" +import { + CompleteOutputLine, + ProductionRunGraph, + RunStage, + RunStageInput, + RunStageOutput, + StageQuantityLine, + TransferLine, +} from "@/types/production" +import { ItemListItem, Uom } from "@/types/master-data" +import { ReasonCode } from "@/types/stock" + +import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" +import { Separator } from "@/components/ui/separator" + +/** + * The stage drawer (docs/21-FRONTEND-PHASE2.md §5) — the only place a run is actually driven. + * + * One component rather than the seven the plan sketched: each per-status panel is ~30 lines and + * they all share the same lookup helpers, submit wrapper, and error handling, so splitting them + * would mean threading that shared context through seven prop lists for no isolation benefit. + * + * The body is switched on `stage.status`, which is deliberately the *server's* status and never + * a local guess — every action re-fetches the whole run through `onActed`, so what is rendered + * is always what the server last said. Two consequences worth knowing: + * + * * A 409 carrying a stage-status code means someone else moved first. `submit` refreshes + * silently instead of showing an error (docs/21 §6); that is also what makes the server's + * accept-and-ignore `Idempotency-Key` posture feel right — a double-click just refreshes. + * * Local form state is keyed off `stage.runStageId` and reset whenever the stage changes, so + * a refresh mid-edit can never post figures from a stage the user is no longer looking at. + */ + +function fmt(n: number): string { + return Number(n.toFixed(4)).toLocaleString(undefined, { maximumFractionDigits: 4 }) +} + +function money(n: number): string { + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 }) +} + +/** Minutes between two instants, floored — the same unit the server reports `actualMinutes` in. */ +function minutesSince(iso: string, now: number): number { + return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000)) +} + +function StatusPill({ status }: { status: StageStatus }) { + const color = STAGE_STATUS_COLOR[status] + return ( + + + {STAGE_STATUS_LABEL[status]} + + ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +export function StageDrawer({ + run, + stage, + items, + uoms, + reasonCodes, + onClose, + onActed, +}: { + run: ProductionRunGraph + stage: RunStage | null + items: ItemListItem[] + uoms: Uom[] + reasonCodes: ReasonCode[] + onClose: () => void + onActed: () => void +}) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + // Editable planned quantities (Ready only) keyed by run input/output id. + const [plannedInputs, setPlannedInputs] = useState>({}) + const [plannedOutputs, setPlannedOutputs] = useState>({}) + + // Complete-form state. + const [produced, setProduced] = useState>({}) + const [scrapped, setScrapped] = useState>({}) + const [scrapReason, setScrapReason] = useState>({}) + const [fieldValues, setFieldValues] = useState>({}) + + // Transfer state — the amount to push per output, defaulted to everything available. + const [transferQty, setTransferQty] = useState>({}) + + const [confirm, setConfirm] = useState(null) + + // A per-action Idempotency-Key, minted fresh for each stage the drawer opens on. Same shape as + // app/dashboard/receiving/grn/[id]/page.tsx. + const idempotencyKey = useRef(crypto.randomUUID()) + + const stageId = stage?.runStageId ?? null + + // Reseed every form whenever the drawer switches stage *or* the server sends new figures for + // the one it is on. Without the second half, a refresh after an action would leave the inputs + // showing pre-action numbers. + useEffect(() => { + if (!stage) return + setPlannedInputs(Object.fromEntries(stage.inputs.map((i) => [i.runInputId, String(i.plannedQty)]))) + setPlannedOutputs(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.plannedQty)]))) + setProduced(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.producedQty || o.plannedQty)]))) + setScrapped(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.scrappedQty || 0)]))) + setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId]))) + setFieldValues((stage.fieldValues ?? {}) as Record) + setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)]))) + setError(null) + idempotencyKey.current = crypto.randomUUID() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stageId, stage?.status, stage?.outputs, stage?.inputs]) + + // Live elapsed while the stage is running (FR-MFG-19). One tick a minute is enough — the + // figure is rendered in whole minutes, so a faster interval would just re-render for nothing. + const [now, setNow] = useState(() => Date.now()) + const running = stage?.actualStartAt != null && stage?.actualEndAt == null + useEffect(() => { + if (!running) return + const timer = setInterval(() => setNow(Date.now()), 60_000) + return () => clearInterval(timer) + }, [running]) + + const itemName = useMemo(() => { + const byId = new Map(items.map((i) => [i.itemId, i.name])) + return (id: number | null) => (id === null ? "—" : byId.get(id) ?? `Item #${id}`) + }, [items]) + + const uomName = useMemo(() => { + const byId = new Map(uoms.map((u) => [u.uomId, u.name])) + return (id: number) => byId.get(id) ?? `#${id}` + }, [uoms]) + + const stageName = useMemo(() => { + const byId = new Map(run.stages.map((s) => [s.runStageId, s.name])) + return (id: number) => byId.get(id) ?? `Stage #${id}` + }, [run.stages]) + + if (!stage) return null + + const isEditable = stage.status === "Ready" + const upstreamInputs = stage.inputs.filter((i) => i.source === "Upstream") + const deliveredSoFar = upstreamInputs.reduce((sum, i) => sum + i.deliveredQty, 0) + const canRejectIntake = (stage.status === "Ready" || stage.status === "Waiting") && deliveredSoFar > 0 + const availableTotal = stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0) + const stageEvents = run.events.filter((e) => e.runStageId === stage.runStageId) + + /** + * One wrapper for every action: busy flag, error surfacing, and the docs/21 §6 rule that a + * stage-status 409 refreshes silently rather than shouting at the user. + */ + async function submit(action: () => Promise) { + setBusy(true) + setError(null) + try { + await action() + idempotencyKey.current = crypto.randomUUID() + onActed() + } catch (err) { + if (isStaleStageError(err)) { + onActed() + return + } + setError(errorMessage(err)) + } finally { + setBusy(false) + } + } + + function saveQuantities() { + const inputs: StageQuantityLine[] = stage!.inputs + .map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) })) + .filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0) + const outputs: StageQuantityLine[] = stage!.outputs + .map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) })) + .filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0) + + return submit(() => productionRunsApi.updateStageQuantities(run.runId, stage!.runStageId, { inputs, outputs })) + } + + function completeStage() { + const outputs: CompleteOutputLine[] = stage!.outputs.map((o) => ({ + runOutputId: o.runOutputId, + producedQty: Number(produced[o.runOutputId] ?? 0) || 0, + scrappedQty: Number(scrapped[o.runOutputId] ?? 0) || 0, + scrapReasonCodeId: Number(scrapped[o.runOutputId] ?? 0) > 0 ? scrapReason[o.runOutputId] : null, + })) + + return submit(() => + productionRunsApi.complete( + run.runId, + stage!.runStageId, + { outputs, fieldValues: Object.keys(fieldValues).length > 0 ? fieldValues : null }, + idempotencyKey.current + ) + ) + } + + /** + * Approve. An empty `transfers` array tells the server to push every output in full, which is + * the common case; a line is only sent when the user has dialled it down below what's + * available. On the terminal stage the server ignores transfers entirely and posts the receipt. + */ + function approveStage() { + const transfers: TransferLine[] = stage!.outputs + .filter((o) => { + const wanted = Number(transferQty[o.runOutputId] ?? o.availableToTransfer) + return Number.isFinite(wanted) && wanted !== o.availableToTransfer + }) + .map((o) => ({ runOutputId: o.runOutputId, qty: Number(transferQty[o.runOutputId]) })) + + return submit(() => + productionRunsApi.approve( + run.runId, + stage!.runStageId, + transfers.length > 0 ? { transfers } : {}, + idempotencyKey.current + ) + ) + } + + function transferRemainder(output: RunStageOutput) { + const qty = Number(transferQty[output.runOutputId] ?? 0) + return submit(() => + productionRunsApi.transfer( + run.runId, + stage!.runStageId, + { runOutputId: output.runOutputId, qty }, + idempotencyKey.current + ) + ) + } + + // --- terminal receipt preview --------------------------------------------- + // Mirrors the server's arithmetic (docs/30 §D.3 terminal approve) so the operator sees the + // layer they are about to create *before* creating it. Deliberately recomputed here rather + // than requested: there is no preview endpoint, and every input is already on this page. + const terminalOutput = stage.isTerminal ? stage.outputs[0] : undefined + const goodQty = terminalOutput ? terminalOutput.producedQty - terminalOutput.scrappedQty : 0 + const previewUnitCost = goodQty > 0 ? run.costPool.net / goodQty : null + + const scrapReasons = reasonCodes.filter((r) => r.context === "Production") + + const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues) + const missingScrapReasons = stage.outputs + .filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId]) + .map((o) => o.name) + + return ( + <> + !open && onClose()}> + + +
+ {stage.name} + + {stage.roleLabel && ( + + {stage.roleLabel} + + )} + {stage.isTerminal && ( + + Final stage + + )} +
+ + Estimated {stage.estimatedMinutes} min + {stage.actualMinutes !== null + ? ` · actual ${stage.actualMinutes} min` + : running && stage.actualStartAt + ? ` · running for ${minutesSince(stage.actualStartAt, now)} min` + : ""} + +
+ +
+ {error && ( +
+ + {error} +
+ )} + + {/* ---------------------------------------------------------- inputs */} +
+

Inputs

+ {stage.inputs.length === 0 &&

This stage consumes nothing.

} + {stage.inputs.map((input) => ( + setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))} + itemName={itemName} + uomName={uomName} + /> + ))} +
+ + + + {/* --------------------------------------------------------- outputs */} +
+

Outputs

+ {stage.outputs.map((output) => ( +
+
+

{output.name}

+ {uomName(output.uomId)} +
+ {output.itemId !== null && ( +

Finished good: {itemName(output.itemId)}

+ )} + +
+ {isEditable ? ( + + ) : ( + {fmt(output.plannedQty)} + )} + {output.producedQty > 0 && {fmt(output.producedQty)}} + {output.scrappedQty > 0 && ( + + {fmt(output.scrappedQty)} + + )} + {output.transferredQty > 0 && {fmt(output.transferredQty)}} + {output.availableToTransfer > 0 && ( + + {fmt(output.availableToTransfer)} + + )} +
+
+ ))} +
+ + + + {/* ------------------------------------------------ status-specific */} + {stage.status === "Waiting" && ( +

+ Waiting on upstream deliveries. This stage becomes Ready once every upstream input has received its + planned quantity. +

+ )} + + {stage.status === "Ready" && ( +
+

+ Adjust planned quantities if needed, then start. Starting consumes the stock inputs above FIFO — + quantities are locked from that point on. +

+
+ + +
+
+ )} + + {stage.status === "InProgress" && ( +
+

Record output

+ {stage.outputs.map((output) => { + const scrapQty = Number(scrapped[output.runOutputId] ?? 0) + return ( +
+

{output.name}

+ + + {scrapQty > 0 && ( + + value={scrapReason[output.runOutputId] ?? null} + onValueChange={(v) => setScrapReason((prev) => ({ ...prev, [output.runOutputId]: v }))} + > + + + + + {scrapReasons.map((r) => ( + + {r.code} — {r.description} + + ))} + + + )} +
+ ) + })} + + + + {/* + Blocked client-side as well as server-side. The server's 400 + REQUIRED_FIELD_MISSING rolls the whole transaction back, so letting it through + would cost a round trip and — worse — look to the operator like the completion + half-applied. `missingRequiredFields` deliberately mirrors the server's rule. + */} + {missingScrapReasons.length > 0 && ( +

+ Pick a scrap reason for: {missingScrapReasons.join(", ")}. +

+ )} + {missingFields.length > 0 && ( +

Fill in: {missingFields.join(", ")}.

+ )} + + +
+ )} + + {stage.status === "Done" && !stage.isTerminal && ( +
+

Approve & transfer

+

+ Approving pushes work downstream. Leave the amounts as they are to transfer everything, or lower one to + hold some back — you can transfer the remainder later. +

+ {stage.outputs.map((output) => ( + + ))} + +
+ )} + + {stage.status === "Done" && stage.isTerminal && terminalOutput && ( +
+

Finish the run

+
+ {fmt(goodQty)} {uomName(terminalOutput.uomId)} + {money(run.costPool.consumed)} + −{money(run.costPool.returned)} + + {money(run.costPool.net)} + {previewUnitCost === null ? "—" : money(previewUnitCost)} +
+

+ Approving creates a costed stock layer of {fmt(goodQty)} {itemName(terminalOutput.itemId)} and completes + the run. Its costs close at that point — return any leftover materials first. +

+
+ + +
+
+ )} + + {stage.status === "Approved" && availableTotal > 0 && ( +
+

Transfer remainder

+ {stage.outputs + .filter((o) => o.availableToTransfer > 0) + .map((output) => ( +
+ {output.name} + setTransferQty((prev) => ({ ...prev, [output.runOutputId]: e.target.value }))} + className="h-8 w-24 text-sm" + /> + +
+ ))} +
+ )} + + {stage.status === "Approved" && availableTotal === 0 && ( +

Approved — everything this stage produced has moved on.

+ )} + + {canRejectIntake && ( + <> + +
+

Reject what was delivered

+

+ Sends {fmt(deliveredSoFar)} back to the stage(s) that delivered it and reopens them for correction. + No stock moves — this is work in progress, not inventory. +

+ +
+ + )} + + {/* --------------------------------------------------------- history */} + {stageEvents.length > 0 && ( + <> + +
+

History

+
    + {stageEvents.map((event) => ( +
  1. + +
    + {event.eventType} + + {new Date(event.createdAt).toLocaleString()} + + {event.note && {event.note}} +
    +
  2. + ))} +
+
+ + )} +
+
+
+ + !open && setConfirm(null)}> + + submit(() => productionRunsApi.rejectIntake(run.runId, stage.runStageId, {}, idempotencyKey.current)) + } + /> + + + !open && setConfirm(null)}> + + submit(() => productionRunsApi.rejectTerminal(run.runId, stage.runStageId, {}, idempotencyKey.current)) + } + /> + + + ) +} + +/** Split out only because the input card has three mutually exclusive quantity presentations. */ +function InputCard({ + input, + editable, + value, + onValueChange, + itemName, + uomName, +}: { + input: RunStageInput + editable: boolean + value: string + onValueChange: (value: string) => void + itemName: (id: number | null) => string + uomName: (id: number) => string +}) { + const isUpstream = input.source === "Upstream" + const short = isUpstream && input.deliveredQty < input.plannedQty + + return ( +
+
+

+ {isUpstream ? "Upstream work in progress" : itemName(input.itemId)} +

+ {uomName(input.uomId)} +
+ +
+ {editable ? ( + + ) : ( + {fmt(input.plannedQty)} + )} + + {isUpstream && ( + + {fmt(input.deliveredQty)} + + )} + + {/* + Consumed/returned figures are in the item's BASE uom, while `plannedQty` above is in the + input's declared uom — an input declared in "box of 12" shows planned 3 and consumed 36. + Labelled explicitly so the two are never read as the same unit. + */} + {input.consumedQty > 0 && ( + <> + + {fmt(input.consumedQty)} · {money(input.consumedValue)} + + {input.returnedQty > 0 && ( + + {fmt(input.returnedQty)} · {money(input.returnedValue)} + + )} + + )} +
+
+ ) +} + +/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */ +function CustomFields({ + stage, + values, + onChange, +}: { + stage: RunStage + values: Record + onChange: (next: Record) => void +}) { + if (stage.fieldDefs.length === 0) return null + return ( +
+

Checks

+ +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx new file mode 100644 index 0000000..081d561 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/runs/[id]/page.tsx @@ -0,0 +1,333 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import { ReactFlow, Background, Controls, MiniMap, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { useTheme } from "next-themes" +import { ArrowLeft, RotateCcw } from "lucide-react" + +import { cn } from "@/lib/utils" +import { productionRunsApi } from "@/lib/api/production-runs" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { warehousesApi } from "@/lib/api/warehouses" +import { reasonCodesApi } from "@/lib/api/reason-codes" +import { errorMessage } from "@/lib/error-map" +import { ProductionRunGraph, ProductionRunStatus, RunStage, StageSummary } from "@/types/production" +import { ItemListItem, Uom, Warehouse } from "@/types/master-data" +import { ReasonCode } from "@/types/stock" +import { STAGE_STATUS_COLOR } from "@/lib/production-status-colors" +import { + RunHeaderNodeComponent, + RunStageNodeComponent, + type RunHeaderData, + type RunStageData, +} from "@/components/production/RunStageNode" +import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { StageDrawer } from "./StageDrawer" +import { RunActions } from "./RunActions" + +function runStatusBadgeClass(status: ProductionRunStatus) { + if (status === "Completed") return "bg-success/10 text-success" + if (status === "Cancelled") return "bg-destructive/10 text-destructive" + return "bg-info/10 text-info" +} + +const nodeTypes = { runHeader: RunHeaderNodeComponent, runStage: RunStageNodeComponent } + +/** + * Where the run header box sits relative to the stages. + * + * Stage positions come from the run's own `posX`/`posY` — copied from the template at creation + * (FR-MFG-06), so the canvas matches what was drawn in the builder. The header is placed to the + * left of the leftmost stage rather than at a fixed origin, because those coordinates are + * arbitrary and could otherwise put the header on top of a stage. + */ +const HEADER_GAP_X = 240 + +function buildFlow(run: ProductionRunGraph): { nodes: Node[]; edges: Edge[] } { + const minX = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posX)) : 0 + const minY = run.stages.length > 0 ? Math.min(...run.stages.map((s) => s.posY)) : 0 + + const nodes: Node[] = [ + { + id: "header", + type: "runHeader", + position: { x: minX - HEADER_GAP_X, y: minY }, + data: { docNo: run.docNo, templateName: run.templateName, status: run.status } satisfies RunHeaderData, + draggable: false, + }, + ] + + // The stage the operator is expected to act on: the furthest-along actionable one, so a run + // mid-flight highlights the stage in progress rather than the first thing still Waiting. + const actionable = ["InProgress", "Done", "Ready"] as const + const activeId = + run.status === "InProgress" + ? actionable.reduce( + (found, status) => found ?? run.stages.find((s) => s.status === status)?.runStageId ?? null, + null + ) + : null + + for (const stage of run.stages) { + const upstream = stage.inputs.filter((i) => i.source === "Upstream") + nodes.push({ + id: String(stage.runStageId), + type: "runStage", + position: { x: stage.posX, y: stage.posY }, + data: { + name: stage.name, + roleLabel: stage.roleLabel, + state: stage.status, + isTerminal: stage.isTerminal, + isEntry: stage.isEntry, + estimatedMinutes: stage.estimatedMinutes, + actualMinutes: stage.actualMinutes, + actualStartAt: stage.actualStartAt, + intake: + upstream.length === 0 + ? null + : { + delivered: upstream.reduce((sum, i) => sum + i.deliveredQty, 0), + planned: upstream.reduce((sum, i) => sum + i.plannedQty, 0), + }, + availableToTransfer: stage.outputs.reduce((sum, o) => sum + o.availableToTransfer, 0), + isActive: stage.runStageId === activeId, + } satisfies RunStageData, + draggable: false, + }) + } + + const edges: Edge[] = run.edges.map((e) => { + const parent = run.stages.find((s) => s.runStageId === e.parentRunStageId) + return { + id: `e${e.runEdgeId}`, + source: String(e.parentRunStageId), + target: String(e.childRunStageId), + animated: parent?.status === "InProgress", + } + }) + + // Entry stages hang off the run header so the line reads left to right from the run itself. + for (const stage of run.stages.filter((s) => s.isEntry)) { + edges.push({ id: `eh-${stage.runStageId}`, source: "header", target: String(stage.runStageId) }) + } + + return { nodes, edges } +} + +export default function ProductionRunDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const { resolvedTheme } = useTheme() + const runId = Number(params.id) + + const [run, setRun] = useState(null) + const [loadError, setLoadError] = useState(null) + const [selectedStageId, setSelectedStageId] = useState(null) + + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [reasonCodes, setReasonCodes] = useState([]) + + // Same hydration-mismatch guard as the other canvas pages: colorMode depends on + // resolvedTheme, which is unknown on the server and on the client's first paint. + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + const load = useCallback(() => { + setLoadError(null) + productionRunsApi + .get(runId) + .then(({ data }) => setRun(data)) + .catch((err) => setLoadError(errorMessage(err))) + }, [runId]) + + useEffect(() => { + if (Number.isFinite(runId)) load() + }, [runId, load]) + + useEffect(() => { + Promise.all([ + itemsApi.list({ pageSize: 200 }), + uomsApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + reasonCodesApi.list("Production", { pageSize: 100 }), + ]) + .then(([itemRes, uomRes, warehouseRes, reasonRes]) => { + setItems(itemRes.items) + setUoms(uomRes.items) + setWarehouses(warehouseRes.items) + setReasonCodes(reasonRes.items) + }) + .catch(() => { + // Reference data only feeds names and pickers — a failure here degrades labels to ids + // rather than blocking the run, so it deliberately doesn't set loadError. + }) + }, []) + + const { nodes, edges } = useMemo( + () => (run ? buildFlow(run) : { nodes: [] as Node[], edges: [] as Edge[] }), + [run] + ) + + const stageSummary: StageSummary = useMemo(() => { + const counts: StageSummary = { waiting: 0, ready: 0, inProgress: 0, done: 0, approved: 0 } + for (const stage of run?.stages ?? []) { + if (stage.status === "Waiting") counts.waiting++ + else if (stage.status === "Ready") counts.ready++ + else if (stage.status === "InProgress") counts.inProgress++ + else if (stage.status === "Done") counts.done++ + else counts.approved++ + } + return counts + }, [run]) + + const selectedStage: RunStage | null = + run?.stages.find((s) => s.runStageId === selectedStageId) ?? null + + const onNodeClick: NodeMouseHandler = useCallback((_, node) => { + if (node.type !== "runStage") return + setSelectedStageId(Number(node.id)) + }, []) + + if (loadError) { + return ( +
+

{loadError}

+ +
+ ) + } + + if (!run) { + return ( +
+ + + +
+ ) + } + + const warehouse = warehouses.find((w) => w.warehouseId === run.warehouseId) + const approvedCount = stageSummary.approved + const progressPercent = run.stages.length > 0 ? Math.round((approvedCount / run.stages.length) * 100) : 0 + + return ( +
+ + +
+
+
+
+ {run.docNo} + + {run.status === "InProgress" ? "In Progress" : run.status} + + {run.reworkCount > 0 && ( + + + Rework #{run.reworkCount} + + )} +
+

+ {run.templateName} + {warehouse && ` · ${warehouse.name}`} + {` · ×${Number(run.scaleFactor.toFixed(6)).toLocaleString()} scale`} +

+
+
+ Target {run.targetQty.toLocaleString()} + + Created {new Date(run.createdAt).toLocaleDateString()} + {run.completedAt && <> · Completed {new Date(run.completedAt).toLocaleDateString()}} + +
+
+ + + +
+
+
+ + {progressPercent}% approved + + {" "}· cost pool {run.costPool.net.toLocaleString(undefined, { minimumFractionDigits: 2 })} + + +
+
+
+
+
+ +
+
+ +
+ +
+ +

Click a stage to open it.

+ +
+ {mounted ? ( + + + + + + ) : ( + + )} +
+ + setSelectedStageId(null)} + onActed={load} + /> +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/production/runs/page.tsx b/Frontend/erp-system/app/dashboard/production/runs/page.tsx new file mode 100644 index 0000000..0aca54b --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/runs/page.tsx @@ -0,0 +1,448 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { ChevronRight, PlayCircle, RotateCcw, Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { productionRunsApi } from "@/lib/api/production-runs" +import { productionTemplatesApi } from "@/lib/api/production-templates" +import { warehousesApi } from "@/lib/api/warehouses" +import { errorMessage } from "@/lib/error-map" +import { ProductionRunStatus, ProductionRunSummary, ProductionTemplateSummary } from "@/types/production" +import { Bin, Warehouse } from "@/types/master-data" +import { PaginationMeta } from "@/types/common" +import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, STAGE_STATUS_ORDER } from "@/lib/production-status-colors" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" +import { StageProgressStrip, StageStatusLegend } from "@/components/production/stage-progress-strip" + +const PAGE_SIZE = 25 + +type StatusFilter = ProductionRunStatus | "All" + +function runStatusBadgeClass(status: ProductionRunStatus) { + if (status === "Completed") return "bg-success/10 text-success" + if (status === "Cancelled") return "bg-destructive/10 text-destructive" + return "bg-info/10 text-info" +} + +const SUMMARY_KEYS = { + Waiting: "waiting", + Ready: "ready", + InProgress: "inProgress", + Done: "done", + Approved: "approved", +} as const + +export default function ProductionRunsPage() { + const router = useRouter() + + // null = still loading (the codebase convention for "no data yet" vs "empty result"). + const [runs, setRuns] = useState(null) + const [pagination, setPagination] = useState(null) + const [loadError, setLoadError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("All") + const [templateId, setTemplateId] = useState(null) + const [warehouseId, setWarehouseId] = useState(null) + const [page, setPage] = useState(1) + + const [templates, setTemplates] = useState([]) + const [warehouses, setWarehouses] = useState([]) + + const [open, setOpen] = useState(false) + const [startTemplateId, setStartTemplateId] = useState(null) + const [targetQty, setTargetQty] = useState("") + const [startWarehouseId, setStartWarehouseId] = useState(null) + const [bins, setBins] = useState([]) + const [outputBinId, setOutputBinId] = useState(null) + const [formError, setFormError] = useState("") + const [submitting, setSubmitting] = useState(false) + + // 300ms debounce, matching app/dashboard/receiving/grn/page.tsx. Resets to page 1 with the + // query so a narrower search can't leave you stranded past the last page. + useEffect(() => { + const timer = setTimeout(() => { + setQuery(searchInput.trim()) + setPage(1) + }, 300) + return () => clearTimeout(timer) + }, [searchInput]) + + const load = useCallback(() => { + setLoadError(null) + productionRunsApi + .list({ + page, + pageSize: PAGE_SIZE, + q: query || undefined, + status: status === "All" ? undefined : status, + templateId: templateId ?? undefined, + warehouseId: warehouseId ?? undefined, + }) + .then((res) => { + setRuns(res.items) + setPagination(res.pagination) + }) + .catch((err) => { + setRuns([]) + setLoadError(errorMessage(err)) + }) + }, [page, query, status, templateId, warehouseId]) + + useEffect(load, [load]) + + // Filter and picker fills. Templates are fetched unfiltered so the *filter* can name an + // Inactive template that still has historical runs; the start dialog narrows to Active + // itself, because FR-MFG-01 only blocks starting new runs (docs/21 §4). + useEffect(() => { + Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })]) + .then(([templateRes, warehouseRes]) => { + setTemplates(templateRes.items) + setWarehouses(warehouseRes.items) + }) + .catch((err) => toast.error("Could not load filters", errorMessage(err))) + }, []) + + // Bins belong to a warehouse, so the list is only meaningful once one is picked. + useEffect(() => { + if (startWarehouseId === null) { + setBins([]) + return + } + warehousesApi + .listBins(startWarehouseId) + .then(setBins) + .catch(() => setBins([])) + }, [startWarehouseId]) + + const startableTemplates = templates.filter((t) => t.status === "Active") + const startTemplate = startableTemplates.find((t) => t.templateId === startTemplateId) ?? null + const targetQtyNum = Number(targetQty) + + function openStartDialog() { + setStartTemplateId(null) + setTargetQty("") + setStartWarehouseId(null) + setOutputBinId(null) + setFormError("") + setOpen(true) + } + + async function handleStartRun() { + if (!startTemplate) return setFormError("Pick a template.") + if (!(targetQtyNum > 0)) return setFormError("Target quantity must be greater than 0.") + if (startWarehouseId === null) return setFormError("Pick a warehouse.") + + setFormError("") + setSubmitting(true) + try { + const { data } = await productionRunsApi.create({ + templateId: startTemplate.templateId, + targetQty: targetQtyNum, + warehouseId: startWarehouseId, + outputBinId, + }) + toast.success("Run started", `${data.docNo} — ${data.templateName}`) + setOpen(false) + // Straight to the run: the per-stage quantities the operator may want to adjust before + // starting stage one only exist there (docs/30 §4). + router.push(`/dashboard/production/runs/${data.runId}`) + } catch (err) { + setFormError(errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + const hasFilters = query.length > 0 || status !== "All" || templateId !== null || warehouseId !== null + + return ( +
+
+
+

Production Runs

+

All manufacturing runs with per-stage progress at a glance.

+
+ + Start Run} /> + + + Start a run + Fine-tune per-stage quantities afterward on the run itself. + + + + Template + value={startTemplateId} onValueChange={setStartTemplateId}> + + + + + {startableTemplates.map((t) => ( + + {t.name} · {t.code} + + ))} + + + + + 0)}> + Target quantity + setTargetQty(e.target.value)} + placeholder="e.g. 200" + /> + + + + Warehouse + value={startWarehouseId} onValueChange={(v) => { setStartWarehouseId(v); setOutputBinId(null) }}> + + + + + {warehouses.map((w) => ( + + {w.name} · {w.code} + + ))} + + + + + + Output bin (optional) + value={outputBinId} onValueChange={setOutputBinId}> + + + + + {bins.map((b) => ( + + {b.code} + {b.binType ? ` · ${b.binType}` : ""} + + ))} + + + + + {startTemplate && targetQtyNum > 0 && ( +
+ Every stage's planned inputs and outputs are scaled from the template's per-batch figures against + this target. The authoritative numbers come back with the run and stay editable until each stage starts. +
+ )} + + +
+
+ + +
+
+
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search doc no…" + className="h-14 w-full pl-11 text-base" + aria-label="Search runs" + /> +
+ + value={templateId} + onValueChange={(v) => { setTemplateId(v); setPage(1) }} + > + + + + + {templates.map((t) => ( + {t.name} + ))} + + + + value={warehouseId} + onValueChange={(v) => { setWarehouseId(v); setPage(1) }} + > + + + + + {warehouses.map((w) => ( + {w.name} + ))} + + + value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}> + + + + + All statuses + In Progress + Completed + Cancelled + + +
+ + {hasFilters && ( + + )} + +
+ +
+ + {loadError && ( +
{loadError}
+ )} + + {runs === null ? ( +
+ + + +
+ ) : runs.length === 0 ? ( +
+ +

+ {hasFilters ? "No runs match your search/filter." : "No runs yet."} +

+
+ ) : ( + <> +
+ {runs.map((r) => { + const warehouse = warehouses.find((w) => w.warehouseId === r.warehouseId) + return ( + + ) + })} +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx new file mode 100644 index 0000000..f212906 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/AnnotationNodes.tsx @@ -0,0 +1,137 @@ +import { memo, useRef } from "react" +import { NodeResizer, type NodeProps } from "@xyflow/react" +import { RotateCw, X } from "lucide-react" + +import { AnnotationData } from "./types" + +type AnnotationNodeData = AnnotationData & { + onLabelChange?: (label: string) => void + onRotationChange?: (rotation: number) => void + onDelete?: () => void +} + +// `className` supplies its own position utility (e.g. "absolute -top-2.5 -right-2.5" or +// "static") — not baked in here, so callers that already sit inside a positioned flex +// row (LineNode's rotate/delete pair) aren't fighting a hardcoded `absolute`. +function DeleteHandle({ onDelete, className }: { onDelete?: () => void; className?: string }) { + return ( + + ) +} + +/** + * Free-floating group/label box. Purely visual — no Handles, so it can never be an edge + * endpoint, and it's excluded from every graph check (see StageNode for the real stage card). + * Rendered behind stage nodes: the page prepends new boxes to the nodes array, and React + * Flow paints later array entries on top. + */ +function BoxNode({ data, selected }: NodeProps & { data: AnnotationNodeData }) { + return ( +
+ + {selected && data.onDelete && } + data.onLabelChange?.(e.target.value)} + placeholder="Group label…" + className="nodrag m-2 w-[calc(100%-1rem)] rounded-md bg-transparent px-1.5 py-1 text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60 focus:bg-card" + /> +
+ ) +} + +/** + * Thin resizable divider bar, optionally labeled (e.g. "Phase 1"), and rotatable by dragging + * the small handle that appears above it once selected. The resize outline/handles and the + * rotate handle itself rotate together with the bar — they all live in one rotated wrapper — + * so the selection box always matches the bar's visual angle. Note: NodeResizer computes its + * drag deltas in unrotated screen space, so resizing while significantly rotated will feel a + * little off; acceptable here since this is a lightweight annotation, not precision CAD. + * `wrapperRef` (the outer, unrotated element) is what the rotate math measures from, so the + * center point stays stable regardless of the current angle. + */ +function LineNode({ data, selected }: NodeProps & { data: AnnotationNodeData }) { + const wrapperRef = useRef(null) + const rotation = data.rotation ?? 0 + + return ( +
+
+ + + {selected && (data.onRotationChange || data.onDelete) && ( +
+ {data.onRotationChange && ( + + )} + {data.onDelete && } +
+ )} + +
+
+
+
+ + data.onLabelChange?.(e.target.value)} + placeholder="Label (optional)" + className="nodrag absolute -bottom-6 left-1/2 w-24 -translate-x-1/2 rounded-md bg-transparent px-1 text-center text-xs text-muted-foreground outline-none placeholder:text-muted-foreground/50 focus:bg-card" + /> +
+ ) +} + +export default memo(BoxNode) +export const LineNodeComponent = memo(LineNode) diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx new file mode 100644 index 0000000..825740d --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageEditorPanel.tsx @@ -0,0 +1,439 @@ +"use client" + +import { Plus, Trash2, X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { CustomFieldType, StageInputSource } from "@/types/production" +import { ItemListItem, Uom } from "@/types/master-data" +import { + BuilderFieldDef, + BuilderInput, + BuilderOutput, + StageNodeData, + newKey, + newLocalId, +} from "./types" + +import { Button } from "@/components/ui/button" +import { Field, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" + +function slugify(label: string) { + return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") +} + +const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"] +const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"] + +export interface UpstreamOutputOption { + stageKey: string + stageName: string + outputKey: string + outputName: string +} + +/** One row per input/output quantity — UOM select plus qty, used three times below. */ +function QtyRow({ + qty, + uomId, + uoms, + readOnly, + onQtyChange, + onUomChange, +}: { + qty: number + uomId: number | null + uoms: Uom[] + readOnly: boolean + onQtyChange: (qty: number) => void + onUomChange: (uomId: number) => void +}) { + return ( +
+ onQtyChange(Number(e.target.value) || 0)} + className="h-8 text-sm" + placeholder="Qty per batch" + aria-label="Quantity per batch" + /> + value={uomId} onValueChange={(v) => v && onUomChange(v)}> + + + + + {uoms.map((u) => ( + + {u.name} + + ))} + + +
+ ) +} + +export function StageEditorPanel({ + data, + isTerminal, + upstreamOptions, + items, + uoms, + readOnly, + onChange, + onDelete, + onClose, +}: { + data: StageNodeData + isTerminal: boolean + upstreamOptions: UpstreamOutputOption[] + items: ItemListItem[] + uoms: Uom[] + readOnly: boolean + onChange: (patch: Partial) => void + onDelete: () => void + onClose: () => void +}) { + function updateInput(localId: string, patch: Partial) { + onChange({ inputs: data.inputs.map((i) => (i.localId === localId ? { ...i, ...patch } : i)) }) + } + function addInput() { + onChange({ + inputs: [ + ...data.inputs, + { localId: newLocalId(), source: "Stock", itemId: null, fromOutputKey: null, uomId: null, qtyPerBatch: 1 }, + ], + }) + } + function removeInput(localId: string) { + onChange({ inputs: data.inputs.filter((i) => i.localId !== localId) }) + } + + /** + * Switching source clears the other side's field. Leaving a stale `itemId` on an Upstream + * input (or a stale `fromOutputKey` on a Stock one) is a 422 GRAPH_INPUT_SOURCE_INVALID — + * the validator rejects an input that carries both. + */ + function changeInputSource(localId: string, source: StageInputSource) { + updateInput(localId, source === "Stock" ? { source, fromOutputKey: null } : { source, itemId: null }) + } + + /** Default the UOM to the item's base unit — right most of the time, still overridable. */ + function pickInputItem(input: BuilderInput, itemId: number) { + const item = items.find((i) => i.itemId === itemId) + updateInput(input.localId, { itemId, uomId: input.uomId ?? item?.baseUomId ?? null }) + } + + function updateOutput(key: string, patch: Partial) { + onChange({ outputs: data.outputs.map((o) => (o.key === key ? { ...o, ...patch } : o)) }) + } + function addOutput() { + onChange({ + outputs: [...data.outputs, { key: newKey(), itemId: null, name: "", uomId: null, qtyPerBatch: 1 }], + }) + } + function removeOutput(key: string) { + onChange({ outputs: data.outputs.filter((o) => o.key !== key) }) + } + + /** The terminal output's name mirrors the finished item, so the two can't drift apart. */ + function pickOutputItem(output: BuilderOutput, itemId: number) { + const item = items.find((i) => i.itemId === itemId) + updateOutput(output.key, { + itemId, + name: item?.name ?? output.name, + uomId: output.uomId ?? item?.baseUomId ?? null, + }) + } + + function updateField(localId: string, patch: Partial) { + onChange({ + fieldDefs: data.fieldDefs.map((f) => { + if (f.localId !== localId) return f + const next = { ...f, ...patch } + if (patch.label !== undefined) next.key = slugify(patch.label) || f.key + return next + }), + }) + } + function addField() { + onChange({ + fieldDefs: [ + ...data.fieldDefs, + { localId: newLocalId(), key: "", label: "", type: "Text", options: [], required: false }, + ], + }) + } + function removeField(localId: string) { + onChange({ fieldDefs: data.fieldDefs.filter((f) => f.localId !== localId) }) + } + + return ( +
+
+

Stage editor

+ +
+ +
+ + Name + onChange({ name: e.target.value })} placeholder="e.g. Welding" /> + + + + Role label + onChange({ roleLabel: e.target.value })} + placeholder="e.g. QA" + list="role-suggestions" + /> + + {ROLE_SUGGESTIONS.map((r) => ( + + + + + Estimated minutes + onChange({ estimatedMinutes: Number(e.target.value) || 0 })} + /> + + + {/* Inputs */} +
+
+

Inputs

+ {!readOnly && ( + + )} +
+
+ {data.inputs.length === 0 &&

No inputs yet.

} + {data.inputs.map((input) => ( +
+
+ + value={input.source} + onValueChange={(v) => v && changeInputSource(input.localId, v)} + > + + + + + Stock + Upstream + + + {!readOnly && ( + + )} +
+ +
+ {input.source === "Stock" ? ( + + value={input.itemId} + onValueChange={(v) => v && pickInputItem(input, v)} + > + + + + + {items.map((i) => ( + + {i.name} · {i.sku} + + ))} + + + ) : ( + + value={input.fromOutputKey} + onValueChange={(v) => v && updateInput(input.localId, { fromOutputKey: v })} + > + + + + + {upstreamOptions.map((o) => ( + + {o.stageName} — {o.outputName} + + ))} + + + )} + + updateInput(input.localId, { qtyPerBatch })} + onUomChange={(uomId) => updateInput(input.localId, { uomId })} + /> +
+
+ ))} +
+
+ + {/* Outputs */} +
+
+

+ Outputs{isTerminal && (terminal — finished good)} +

+ {!readOnly && ( + + )} +
+
+ {data.outputs.length === 0 &&

No outputs yet.

} + {data.outputs.map((output) => ( +
+
+ {isTerminal ? ( + value={output.itemId} onValueChange={(v) => v && pickOutputItem(output, v)}> + + + + + {items.map((i) => ( + + {i.name} · {i.sku} + + ))} + + + ) : ( + updateOutput(output.key, { name: e.target.value })} + placeholder="Output name (work in progress)" + className="h-8 flex-1 text-sm" + /> + )} + {!readOnly && ( + + )} +
+ updateOutput(output.key, { qtyPerBatch })} + onUomChange={(uomId) => updateOutput(output.key, { uomId })} + /> +
+ ))} +
+
+ + {/* Custom fields */} +
+
+

Custom fields

+ {!readOnly && ( + + )} +
+
+ {data.fieldDefs.length === 0 &&

No custom fields.

} + {data.fieldDefs.map((field) => ( +
+
+ updateField(field.localId, { label: e.target.value })} + placeholder="Label" + className="h-8 flex-1 text-sm" + /> + {!readOnly && ( + + )} +
+ {field.key &&

key: {field.key}

} +
+ + value={field.type} + onValueChange={(v) => v && updateField(field.localId, { type: v })} + > + + + + + {FIELD_TYPES.map((t) => ( + {t} + ))} + + +
+ updateField(field.localId, { required: checked })} + /> + Required +
+
+ {field.type === "Select" && ( + updateField(field.localId, { options: e.target.value.split(",").map((s) => s.trim()).filter(Boolean) })} + placeholder="Options, comma separated" + className="mt-2 h-8 text-sm" + /> + )} +
+ ))} +
+
+ + {!readOnly && ( + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx new file mode 100644 index 0000000..4c24214 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/StageNode.tsx @@ -0,0 +1,68 @@ +import { memo } from "react" +import { Handle, Position, type NodeProps } from "@xyflow/react" +import { ArrowRight, X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { StageNodeData } from "./types" + +/** + * Stage card (docs/21-FRONTEND-PHASE2.md §2): name, role label chip, estimated minutes, + * input count → output count. Selecting it opens the stage editor panel (handled by the + * parent page via onNodeClick, not here) — the same delete affordance also lives there + * ("Delete stage" button); this inline × is a faster path once a stage is already selected. + */ +function StageNode({ data, selected }: NodeProps & { data: StageNodeData }) { + const disconnected = data.disconnected + + return ( +
+ + + {selected && data.onDelete && ( + + )} + +
+

{data.name || "Untitled stage"}

+ {data.roleLabel && ( + + {data.roleLabel} + + )} +
+ +

{data.estimatedMinutes} min

+ +
+ {data.inputs.length} in + + {data.outputs.length} out +
+ + {disconnected &&

Disconnected

} + + +
+ ) +} + +export default memo(StageNode) diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx new file mode 100644 index 0000000..8a19847 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/page.tsx @@ -0,0 +1,813 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useParams, useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { + addEdge, + applyEdgeChanges, + applyNodeChanges, + Background, + Controls, + MiniMap, + ReactFlow, + type Connection, + type Edge, + type Node, + type NodeChange, + type EdgeChange, + type NodeMouseHandler, +} from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { useTheme } from "next-themes" +import { AlertTriangle, ArrowLeft, Lock, Minus, Plus, Save, Square } from "lucide-react" + +import { cn } from "@/lib/utils" +import { productionTemplatesApi } from "@/lib/api/production-templates" +import { itemsApi } from "@/lib/api/items" +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage } from "@/lib/error-map" +import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production" +import { ItemListItem, Uom } from "@/types/master-data" + +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Field, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" +import StageNode from "./StageNode" +import AnnotationBoxNode, { LineNodeComponent } from "./AnnotationNodes" +import { StageEditorPanel, type UpstreamOutputOption } from "./StageEditorPanel" +import { AnnotationData, StageNodeData, newKey, newLocalId } from "./types" + +const nodeTypes = { stage: StageNode, box: AnnotationBoxNode, line: LineNodeComponent } + +const DEFAULT_BOX = { width: 320, height: 220 } +const DEFAULT_LINE = { width: 220, height: 4 } + +/** + * Server graph -> React Flow. + * + * A stage's node id **is** its server key, which is why edges need no translation in either + * direction: `edge.source`/`edge.target` are already the `parentKey`/`childKey` the save + * payload wants. Annotations come first in the array because React Flow paints later entries + * on top, and a grouping box belongs behind the stage cards it groups. + */ +function graphToFlow(graph: ProductionTemplateGraph): { nodes: Node[]; edges: Edge[] } { + const annotations: Node[] = graph.annotations.map((a) => ({ + id: `ann-${newLocalId()}`, + type: a.kind, + position: { x: a.posX, y: a.posY }, + width: a.width, + height: a.height, + data: { label: a.label ?? "", rotation: a.rotation ?? undefined } satisfies AnnotationData, + })) + + const stages: Node[] = graph.stages.map((s) => ({ + id: s.key, + type: "stage", + position: { x: s.posX, y: s.posY }, + data: { + name: s.name, + roleLabel: s.roleLabel ?? "", + estimatedMinutes: s.estimatedMinutes, + inputs: s.inputs.map((i) => ({ + localId: newLocalId(), + source: i.source, + itemId: i.itemId, + fromOutputKey: i.fromOutputKey, + uomId: i.uomId, + qtyPerBatch: i.qtyPerBatch, + })), + outputs: s.outputs.map((o) => ({ + key: o.key, + itemId: o.itemId, + name: o.name, + uomId: o.uomId, + qtyPerBatch: o.qtyPerBatch, + })), + fieldDefs: s.fieldDefs.map((f) => ({ + localId: newLocalId(), + key: f.key, + label: f.label, + type: f.type, + options: f.options ?? [], + required: f.required, + })), + } satisfies StageNodeData, + })) + + const edges: Edge[] = graph.edges.map((e) => ({ + id: `e${e.edgeId}`, + source: e.parentKey, + target: e.childKey, + })) + + return { nodes: [...annotations, ...stages], edges } +} + +/** Kahn's algorithm — true when the toposort can't reach every node, i.e. there's a cycle. */ +function detectCycle(nodes: Node[], edges: Edge[]): boolean { + const inDegree = new Map(nodes.map((n) => [n.id, 0])) + for (const e of edges) inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1) + const queue = nodes.filter((n) => inDegree.get(n.id) === 0).map((n) => n.id) + let visited = 0 + while (queue.length > 0) { + const id = queue.shift()! + visited++ + for (const e of edges.filter((e) => e.source === id)) { + const next = (inDegree.get(e.target) ?? 0) - 1 + inDegree.set(e.target, next) + if (next === 0) queue.push(e.target) + } + } + return visited !== nodes.length +} + +/** + * Best-effort mapping from a `422 GRAPH_*` detail back to the stages it names. + * + * The validator's messages quote real stage names ("...contains a cycle involving: Cut frame, + * Assemble"), so a substring match finds them without the server having to return keys. It is + * deliberately advisory: the full message is always shown in the banner too, so a stage renamed + * to something ambiguous costs a highlight, never the explanation. + */ +function stagesNamedIn(detail: string | undefined, stageNodes: Node[]): Set { + if (!detail) return new Set() + const named = stageNodes.filter((n) => { + const name = (n.data as StageNodeData).name.trim() + return name.length > 0 && detail.includes(name) + }) + return new Set(named.map((n) => n.id)) +} + +export default function TemplateBuilderPage() { + const params = useParams<{ id: string }>() + const searchParams = useSearchParams() + const router = useRouter() + const { resolvedTheme } = useTheme() + + // "new" is a draft that exists only in this page until the first successful Save. A template + // cannot be created from a name alone — the server requires at least one stage and a terminal + // output naming a real finished item (FR-MFG-02/05) — so there is nothing to POST up front. + const isNew = params.id === "new" + const templateId = Number(params.id) + + const [graph, setGraph] = useState(null) + const [etag, setEtag] = useState(null) + const [loadError, setLoadError] = useState(null) + + // Derived rather than its own state: a draft is ready immediately, and a saved template is + // ready as soon as the GET resolves either way. + const loaded = isNew || graph !== null || loadError !== null + + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [description, setDescription] = useState("") + + const [nodes, setNodes] = useState([]) + const [edges, setEdges] = useState([]) + const [selectedNodeId, setSelectedNodeId] = useState(null) + + const [items, setItems] = useState([]) + const [uoms, setUoms] = useState([]) + + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + const [conflict, setConflict] = useState(false) + const [lockedByServer, setLockedByServer] = useState(false) + const [focusedKeys, setFocusedKeys] = useState>(new Set()) + const [togglingStatus, setTogglingStatus] = useState(false) + + // `lockedByServer` covers the edit-lock TOCTOU: a run can start between our GET and our PUT, + // in which case the 409 is the first we hear of it (FR-MFG-06). + const activeRunCount = graph?.activeRunCount ?? 0 + const locked = activeRunCount > 0 || lockedByServer + + // `resolvedTheme` is unknown on the server (and on the client's first paint, before + // next-themes reads localStorage), so `colorMode` would differ between the SSR markup and the + // first client render — the same hydration-mismatch class theme-toggle.tsx guards against. + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + const applyGraph = useCallback((data: ProductionTemplateGraph, tag: string | null) => { + const flow = graphToFlow(data) + setGraph(data) + setEtag(tag) + setCode(data.code) + setName(data.name) + setDescription(data.description ?? "") + setNodes(flow.nodes) + setEdges(flow.edges) + setSelectedNodeId(null) + setConflict(false) + setLockedByServer(false) + setFocusedKeys(new Set()) + setSaveError(null) + }, []) + + const load = useCallback(() => { + setLoadError(null) + productionTemplatesApi + .get(templateId) + .then(({ data, etag: tag }) => applyGraph(data, tag)) + .catch((err) => setLoadError(errorMessage(err))) + }, [templateId, applyGraph]) + + useEffect(() => { + if (isNew) { + // Seeded from the overview's "New Template" dialog. + setCode(searchParams.get("code") ?? "") + setName(searchParams.get("name") ?? "") + return + } + if (Number.isFinite(templateId)) load() + // searchParams is read once for the draft seed; re-running on every query change would + // overwrite what the user has typed since. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isNew, templateId, load]) + + useEffect(() => { + Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })]) + .then(([itemRes, uomRes]) => { + setItems(itemRes.items) + setUoms(uomRes.items) + }) + .catch((err) => toast.error("Could not load items and UOMs", errorMessage(err))) + }, []) + + const onNodesChange = useCallback( + (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), + [] + ) + const onEdgesChange = useCallback( + (changes: EdgeChange[]) => setEdges((eds) => applyEdgeChanges(changes, eds)), + [] + ) + const onConnect = useCallback( + (connection: Connection) => { + if (locked) return + if (connection.source === connection.target) { + toast.error("Can't connect a stage to itself") + return + } + const duplicate = edges.some((e) => e.source === connection.source && e.target === connection.target) + if (duplicate) { + toast.error("These stages are already connected") + return + } + setEdges((eds) => addEdge(connection, eds)) + }, + [edges, locked] + ) + + const onNodeClick: NodeMouseHandler = useCallback((_, node) => setSelectedNodeId(node.id), []) + const onPaneClick = useCallback(() => setSelectedNodeId(null), []) + + // Guarded here, not just by hiding the toolbar/panel controls: `elementsSelectable` stays + // true even when locked (so a locked template can still be inspected), and these two setters + // go straight to setNodes/setEdges — they don't route through onNodesChange, which is what + // actually gets set to `undefined` when locked. + function updateNodeData(nodeId: string, patch: Partial | Partial) { + if (locked) return + setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...patch } } : n))) + } + + function addStage() { + const id = newKey() + const existingStages = nodes.filter((n) => n.type === "stage") + const maxX = existingStages.reduce((max, n) => Math.max(max, n.position.x), 0) + const y = existingStages.length > 0 ? existingStages[existingStages.length - 1].position.y : 120 + setNodes((nds) => [ + ...nds, + { + id, + type: "stage", + position: { x: existingStages.length > 0 ? maxX + 280 : 40, y }, + data: { + name: "New stage", + roleLabel: "", + estimatedMinutes: 15, + inputs: [], + outputs: [], + fieldDefs: [], + } satisfies StageNodeData, + }, + ]) + setSelectedNodeId(id) + } + + // Generic across every node type — stage cards, boxes and lines all use this (the inline × + // buttons on the nodes themselves, plus the stage editor's "Delete stage"). Box/line nodes + // never have edges, so the edge filter is a no-op for them, not a special case. + function deleteNode(nodeId: string) { + if (locked) return + setNodes((nds) => nds.filter((n) => n.id !== nodeId)) + setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId)) + setSelectedNodeId((id) => (id === nodeId ? null : id)) + } + + function addAnnotation(kind: "box" | "line") { + const size = kind === "box" ? DEFAULT_BOX : DEFAULT_LINE + setNodes((nds) => [ + { + id: `ann-${newLocalId()}`, + type: kind, + position: kind === "box" ? { x: 40, y: 40 } : { x: 60, y: 300 }, + ...size, + data: { label: "" } satisfies AnnotationData, + }, + ...nds, + ]) + } + + // React Flow's built-in keyboard delete (Backspace/Delete on a selected node) goes through + // this callback, not through deleteNode() above — stage edges need cleaning up either way. + const onNodesDelete = useCallback((deleted: Node[]) => { + const deletedIds = new Set(deleted.map((n) => n.id)) + setEdges((eds) => eds.filter((e) => !deletedIds.has(e.source) && !deletedIds.has(e.target))) + }, []) + + // Boxes and lines are pure annotations — never part of the stage graph, so every graph check + // below operates on stage nodes only (docs/21 §2 "Client-side graph checks (UX only — server + // re-validates on save)"). + const stageNodes = useMemo(() => nodes.filter((n) => n.type === "stage"), [nodes]) + + const analysis = useMemo(() => { + const terminalIds = new Set(stageNodes.filter((n) => !edges.some((e) => e.source === n.id)).map((n) => n.id)) + const entryIds = new Set(stageNodes.filter((n) => !edges.some((e) => e.target === n.id)).map((n) => n.id)) + const disconnectedIds = new Set( + stageNodes.length > 1 + ? stageNodes.filter((n) => !edges.some((e) => e.source === n.id || e.target === n.id)).map((n) => n.id) + : [] + ) + const hasCycle = detectCycle(stageNodes, edges) + return { terminalIds, entryIds, disconnectedIds, hasCycle } + }, [stageNodes, edges]) + + /** Output keys a stage may legally draw from — its *direct* parents' outputs (FR-MFG-04). */ + const allowedUpstreamKeys = useCallback( + (stageId: string) => { + const parentIds = new Set(edges.filter((e) => e.target === stageId).map((e) => e.source)) + return new Set( + nodes + .filter((n) => n.type === "stage" && parentIds.has(n.id)) + .flatMap((n) => (n.data as StageNodeData).outputs.map((o) => o.key)) + ) + }, + [edges, nodes] + ) + + // Clear stale Upstream references after an edge is deleted, with a warning toast — docs/21 §2 + // "re-check after edge deletions and clear broken references with a warning toast". + useEffect(() => { + for (const node of stageNodes) { + const data = node.data as StageNodeData + const allowed = allowedUpstreamKeys(node.id) + const stale = data.inputs.filter( + (i) => i.source === "Upstream" && i.fromOutputKey && !allowed.has(i.fromOutputKey) + ) + if (stale.length > 0) { + updateNodeData(node.id, { + inputs: data.inputs.map((i) => (stale.includes(i) ? { ...i, fromOutputKey: null } : i)), + }) + toast.warning("Input reference cleared", `"${data.name}" drew from a stage that no longer feeds it.`) + } + } + // Only re-run when the edge set changes — re-running on every node data edit would loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [edges]) + + const issues = useMemo(() => { + const list: string[] = [] + if (!code.trim()) list.push("Code is required.") + if (!name.trim()) list.push("Name is required.") + if (stageNodes.length === 0) list.push("Add at least one stage.") + + if (analysis.hasCycle) list.push("Cycle detected — stages must form a one-directional flow.") + if (stageNodes.length > 0 && analysis.terminalIds.size !== 1) { + list.push( + analysis.terminalIds.size === 0 + ? "No terminal stage — connect stages so the line converges to a single final stage." + : `${analysis.terminalIds.size} terminal stages found — connect stages so the line converges to a single final stage.` + ) + } + if (stageNodes.length > 0 && analysis.entryIds.size === 0) { + list.push("No entry stage — at least one stage must have no inputs from other stages.") + } + if (analysis.disconnectedIds.size > 0) { + const names = stageNodes.filter((n) => analysis.disconnectedIds.has(n.id)).map((n) => (n.data as StageNodeData).name) + list.push(`Disconnected stage${names.length > 1 ? "s" : ""}: ${names.join(", ")}.`) + } + + // Field-completeness. These mirror the server's own requirements, and they are what make + // the non-null assertions in buildRequest() below sound — a row is never sent half-filled. + for (const node of stageNodes) { + const data = node.data as StageNodeData + const label = data.name.trim() || "Untitled stage" + const isTerminal = analysis.terminalIds.has(node.id) + + if (!data.name.trim()) list.push("Every stage needs a name.") + + data.inputs.forEach((input, i) => { + const where = `Input ${i + 1} of "${label}"` + if (input.source === "Stock" && input.itemId === null) list.push(`${where} needs an item.`) + if (input.source === "Upstream" && !input.fromOutputKey) list.push(`${where} needs an upstream output.`) + if (input.uomId === null) list.push(`${where} needs a UOM.`) + if (input.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`) + }) + + data.outputs.forEach((output, i) => { + const where = `Output ${i + 1} of "${label}"` + if (!isTerminal && !output.name.trim()) list.push(`${where} needs a name.`) + if (output.uomId === null) list.push(`${where} needs a UOM.`) + if (output.qtyPerBatch <= 0) list.push(`${where} needs a quantity greater than zero.`) + }) + + if (isTerminal) { + if (data.outputs.length !== 1) { + list.push(`Terminal stage "${label}" must have exactly one output (the finished good).`) + } else if (data.outputs[0].itemId === null) { + list.push(`Terminal stage "${label}" needs its output linked to a finished-good item.`) + } + } + } + + return list + }, [analysis, stageNodes, code, name]) + + const selectedNode = stageNodes.find((n) => n.id === selectedNodeId) + const upstreamOptions: UpstreamOutputOption[] = useMemo(() => { + if (!selectedNode) return [] + const parentIds = edges.filter((e) => e.target === selectedNode.id).map((e) => e.source) + return parentIds.flatMap((parentId) => { + const parent = nodes.find((n) => n.id === parentId) + if (!parent) return [] + const parentData = parent.data as StageNodeData + return parentData.outputs.map((o) => ({ + stageKey: parent.id, + stageName: parentData.name, + outputKey: o.key, + outputName: o.name || "(unnamed output)", + })) + }) + }, [selectedNode, edges, nodes]) + + const displayNodes = useMemo( + () => + nodes.map((n) => + n.type === "stage" + ? { + ...n, + data: { + ...n.data, + disconnected: analysis.disconnectedIds.has(n.id), + focused: focusedKeys.has(n.id), + onDelete: locked ? undefined : () => deleteNode(n.id), + }, + } + : { + ...n, + data: { + ...n.data, + onLabelChange: locked ? undefined : (label: string) => updateNodeData(n.id, { label }), + onRotationChange: locked ? undefined : (rotation: number) => updateNodeData(n.id, { rotation }), + onDelete: locked ? undefined : () => deleteNode(n.id), + }, + } + ), + [nodes, analysis.disconnectedIds, focusedKeys] // eslint-disable-line react-hooks/exhaustive-deps + ) + + function buildRequest(): SaveTemplateRequest { + const stages = stageNodes.map((n) => { + const data = n.data as StageNodeData + const isTerminal = analysis.terminalIds.has(n.id) + return { + key: n.id, + name: data.name.trim(), + roleLabel: data.roleLabel.trim() || null, + estimatedMinutes: data.estimatedMinutes, + posX: Math.round(n.position.x), + posY: Math.round(n.position.y), + fieldDefs: data.fieldDefs.map((f) => ({ + key: f.key, + label: f.label, + type: f.type, + options: f.type === "Select" ? f.options : null, + required: f.required, + })), + inputs: data.inputs.map((i) => ({ + source: i.source, + itemId: i.source === "Stock" ? i.itemId : null, + fromOutputKey: i.source === "Upstream" ? i.fromOutputKey : null, + uomId: i.uomId!, + qtyPerBatch: i.qtyPerBatch, + })), + // Only the terminal stage's output may name an item (FR-MFG-05). A stage that *was* + // terminal and then got a child keeps its picked itemId in local state with the item + // field no longer rendered, so dropping it here is the only way the user can recover — + // an issue-list message about an invisible field would be unactionable. + outputs: data.outputs.map((o) => ({ + key: o.key, + itemId: isTerminal ? o.itemId : null, + name: o.name.trim(), + uomId: o.uomId!, + qtyPerBatch: o.qtyPerBatch, + })), + } + }) + + const annotations: CanvasAnnotation[] = nodes + .filter((n) => n.type === "box" || n.type === "line") + .map((n) => { + const data = n.data as AnnotationData + const fallback = n.type === "box" ? DEFAULT_BOX : DEFAULT_LINE + return { + kind: n.type as "box" | "line", + posX: Math.round(n.position.x), + posY: Math.round(n.position.y), + // `width`/`height` are set on creation and updated by NodeResizer; `measured` is what + // React Flow fills in after layout for nodes sized purely by CSS. + width: Math.round(n.width ?? n.measured?.width ?? fallback.width), + height: Math.round(n.height ?? n.measured?.height ?? fallback.height), + label: data.label.trim() || null, + rotation: data.rotation ?? null, + } + }) + + return { + code: code.trim(), + name: name.trim(), + description: description.trim() || null, + stages, + edges: edges.map((e) => ({ parentKey: e.source, childKey: e.target })), + annotations, + } + } + + async function handleSave() { + if (issues.length > 0) { + toast.error("Can't save yet", `${issues.length} issue${issues.length > 1 ? "s" : ""} to fix first.`) + return + } + + setSaving(true) + setSaveError(null) + setFocusedKeys(new Set()) + + try { + if (isNew) { + const result = await productionTemplatesApi.create(buildRequest()) + toast.success("Template created", `${result.data.code} — ${result.data.name}`) + // Swap the draft URL for the real one. The load effect re-runs on the new id and + // rehydrates from the server, so keys minted here are replaced by real ones. + router.replace(`/dashboard/production/templates/${result.data.templateId}`) + return + } + + if (!etag) return + const result = await productionTemplatesApi.update(templateId, buildRequest(), etag) + applyGraph(result.data, result.etag) + toast.success("Template saved", `${result.data.code} — ${result.data.stages.length} stage(s)`) + } catch (err) { + const errorCode = (err as { code?: string })?.code + const detail = (err as { detail?: string })?.detail + + if (errorCode === "CONCURRENCY_CONFLICT" || errorCode === "PRECONDITION_REQUIRED") { + setConflict(true) + setSaveError(errorMessage(err)) + return + } + if (errorCode === "TEMPLATE_IN_USE") { + // A run started between our GET and this PUT. Lock the canvas rather than reloading, + // so nothing the user just drew is thrown away without them seeing why. + setLockedByServer(true) + setSaveError(errorMessage(err)) + return + } + if (errorCode?.startsWith("GRAPH_") || errorCode === "TERMINAL_OUTPUT_ITEM_REQUIRED") { + setFocusedKeys(stagesNamedIn(detail, stageNodes)) + } + setSaveError(errorMessage(err)) + toast.error("Could not save template", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleToggleStatus() { + if (!graph) return + const next: TemplateStatus = graph.status === "Active" ? "Inactive" : "Active" + setTogglingStatus(true) + try { + await productionTemplatesApi.updateStatus(templateId, next) + // PATCH /status bumps the row's xmin, which invalidates the ETag we hold. Re-read it (and + // only it) so an unsaved canvas edit can still be saved afterwards — a full reload here + // would silently discard the user's work. + const refreshed = await productionTemplatesApi.get(templateId) + setEtag(refreshed.etag) + setGraph((g) => + g ? { ...g, status: refreshed.data.status, activeRunCount: refreshed.data.activeRunCount } : g + ) + toast.success(next === "Active" ? "Template activated" : "Template deactivated") + } catch (err) { + toast.error("Could not update status", errorMessage(err)) + } finally { + setTogglingStatus(false) + } + } + + if (!loaded) { + return ( +
+ + +
+ ) + } + + if (loadError) { + return ( +
+
{loadError}
+ + Back to templates + +
+ ) + } + + return ( +
+
+
+ + + +
+
+

{name || "Untitled template"}

+ {graph ? ( + + {graph.status} + + ) : ( + + Unsaved draft + + )} +
+

+ {stageNodes.length} stage{stageNodes.length === 1 ? "" : "s"} · {edges.length} connection + {edges.length === 1 ? "" : "s"} +

+
+
+
+ {!locked && ( + <> + + + + + )} + {graph && ( + + )} + {!locked && ( + + )} +
+
+ +
+ + Code + setCode(e.target.value)} placeholder="PT-CHAIR" className="h-10" /> + + + Name + setName(e.target.value)} placeholder="Aluminium Frame Assembly" className="h-10" /> + + + Description + setDescription(e.target.value)} + placeholder="Optional" + className="h-10" + /> + +
+ + {locked && ( +
+ + {activeRunCount > 0 + ? `Template locked — ${activeRunCount} run${activeRunCount === 1 ? "" : "s"} in progress. It can be viewed but not edited until they finish.` + : "Template locked — a run started while you were editing, so this template can no longer be changed."} +
+ )} + + {conflict && ( +
+ +
+ {saveError ?? "This template was changed by someone else."} Reload before retrying. + +
+
+ )} + + {saveError && !conflict && ( +
+ + {saveError} +
+ )} + + {issues.length > 0 && ( +
+ {issues.map((issue, i) => ( +
+ + {issue} +
+ ))} +
+ )} + +
+
+ {mounted && ( + + + + + + )} +
+ + {selectedNode && ( + updateNodeData(selectedNode.id, patch)} + onDelete={() => deleteNode(selectedNode.id)} + onClose={() => setSelectedNodeId(null)} + /> + )} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts b/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts new file mode 100644 index 0000000..3825fab --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/[id]/types.ts @@ -0,0 +1,87 @@ +// Canvas builder state (docs/21-FRONTEND-PHASE2.md §2), shaped for editing rather than for +// the wire. `page.tsx` converts between these and the real contract in `types/production.ts`. +// +// Two things the API shapes can't express and the canvas needs: +// +// * **Stable React list keys.** Stage inputs and custom fields have no client-facing key in +// the contract (only outputs do, because Upstream inputs reference them by key). Rendering +// them by array index would make React reuse the wrong when a row is removed, so +// every editable row carries a throwaway `localId` that is stripped on save. +// * **Half-filled rows.** `uomId` is `number | null` here but `number` on the wire: a row the +// user just added has nothing picked yet. `page.tsx` blocks the save until every one is set, +// which is what makes the non-null assertions in its payload builder sound. +// +// A stage's identity IS its React Flow node id, which is its server key — the stringified +// stage id, or `tmp-` for a stage drawn in this session. That is why edges need no +// translation on save: `edge.source`/`edge.target` are already `parentKey`/`childKey`. + +import { CustomFieldType, StageInputSource } from "@/types/production" + +/** `tmp-` prefixed so the server can tell a newly drawn stage/output from one it already has. */ +export function newKey(): string { + return `tmp-${crypto.randomUUID()}` +} + +/** Render-only identity for rows the contract keys by position. Never sent. */ +export function newLocalId(): string { + return crypto.randomUUID() +} + +export interface BuilderInput { + localId: string + source: StageInputSource + /** Stock inputs only. */ + itemId: number | null + /** Upstream inputs only — an output key belonging to a *direct* parent stage. */ + fromOutputKey: string | null + uomId: number | null + qtyPerBatch: number +} + +export interface BuilderOutput { + /** Server output id as a string, or `tmp-`. Upstream inputs reference this. */ + key: string + /** Terminal stage only — the finished good. Must stay null on WIP outputs (FR-MFG-05). */ + itemId: number | null + name: string + uomId: number | null + qtyPerBatch: number +} + +export interface BuilderFieldDef { + localId: string + /** Slugified from `label`; the run's `fieldValues` are keyed by it (FR-MFG-07). */ + key: string + label: string + type: CustomFieldType + /** Only sent when `type` is `Select`. */ + options: string[] + required: boolean +} + +export interface StageNodeData extends Record { + name: string + roleLabel: string + estimatedMinutes: number + inputs: BuilderInput[] + outputs: BuilderOutput[] + fieldDefs: BuilderFieldDef[] + /** Recomputed by the page on every graph change, not user-editable — no in/out edges at all. */ + disconnected?: boolean + /** Set when a server `422 GRAPH_*` named this stage, so the canvas can point at it. */ + focused?: boolean + /** Injected by the page at render time — deletes this node (and any edges touching it). */ + onDelete?: () => void +} + +/** + * Free-floating annotations — grouping boxes and divider lines. Purely visual: they carry no + * graph semantics (no ports, never part of the cycle/terminal/entry/disconnected checks or the + * save-blocking issue list), unlike "stage" nodes. Persisted verbatim in the template's + * `annotations` jsonb so a layout survives a reload. + */ +export interface AnnotationData extends Record { + label: string + /** Degrees, applied as a CSS rotation around the node's own center. Lines only. */ + rotation?: number +} diff --git a/Frontend/erp-system/app/dashboard/production/templates/page.tsx b/Frontend/erp-system/app/dashboard/production/templates/page.tsx new file mode 100644 index 0000000..6febe70 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/production/templates/page.tsx @@ -0,0 +1,313 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { ReactFlow, Background, Controls, type Edge, type Node, type NodeMouseHandler } from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { useTheme } from "next-themes" +import { LayoutTemplate, Plus, Search } from "lucide-react" + +import { productionTemplatesApi } from "@/lib/api/production-templates" +import { errorMessage } from "@/lib/error-map" +import { ProductionTemplateSummary, TemplateStatus } from "@/types/production" +import { PaginationMeta } from "@/types/common" +import { + LineHeaderNodeComponent, + LineStageNodeComponent, + type LineHeaderData, + type LineStageData, +} from "@/components/production/ProductionLineNodes" + +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" + +type StatusFilter = TemplateStatus | "All" + +const PAGE_SIZE = 25 + +const nodeTypes = { lineHeader: LineHeaderNodeComponent, lineStage: LineStageNodeComponent } + +const ROW_HEIGHT = 150 +const STAGE_START_X = 300 +const STAGE_GAP_X = 200 + +/** One row per template — its production line, header on the left, stages left to right. */ +function buildLinesGraph(templates: ProductionTemplateSummary[]): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [] + const edges: Edge[] = [] + + templates.forEach((t, row) => { + const y = row * ROW_HEIGHT + nodes.push({ + id: `h${t.templateId}`, + type: "lineHeader", + position: { x: 0, y }, + data: { + templateId: t.templateId, + code: t.code, + name: t.name, + status: t.status, + activeRunCount: t.activeRunCount, + } satisfies LineHeaderData, + draggable: false, + }) + + // Names come straight from the list projection, so the overview needs one request + // regardless of how many templates it shows. + t.stageNames.forEach((stageName, i) => { + const stageId = `s${t.templateId}-${i}` + nodes.push({ + id: stageId, + type: "lineStage", + position: { x: STAGE_START_X + i * STAGE_GAP_X, y: y + 22 }, + data: { templateId: t.templateId, name: stageName } satisfies LineStageData, + draggable: false, + }) + edges.push({ + id: `e-${stageId}`, + source: i === 0 ? `h${t.templateId}` : `s${t.templateId}-${i - 1}`, + target: stageId, + }) + }) + }) + + return { nodes, edges } +} + +export default function ProductionTemplatesPage() { + const router = useRouter() + const { resolvedTheme } = useTheme() + + // null = still loading (the codebase convention for "no data yet" vs "empty result"). + const [templates, setTemplates] = useState(null) + const [pagination, setPagination] = useState(null) + const [loadError, setLoadError] = useState(null) + + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [status, setStatus] = useState("All") + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [errors, setErrors] = useState<{ code?: string; name?: string }>({}) + + // Same hydration-mismatch guard as the builder canvas: colorMode depends on resolvedTheme, + // which is unknown on the server and on first paint. + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + // 300ms debounce, matching app/dashboard/receiving/grn/page.tsx. Resets to page 1 with the + // query so a narrower search can't leave you stranded past the last page — done here rather + // than in a second effect watching [query, status], which would be a cascading render. + useEffect(() => { + const timer = setTimeout(() => { + setQuery(searchInput.trim()) + setPage(1) + }, 300) + return () => clearTimeout(timer) + }, [searchInput]) + + const load = useCallback(() => { + setLoadError(null) + productionTemplatesApi + .list({ + page, + pageSize: PAGE_SIZE, + q: query || undefined, + status: status === "All" ? undefined : status, + }) + .then((res) => { + setTemplates(res.items) + setPagination(res.pagination) + }) + .catch((err) => { + setTemplates([]) + setLoadError(errorMessage(err)) + }) + }, [page, query, status]) + + useEffect(load, [load]) + + const hasFilters = query.length > 0 || status !== "All" + const { nodes, edges } = useMemo(() => buildLinesGraph(templates ?? []), [templates]) + + const onNodeClick: NodeMouseHandler = (_, node) => { + const templateId = (node.data as LineHeaderData | LineStageData).templateId + router.push(`/dashboard/production/templates/${templateId}`) + } + + function openCreateDialog() { + setCode("") + setName("") + setErrors({}) + setOpen(true) + } + + /** + * Opens the builder on an unsaved draft rather than creating anything now. + * + * A template cannot exist without a valid graph: the server requires at least one stage and + * a terminal output naming a real finished item (FR-MFG-02/05). There is nothing sensible to + * POST from a name alone, so the draft lives in the builder and the first Save creates it. + */ + function handleCreate() { + const nextErrors: { code?: string; name?: string } = {} + if (!code.trim()) nextErrors.code = "Code is required." + if (!name.trim()) nextErrors.name = "Name is required." + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setOpen(false) + router.push( + `/dashboard/production/templates/new?code=${encodeURIComponent(code.trim())}&name=${encodeURIComponent(name.trim())}`, + ) + } + + return ( +
+
+
+

Production Templates

+

Every production line, stage by stage. Click a line to open its builder.

+
+ + New Template} /> + + + New template + + Name it, then build its stage graph. It's saved once the graph is valid. + + + + + Code + setCode(e.target.value)} + placeholder="e.g. PT-CHAIR" + aria-invalid={!!errors.code} + /> + + + + Name + setName(e.target.value)} + placeholder="e.g. Aluminium Frame Assembly" + aria-invalid={!!errors.name} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + /> + + + +
+ + +
+
+
+
+ +
+
+ + setSearchInput(e.target.value)} + placeholder="Search by code or name…" + className="h-14 w-full pl-11 text-base" + aria-label="Search templates" + /> +
+ value={status} onValueChange={(v) => setStatus(v ?? "All")}> + + + + + All statuses + Active + Inactive + + +
+ + {loadError && ( +
+ {loadError} +
+ )} + + {templates === null ? ( + + ) : templates.length === 0 ? ( +
+ +

+ {hasFilters ? "No templates match your search/filter." : "No templates yet."} +

+
+ ) : ( + <> +
+ {mounted ? ( + + + + + ) : ( + + )} +
+ + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + +
+
+ )} + + )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx index 63ff5ab..9edce27 100644 --- a/Frontend/erp-system/app/dashboard/products/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/[id]/page.tsx @@ -113,7 +113,11 @@ export default function ItemDetailPage() { try { const result = await itemsApi.update( item.itemId, - { sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, taxClass: taxClass || null }, + { + sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId, + baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, + taxClass: taxClass || null, + }, etag ) applyItem(result.data) @@ -177,7 +181,7 @@ export default function ItemDetailPage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/products/brands/page.tsx b/Frontend/erp-system/app/dashboard/products/brands/page.tsx index 4b83c0f..1d4e739 100644 --- a/Frontend/erp-system/app/dashboard/products/brands/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/brands/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" +import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Plus, Search, Tag } from "lucide-react" import { brandsApi } from "@/lib/api/brands" import { errorMessage } from "@/lib/error-map" @@ -155,15 +155,10 @@ export default function BrandsPage() { return (
-
-
- - - -
-

Brands

-

Manage product brands.

-
+
+
+

Brands

+

Manage product brands.

@@ -180,11 +175,11 @@ export default function BrandsPage() { -
- -
@@ -239,21 +234,21 @@ export default function BrandsPage() { {!error && brands !== null && brands.length > 0 && ( <> - - - + + + toggleSort("brandId")} /> - + toggleSort("name")} /> - + toggleSort("status")} /> - + toggleSort("createdAt")} /> - Actions + Actions @@ -364,7 +359,7 @@ function SortableHeader({ return ( ) diff --git a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx index c41f9a9..0541ec0 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/[id]/page.tsx @@ -111,7 +111,7 @@ export default function CategorySubCategoriesPage() { return (
-
+
@@ -138,11 +138,11 @@ export default function CategorySubCategoriesPage() { -
- -
diff --git a/Frontend/erp-system/app/dashboard/products/categories/page.tsx b/Frontend/erp-system/app/dashboard/products/categories/page.tsx index d11549e..f13f273 100644 --- a/Frontend/erp-system/app/dashboard/products/categories/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/categories/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" +import { ArrowDown, ArrowUp, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, ListTree, Network, Pencil, Plus, Search } from "lucide-react" import { categoriesApi } from "@/lib/api/categories" import { errorMessage } from "@/lib/error-map" @@ -154,15 +154,10 @@ export default function CategoriesPage() { return (
-
-
- - - -
-

Categories

-

Item category master (FR-MD-04).

-
+
+
+

Categories

+

Item category master (FR-MD-04).

@@ -179,11 +174,11 @@ export default function CategoriesPage() { -
- -
@@ -238,21 +233,21 @@ export default function CategoriesPage() { {!error && categories !== null && categories.length > 0 && ( <>
- - - + + + toggleSort("categoryId")} /> - + toggleSort("name")} /> - + toggleSort("status")} /> - + toggleSort("createdAt")} /> - Actions + Actions @@ -371,7 +366,7 @@ function SortableHeader({ return ( ) diff --git a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx index 71d6d22..1381bcc 100644 --- a/Frontend/erp-system/app/dashboard/products/item-types/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/item-types/page.tsx @@ -108,7 +108,7 @@ export default function ItemTypesPage() { return (
-
+
@@ -141,11 +141,11 @@ export default function ItemTypesPage() { -
- -
diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index d3b99e5..37ff5f3 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -13,7 +13,7 @@ import { productConfig } from "@/lib/api/product-config" import { uomsApi } from "@/lib/api/uoms" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" -import { validateVariantItemForm } from "@/lib/validations/master-data" +import { validateVariantItemForm, validateVariantPrices } from "@/lib/validations/master-data" import { cn } from "@/lib/utils" import { Brand, Category, ItemType, ProductConfig, StockNature, SubCategory } from "@/types/master-data" @@ -73,10 +73,21 @@ export default function NewItemPage() { // submit, without having to remove and re-add the whole value that produced it. const [removedVariantKeys, setRemovedVariantKeys] = useState>(new Set()) + // Sales pricing (FR-MD-01). "stock" ⇒ salePrice sent as null (sell at FIFO value); + // "fixed" ⇒ every variant must carry a price. `fixValue` is the shared default that + // pre-fills rows; a per-key entry in `pricesByKey` overrides it for that one row only. + const [priceMode, setPriceMode] = useState<"stock" | "fixed">("stock") + const [fixValue, setFixValue] = useState("") + const [pricesByKey, setPricesByKey] = useState>({}) + const [priceErrors, setPriceErrors] = useState>({}) + const [errors, setErrors] = useState>({}) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) + // A row shows its own override if set, otherwise it follows the shared fix value. + const priceFor = (key: string) => pricesByKey[key] ?? fixValue + useEffect(() => { Promise.all([ categoriesApi.list({ pageSize: 200, status: "Active" }), @@ -190,7 +201,11 @@ export default function NewItemPage() { setSubmitError(null) const nextErrors = validateVariantItemForm({ categoryId, hasVariants: variants.length > 0 }) setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + // In fixed mode, block the whole submit until every variant has a price > 0. + const nextPriceErrors = + priceMode === "fixed" ? validateVariantPrices(variants.map((v) => v.key), priceFor) : {} + setPriceErrors(nextPriceErrors) + if (Object.keys(nextErrors).length > 0 || Object.keys(nextPriceErrors).length > 0) return if (baseUomId === null) { setSubmitError("No unit of measure exists yet — create one under Products → UOM before adding items.") return @@ -212,6 +227,7 @@ export default function NewItemPage() { baseUomId, stockNature, trackingMode: "None", + salePrice: priceMode === "fixed" ? Number(priceFor(variant.key)) : null, }) created += 1 } @@ -384,6 +400,59 @@ export default function NewItemPage() {
+ {/* Sales pricing (FR-MD-01). The toggle is frontend-only: "stock" sends + salePrice=null (sold at FIFO value); "fixed" requires a price per variant. */} +
+
+

Sale price

+

+ Choose a fixed selling price, or leave it to the item's stock value. +

+
+ +
+ + +
+ + {priceMode === "fixed" && ( +
+ + setFixValue(e.target.value)} + placeholder="0.00" + className="h-11 text-base" + /> +

+ Edit any row below to give that variant a different price. +

+
+ )} +
+ {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no item-type reference), so this section IS the enforcement. */} {config?.itemTypesEnabled && ( @@ -469,6 +538,9 @@ export default function NewItemPage() { {cat.name} ))} SKU + {priceMode === "fixed" && ( + Sale price + )} {/* Quantity column removed 2026-07-17: there is no `initialQty` on the Item contract and no initial-receipt flow — stock arrives via a GRN. The input was informational-only under the mock and would now be a @@ -485,6 +557,31 @@ export default function NewItemPage() { ))} {variant.sku} + {priceMode === "fixed" && ( + + { + const value = e.target.value + setPricesByKey((prev) => ({ ...prev, [variant.key]: value })) + setPriceErrors((prev) => { + if (!prev[variant.key]) return prev + const next = { ...prev } + delete next[variant.key] + return next + }) + }} + placeholder="0.00" + aria-invalid={!!priceErrors[variant.key]} + className="h-10 w-28 text-base" + /> + + + )} -
diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index cab3278..26d1b7d 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -116,7 +116,7 @@ export default function GrnDetailPage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index 99ce3ef..292283b 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" -import { ArrowLeft, Plus, Trash2 } from "lucide-react" +import { ArrowLeft, ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react" import { grnsApi } from "@/lib/api/grns" import { purchaseOrdersApi } from "@/lib/api/purchase-orders" @@ -107,6 +107,7 @@ export default function NewGrnPage() { const [lineErrors, setLineErrors] = useState>>({}) const [submitError, setSubmitError] = useState(null) const [submitting, setSubmitting] = useState(false) + const [refreshingItems, setRefreshingItems] = useState(false) useEffect(() => { Promise.all([ @@ -188,6 +189,21 @@ export default function NewGrnPage() { } } + // Re-pull the active items list so an item created in the other tab (via "New item") + // becomes selectable without reloading the whole screen and losing the in-progress GRN. + async function refreshItems() { + setRefreshingItems(true) + try { + const res = await itemsApi.list({ pageSize: 200, status: "Active" }) + setItems(res.items) + toast.success("Items refreshed", `${res.items.length} active item${res.items.length === 1 ? "" : "s"} loaded.`) + } catch (err) { + toast.error("Could not refresh items", errorMessage(err)) + } finally { + setRefreshingItems(false) + } + } + function updateLine(key: string, patch: Partial) { setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))) } @@ -384,14 +400,43 @@ export default function NewGrnPage() { )}
-
-

Lines

- {mode === "direct" && ( +
+
+

Lines

+ {mode === "po" && ( +

+ PO lines are prefilled. Use “Add line” to receive an item that isn’t on the PO. +

+ )} +
+
+ {/* Off-PO items are allowed on a PO-based GRN — the server treats a line with + no poLineId as a direct receipt (docs/10 FR-GRN-01, revised). */} - )} + {/* Create a brand-new item in a separate tab, then refresh to pick it up. */} + + +
{poLoading && } diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx index bc094df..c7eb8ef 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx @@ -72,7 +72,7 @@ export default function GrnListPage() { return (
-
+

Goods Receipt Notes

diff --git a/Frontend/erp-system/app/dashboard/settings/page.tsx b/Frontend/erp-system/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..dbe71d9 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/page.tsx @@ -0,0 +1,54 @@ +import Link from "next/link" +import { ShieldCheck, SlidersHorizontal, Users } from "lucide-react" + +const cards = [ + { + title: "Roles", + href: "/dashboard/settings/roles", + icon: ShieldCheck, + description: "Manage roles and navigation permissions", + }, + { + title: "Users", + href: "/dashboard/settings/users", + icon: Users, + description: "Create users and assign their roles", + }, + { + title: "Product Configuration", + href: "/dashboard/products/settings", + icon: SlidersHorizontal, + description: "Configure product and master-data options", + }, +] + +export default function SettingsPage() { + return ( +

+
+

ERP Settings

+

+ Manage ERP access, users, and system configuration. +

+
+ +
+ {cards.map((card) => ( + +
+ +
+
+

{card.title}

+

{card.description}

+
+ + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx index 3674e64..cddbabb 100644 --- a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx @@ -123,7 +123,7 @@ export default function RolesPage() { return (
-
+

Roles

@@ -182,11 +182,11 @@ export default function RolesPage() { )}

-
- -
diff --git a/Frontend/erp-system/app/dashboard/settings/users/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/page.tsx index 7f31957..08cbdaa 100644 --- a/Frontend/erp-system/app/dashboard/settings/users/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/users/page.tsx @@ -2,14 +2,16 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { Pencil, Plus, Users as UsersIcon } from "lucide-react" +import { Link2, Pencil, Plus, Users as UsersIcon } from "lucide-react" import { rolesApi } from "@/lib/api/roles" import { usersApi } from "@/lib/api/users" +import { employeeCrossLinkApi } from "@/lib/api/employees" import { errorMessage, fieldErrors } from "@/lib/error-map" import { cn } from "@/lib/utils" import { PaginationMeta } from "@/types/common" import { Role } from "@/types/rbac" +import { EmployeeMatch } from "@/types/hrm" import { ManagedUser, UserTypeOption } from "@/types/users" import { Button, buttonVariants } from "@/components/ui/button" @@ -50,6 +52,23 @@ export default function UsersPage() { const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + // Advisory cross-link suggestion: does a Staff record already exist with this email? + const [staffMatch, setStaffMatch] = useState(null) + const [checkingEmail, setCheckingEmail] = useState(false) + + async function checkEmail(value: string) { + if (!value.trim()) { setStaffMatch(null); return } + setCheckingEmail(true) + try { + const { match } = await employeeCrossLinkApi.findStaffByEmail(value.trim()) + setStaffMatch(match) + } catch { + setStaffMatch(null) + } finally { + setCheckingEmail(false) + } + } + function load() { usersApi .list({ page, pageSize: PAGE_SIZE }) @@ -88,6 +107,7 @@ export default function UsersPage() { setRoleId("") setUserTypeId("") setErrors({}) + setStaffMatch(null) } async function handleCreate() { @@ -110,6 +130,7 @@ export default function UsersPage() { nic: nic || null, roleId: Number(roleId), userTypeId: userTypeId.trim(), + linkEmployeeId: staffMatch ? staffMatch.employeeId : null, }) toast.success("User created", `Credentials have been emailed to ${result.username}.`) setOpen(false) @@ -126,7 +147,7 @@ export default function UsersPage() { return (
-
+

Users

@@ -161,8 +182,24 @@ export default function UsersPage() { Email - setEmail(e.target.value)} aria-invalid={!!errors.email} /> + setEmail(e.target.value)} + onBlur={(e) => checkEmail(e.target.value)} + aria-invalid={!!errors.email} + /> + {checkingEmail &&

Checking for an existing staff record…

} + {staffMatch && ( +
+ + + Staff record {staffMatch.employeeCode} ({staffMatch.fullName}) matches this email — it will be linked to this user. + +
+ )} Mobile number (optional) @@ -205,11 +242,11 @@ export default function UsersPage() { -
- -
diff --git a/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx b/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx index b0520c2..84d7439 100644 --- a/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/adjustments/page.tsx @@ -37,7 +37,7 @@ export default function AdjustmentsListPage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx b/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx index 3e78c47..0a61115 100644 --- a/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/counts/[id]/page.tsx @@ -89,7 +89,7 @@ export default function CountDetailPage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/stock/counts/page.tsx b/Frontend/erp-system/app/dashboard/stock/counts/page.tsx index 1d1dd05..6269438 100644 --- a/Frontend/erp-system/app/dashboard/stock/counts/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/counts/page.tsx @@ -34,7 +34,7 @@ export default function CountsListPage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx b/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx index b90ae6b..32574bd 100644 --- a/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/transfers/[id]/page.tsx @@ -83,7 +83,7 @@ export default function TransferDetailPage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx b/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx index 273ebe9..9282fab 100644 --- a/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/transfers/page.tsx @@ -34,7 +34,7 @@ export default function TransfersListPage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx b/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx index 578331f..b63d6cf 100644 --- a/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/wastage/page.tsx @@ -66,7 +66,7 @@ export default function WastagePage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx b/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx index b04f997..85730ed 100644 --- a/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx @@ -130,7 +130,7 @@ export default function VendorDetailPage() { return (
-
+
diff --git a/Frontend/erp-system/app/dashboard/vendors/page.tsx b/Frontend/erp-system/app/dashboard/vendors/page.tsx index 38ee91e..c3afcbf 100644 --- a/Frontend/erp-system/app/dashboard/vendors/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/page.tsx @@ -127,7 +127,7 @@ export default function VendorsPage() { return (
-
+

Vendors

Supplier master data — code, terms, tax registration, currency (FR-MD-06).

@@ -172,11 +172,11 @@ export default function VendorsPage() { -
- -
@@ -303,7 +303,7 @@ export default function VendorsPage() {
- }>Close + }>Close
diff --git a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx index 44705a8..d8b6826 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx @@ -88,7 +88,7 @@ export default function WarehouseDetailPage() { return (
-
+
@@ -124,11 +124,11 @@ export default function WarehouseDetailPage() { setBinType(e.target.value)} placeholder="Shelf, Pallet, …" /> -
- -
diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx index 823785e..af4d534 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx @@ -5,7 +5,7 @@ import Link from "next/link" import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react" import { warehousesApi } from "@/lib/api/warehouses" -import { errorMessage, fieldErrors } from "@/lib/error-map" +import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" import { Bin, Warehouse } from "@/types/master-data" @@ -24,17 +24,36 @@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +/** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH" + * so an empty/punctuation-only name still yields a usable base. */ +function warehouseCodeBase(name: string): string { + const firstWord = name.trim().split(/\s+/)[0] ?? "" + const cleaned = firstWord.toUpperCase().replace(/[^A-Z0-9]/g, "") + return cleaned.slice(0, 10) || "WH" +} + +/** Appends a numeric suffix until the code doesn't collide with an existing one — the + * backend enforces global uniqueness (409 on conflict) but has no generation of its own. */ +function generateWarehouseCode(name: string, existingCodes: string[]): string { + const base = `WH-${warehouseCodeBase(name)}` + if (!existingCodes.includes(base)) return base + let suffix = 2 + while (existingCodes.includes(`${base}${suffix}`)) suffix += 1 + return `${base}${suffix}` +} + export default function WarehousesPage() { const [warehouses, setWarehouses] = useState(null) const [bins, setBins] = useState(null) const [error, setError] = useState(null) const [open, setOpen] = useState(false) - const [code, setCode] = useState("") const [name, setName] = useState("") const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : "" + function load() { warehousesApi .list() @@ -54,23 +73,21 @@ export default function WarehousesPage() { async function handleCreate() { const nextErrors: Record = {} - if (!code.trim()) nextErrors.code = "Warehouse code is required" if (!name.trim()) nextErrors.name = "Warehouse name is required" setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) try { - const warehouse = await warehousesApi.create({ code, name }) + const warehouse = await warehousesApi.create({ code: generatedCode, name }) toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`) setOpen(false) - setCode("") setName("") setErrors({}) load() } catch (err) { - const fe = fieldErrors(err) - if (fe?.code) setErrors({ code: fe.code }) + // A 409 here means another creation raced ours for the same generated code — the + // proactive de-dupe above only knows about warehouses loaded when the dialog opened. toast.error("Could not create warehouse", errorMessage(err)) } finally { setSubmitting(false) @@ -81,7 +98,7 @@ export default function WarehousesPage() { return (
-
+
@@ -104,25 +121,24 @@ export default function WarehousesPage() { New warehouse - Create a new warehouse. Bins are added from its detail page. + Create a new warehouse. Its code is generated from the name. Bins are added from its detail page. - - Code - setCode(e.target.value)} placeholder="WH-MAIN" aria-invalid={!!errors.code} /> - - Name setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} /> + + Code (auto-generated) + + -
- -
diff --git a/Frontend/erp-system/app/globals.css b/Frontend/erp-system/app/globals.css index 24bab46..a0ee55b 100644 --- a/Frontend/erp-system/app/globals.css +++ b/Frontend/erp-system/app/globals.css @@ -2,7 +2,7 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; -@custom-variant dark (&:is(.dark *)); +@custom-variant dark (&:is(.dark *, .vibrant *)); @theme inline { --color-background: var(--background); @@ -129,6 +129,69 @@ --sidebar-ring: oklch(0.68 0.186 265.215); } +/* Vibrant — the "System" toggle option. Light content area (background, + cards, header, table panels) paired with a dark sidebar — the same split + Linear/Vercel/Notion use in their light themes. One violet accent drives + every interactive state; the sidebar keeps its own dark token family + (applied via .sidebar-surface below) so it stays dark regardless. */ +.vibrant { + --background: oklch(0.97 0.004 265); + --foreground: oklch(0.2 0.02 265); + --card: oklch(0.995 0.002 265); + --card-foreground: oklch(0.2 0.02 265); + --popover: oklch(0.995 0.002 265); + --popover-foreground: oklch(0.2 0.02 265); + --primary: oklch(0.55 0.2 275); + --primary-foreground: oklch(0.98 0 0); + --secondary: oklch(0.93 0.02 275); + --secondary-foreground: oklch(0.32 0.15 275); + --muted: oklch(0.94 0.006 265); + --muted-foreground: oklch(0.48 0.02 265); + --accent: oklch(0.55 0.14 210); + --accent-foreground: oklch(0.98 0 0); + --destructive: oklch(0.58 0.22 25); + --border: oklch(0.88 0.012 265); + --input: oklch(0.92 0.01 265); + --ring: oklch(0.55 0.2 275); + --success: oklch(0.55 0.15 150); + --warning: oklch(0.72 0.15 80); + --error: oklch(0.58 0.22 25); + --info: oklch(0.55 0.14 210); + --chart-1: oklch(0.55 0.2 275); + --chart-2: oklch(0.55 0.14 210); + --chart-3: oklch(0.55 0.15 150); + --chart-4: oklch(0.72 0.15 80); + --chart-5: oklch(0.58 0.22 25); + --sidebar: oklch(0.18 0.02 265); + --sidebar-foreground: oklch(0.96 0.005 265); + --sidebar-primary: oklch(0.64 0.19 275); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.28 0.03 265); + --sidebar-accent-foreground: oklch(0.96 0.005 265); + --sidebar-border: oklch(0.26 0.025 265); + --sidebar-ring: oklch(0.64 0.19 275); +} + +/* Re-points the shared tokens (--card, --foreground, --muted*, --primary...) + at the sidebar's own dark family for anything inside this scope, so + AppSidebar's existing bg-card/text-foreground/text-muted-foreground + classes render dark without AppSidebar.tsx needing sidebar-specific + classes. CSS custom properties resolve against the cascade at point of + use, so this indirection (the same trick .dark/.vibrant use at the root) + works scoped to just this subtree. */ +.vibrant .sidebar-surface { + --card: var(--sidebar); + --card-foreground: var(--sidebar-foreground); + --popover: var(--sidebar); + --popover-foreground: var(--sidebar-foreground); + --foreground: var(--sidebar-foreground); + --muted: var(--sidebar-accent); + --muted-foreground: oklch(0.72 0.015 265); + --primary: var(--sidebar-primary); + --primary-foreground: var(--sidebar-primary-foreground); + --border: var(--sidebar-border); +} + @layer base { * { @apply border-border outline-ring/50; @@ -154,4 +217,5 @@ h4 { @apply text-lg; } -} \ No newline at end of file +} + diff --git a/Frontend/erp-system/app/layout.tsx b/Frontend/erp-system/app/layout.tsx index b3a02c4..b1c1f57 100644 --- a/Frontend/erp-system/app/layout.tsx +++ b/Frontend/erp-system/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "next-themes"; import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; @@ -27,9 +28,17 @@ export default function RootLayout({ - {children} + + {children} + ); diff --git a/Frontend/erp-system/app/login/forgot/otp/page.tsx b/Frontend/erp-system/app/login/forgot/otp/page.tsx index 05c8cde..0d22975 100644 --- a/Frontend/erp-system/app/login/forgot/otp/page.tsx +++ b/Frontend/erp-system/app/login/forgot/otp/page.tsx @@ -25,10 +25,10 @@ export default function ForgotOtpPage() { return (
-
+
-
- +
+

Verify your email

@@ -46,7 +46,7 @@ export default function ForgotOtpPage() { e.preventDefault() handleResend() }} - className="text-sm font-medium text-black hover:text-black/80 transition-colors" + className="text-sm font-medium text-foreground hover:text-foreground/80 transition-colors" > Didn't receive the code? Resend @@ -57,10 +57,10 @@ export default function ForgotOtpPage() {
- + - +
diff --git a/Frontend/erp-system/app/login/forgot/page.tsx b/Frontend/erp-system/app/login/forgot/page.tsx index 1b02ea7..e78493b 100644 --- a/Frontend/erp-system/app/login/forgot/page.tsx +++ b/Frontend/erp-system/app/login/forgot/page.tsx @@ -19,7 +19,7 @@ export default function ForgotEmailPage() { return (
-
+

Reset your password

Enter the email address associated with your account and we'll send you a one-time code to verify your identity.

@@ -40,19 +40,28 @@ export default function ForgotEmailPage() { {emailError !== "" &&

{emailError}

}
- +
+ + +
diff --git a/Frontend/erp-system/app/login/forgot/reset/page.tsx b/Frontend/erp-system/app/login/forgot/reset/page.tsx index d784d27..856958a 100644 --- a/Frontend/erp-system/app/login/forgot/reset/page.tsx +++ b/Frontend/erp-system/app/login/forgot/reset/page.tsx @@ -39,7 +39,7 @@ export default function ForgotResetPage() { return (
-
+

Create a new password

Choose a strong password to secure your account.

@@ -103,15 +103,15 @@ export default function ForgotResetPage() {
@@ -131,7 +131,7 @@ export default function ForgotResetPage() {
- diff --git a/Frontend/erp-system/app/login/page.tsx b/Frontend/erp-system/app/login/page.tsx index 8ae1730..612976d 100644 --- a/Frontend/erp-system/app/login/page.tsx +++ b/Frontend/erp-system/app/login/page.tsx @@ -17,29 +17,6 @@ import { Checkbox } from "@/components/ui/checkbox" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" -function GoogleIcon() { - return ( - - - - - - - ) -} - export default function LoginPage() { return ( @@ -77,19 +54,28 @@ function LoginForm() { return (
- {/* Full-page background photo — right side only */} -
+ {/* Full-page background photo — right side only. Dark mode swaps to the backdrop-free + cutout (whithoutbackground.png) so there's no white scene box floating on the + dark panel. */} +
+
{/* Login panel — overlays left side, no hard border with the photo */} -
+
@@ -153,7 +139,7 @@ function LoginForm() {
- + Forgot password?
@@ -168,7 +154,7 @@ function LoginForm() {
-
-
- Or continue with -
-
- - -

- Don't have an account? Create an account + Don't have an account? Create an account

diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index a11487c..c80abe1 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -4,18 +4,26 @@ import { useEffect, useState } from "react" import Link from "next/link" import { usePathname } from "next/navigation" import { + Banknote, Boxes, Building2, + CalendarCheck, + CalendarClock, ChevronRight, ClipboardList, + Factory, + FileBarChart, FileText, HelpCircle, + IdCard, LayoutGrid, + LayoutTemplate, ListTree, Menu, Package, PackageCheck, PackageX, + PlayCircle, Ruler, Settings, ShieldCheck, @@ -39,6 +47,12 @@ const navItems: { title: string code: string href: string + /** Where clicking the row actually navigates, if different from `href`. `href` itself + * stays the section prefix used to decide whether this row is "active". */ + landingHref?: string + /** This item's own href has no page of its own (no landingHref either) — clicking the + * row should only expand/collapse its children, never navigate. */ + expandOnly?: boolean icon: LucideIcon chevron?: boolean children?: { title: string; code: string; href: string; icon: LucideIcon }[] @@ -57,28 +71,60 @@ const navItems: { { title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler }, ], }, - { title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, { title: "Procurement", code: "procurement", href: "/dashboard/procurement", + // Clicking "Procurement" itself lands on Purchase Orders — the hub page underneath + // has nothing on it (its card grid was removed once the sidebar grew these sub-items). + landingHref: "/dashboard/procurement/purchase-orders", icon: ClipboardList, chevron: true, children: [ - { title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList }, - { title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText }, { title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart }, { title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX }, + { title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck }, + { title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList }, + { title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText }, ], }, { title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, { title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true }, + { + title: "Production", + code: "production", + href: "/dashboard/production", + landingHref: "/dashboard/production/runs", + icon: Factory, + chevron: true, + children: [ + { title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate }, + { title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle }, + ], + }, + { + title: "HRM", + code: "hrm", + href: "/dashboard/hrm", + landingHref: "/dashboard/hrm/employees", + icon: IdCard, + chevron: true, + children: [ + { title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users }, + { title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck }, + { title: "Leave", code: "hrm.leave", href: "/dashboard/hrm/leave", icon: CalendarClock }, + { title: "Payroll", code: "hrm.payroll", href: "/dashboard/hrm/payroll", icon: Banknote }, + { title: "Reports", code: "hrm.reports", href: "/dashboard/hrm/reports", icon: FileBarChart }, + { title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal }, + ], + }, { title: "Settings", code: "settings", href: "/dashboard/settings", + expandOnly: true, icon: Settings, chevron: true, children: [ @@ -112,9 +158,13 @@ function SidebarContent({ const [expanded, setExpanded] = useState>({}) useEffect(() => { - const parent = items.find((i) => - i.children?.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`)) - ) + const parent = items.find((i) => { + if (!i.children?.length) return false + if (i.children.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))) return true + // Landing on the parent's own hub page (e.g. /dashboard/settings itself, + // not one of its children) should also reveal its sub-items. + return pathname === i.href || pathname.startsWith(`${i.href}/`) + }) if (parent) { setExpanded((prev) => (prev[parent.code] ? prev : { ...prev, [parent.code]: true })) } @@ -126,7 +176,7 @@ function SidebarContent({ return (
+ + + Code + Name + Status + Actions + + + + {items.map((item) => ( + + {item.code} + {item.name} + + + {item.status} + + + + + + + ))} + +
+ )} + + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}–{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + Page {pagination.page} of {pagination.totalPages} + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/components/production/ProductionLineNodes.tsx b/Frontend/erp-system/components/production/ProductionLineNodes.tsx new file mode 100644 index 0000000..0710fe0 --- /dev/null +++ b/Frontend/erp-system/components/production/ProductionLineNodes.tsx @@ -0,0 +1,58 @@ +import { memo } from "react" +import { Handle, Position, type NodeProps } from "@xyflow/react" + +import { cn } from "@/lib/utils" + +export interface LineHeaderData extends Record { + templateId: number + /** The template's `code` (e.g. `PT-CHAIR`). Templates carry a code; only runs get a doc no. */ + code: string + name: string + status: "Active" | "Inactive" + activeRunCount: number +} + +/** Row label docked at the left of each production line — the template itself. */ +function LineHeaderNode({ data }: NodeProps & { data: LineHeaderData }) { + return ( +
+
+ {data.code} + + {data.status} + +
+

{data.name}

+ {data.activeRunCount > 0 && ( + + {data.activeRunCount} in progress + + )} + +
+ ) +} + +export interface LineStageData extends Record { + templateId: number + name: string +} + +/** One stage on a production line — read-only, purely a visual chip on the overview canvas. */ +function LineStageNode({ data }: NodeProps & { data: LineStageData }) { + return ( +
+ +

{data.name}

+ +
+ ) +} + +export const LineHeaderNodeComponent = memo(LineHeaderNode) +export const LineStageNodeComponent = memo(LineStageNode) diff --git a/Frontend/erp-system/components/production/RunStageNode.tsx b/Frontend/erp-system/components/production/RunStageNode.tsx new file mode 100644 index 0000000..952e9b9 --- /dev/null +++ b/Frontend/erp-system/components/production/RunStageNode.tsx @@ -0,0 +1,126 @@ +import { memo } from "react" +import { Handle, Position, type NodeProps } from "@xyflow/react" +import { Flag, PackageCheck, Timer } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ProductionRunStatus } from "@/types/production" +import { STAGE_STATUS_COLOR, STAGE_STATUS_LABEL, type StageStatus } from "@/lib/production-status-colors" + +export interface RunHeaderData extends Record { + docNo: string + templateName: string + status: ProductionRunStatus +} + +/** Left-most box on a run's production line — the run itself, not a stage. */ +function RunHeaderNode({ data }: NodeProps & { data: RunHeaderData }) { + return ( +
+ {data.docNo} +

{data.templateName}

+ +
+ ) +} + +export interface RunStageData extends Record { + name: string + roleLabel: string | null + state: StageStatus + isTerminal: boolean + isEntry: boolean + estimatedMinutes: number + /** Whole minutes once finished; null while still running (FR-MFG-19). */ + actualMinutes: number | null + /** Set once started — drives the "running" hint while actualMinutes is still null. */ + actualStartAt: string | null + /** + * Aggregate intake across this stage's Upstream inputs, or null when it has none (an entry + * stage draws entirely from stock). `delivered >= planned` is exactly the readiness rule the + * server applies, so the badge doubles as an explanation of why a stage is still Waiting. + */ + intake: { delivered: number; planned: number } | null + /** Σ availableToTransfer across outputs — WIP produced but not yet pushed downstream. */ + availableToTransfer: number + /** Marks the stage the operator is expected to act on next. */ + isActive: boolean +} + +function fmt(n: number): string { + // Quantities are decimal(18,4) server-side; trailing zeros just add noise on a canvas card. + return Number(n.toFixed(4)).toLocaleString() +} + +/** + * One stage on a run's production line, positioned from the run's own `posX`/`posY` (copied + * from the template at creation) and colored by its live status. Clicking it opens the stage + * drawer — handled by the page via `onNodeClick`, not here. + */ +function RunStageNode({ data, selected }: NodeProps & { data: RunStageData }) { + const color = STAGE_STATUS_COLOR[data.state] + const running = data.actualStartAt !== null && data.actualMinutes === null + const intakeShort = data.intake !== null && data.intake.delivered < data.intake.planned + + return ( +
+ + +
+ +

{data.name}

+ {data.isTerminal && ( + + + + )} +
+ +
+ + {STAGE_STATUS_LABEL[data.state]} + + {data.roleLabel && ( + {data.roleLabel} + )} +
+ +
+ + {data.actualMinutes !== null + ? `${data.actualMinutes} / ${data.estimatedMinutes} min` + : running + ? `running · est. ${data.estimatedMinutes} min` + : `est. ${data.estimatedMinutes} min`} +
+ + {data.intake && ( +
+ Intake {fmt(data.intake.delivered)} / {fmt(data.intake.planned)} +
+ )} + + {data.availableToTransfer > 0 && ( +
+ + {fmt(data.availableToTransfer)} to transfer +
+ )} + + +
+ ) +} + +export const RunHeaderNodeComponent = memo(RunHeaderNode) +export const RunStageNodeComponent = memo(RunStageNode) diff --git a/Frontend/erp-system/components/production/stage-progress-strip.tsx b/Frontend/erp-system/components/production/stage-progress-strip.tsx new file mode 100644 index 0000000..0e385c3 --- /dev/null +++ b/Frontend/erp-system/components/production/stage-progress-strip.tsx @@ -0,0 +1,85 @@ +import { CheckCircle2 } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + RUN_CANCELLED_COLOR, + RUN_COMPLETED_COLOR, + STAGE_STATUS_COLOR, + STAGE_STATUS_LABEL, + STAGE_STATUS_ORDER, +} from "@/lib/production-status-colors" +import { ProductionRunStatus, StageSummary } from "@/types/production" + +/** + * One segment per stage-status count (docs/21-FRONTEND-PHASE2.md §3). Completed runs render + * a full teal strip + check; cancelled runs get a red accent instead of per-stage segments. + */ +export function StageProgressStrip({ + status, + summary, + className, +}: { + status: ProductionRunStatus + summary: StageSummary + className?: string +}) { + if (status === "Completed") { + return ( +
+
+ +
+ ) + } + + const counts: Record = { + Waiting: summary.waiting, + Ready: summary.ready, + InProgress: summary.inProgress, + Done: summary.done, + Approved: summary.approved, + } + const total = STAGE_STATUS_ORDER.reduce((sum, key) => sum + counts[key], 0) + + return ( +
+ {total === 0 + ? null + : STAGE_STATUS_ORDER.map((key) => { + const count = counts[key] + if (count === 0) return null + return ( +
+ ) + })} +
+ ) +} + +export function StageStatusLegend({ className }: { className?: string }) { + return ( +
+ {STAGE_STATUS_ORDER.map((key) => ( +
+ + {STAGE_STATUS_LABEL[key]} +
+ ))} +
+ + Cancelled +
+
+ + Completed +
+
+ ) +} diff --git a/Frontend/erp-system/components/theme-toggle.tsx b/Frontend/erp-system/components/theme-toggle.tsx new file mode 100644 index 0000000..6cce777 --- /dev/null +++ b/Frontend/erp-system/components/theme-toggle.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useEffect, useState } from "react" +import { Moon, Sun, Monitor } from "lucide-react" +import { useTheme } from "next-themes" + +import { cn } from "@/lib/utils" + +const options = [ + { value: "light", label: "Light mode", icon: Sun }, + { value: "vibrant", label: "Vibrant theme", icon: Monitor }, + { value: "dark", label: "Dark mode", icon: Moon }, +] as const + +/** + * Renders an empty slot until mounted: `theme` is unknown on the server (and on the + * client's first paint, before next-themes reads localStorage), so rendering the active + * segment before that would either be wrong or cause a hydration mismatch. + */ +export function ThemeToggle({ className }: { className?: string }) { + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + + if (!mounted) { + return