Compare commits

...

18 Commits

Author SHA1 Message Date
Sasanka 96f81cb03a Refactor: Remove unused imports and clean up code across multiple pages
- Removed unused Link imports and associated code for navigation in various pages.
- Simplified TableHeader components by removing unnecessary class names.
- Updated alert dialog styles for better consistency with the design system.
- Cleaned up imports in several components to streamline the codebase.
2026-07-31 11:04:32 +05:30
ImanThiyanga cb3f1e5cde Merge branch 'production' into Dev 2026-07-31 05:24:32 +00:00
ImanThiyanga c3fef88bcf Merge pull request 'feat: add production templates API and documentation for manufacturing phase 2' (#19) from feat/production-integration into Dev
Reviewed-on: #19
2026-07-31 04:54:00 +00:00
ImanThiyanga 7d6e597389 feat: add production templates API and documentation for manufacturing phase 2
- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates.
- Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs.
- Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
2026-07-31 10:22:40 +05:30
ImanThiyanga 415ac94ab2 Merge pull request 'Dashborad and prouction' (#18) from Dashborad-and-prouction into Dev
Reviewed-on: #18
2026-07-29 17:08:57 +00:00
Sasanka 4561ef7ba8 feat: implement frontend-only mock for production lines and runs
- Add ProductionTemplatesPage component for managing production templates with a visual representation using React Flow.
- Create LineHeaderNode and LineStageNode components for rendering production line nodes.
- Introduce RunStageNode and RunHeaderNode components for displaying run stages and headers.
- Implement StageProgressStrip for visualizing stage progress in runs.
- Create mock data for production runs and templates to simulate backend functionality.
- Define types for production templates and runs to structure mock data.
- Document frontend Phase 2 specifications for manufacturing processes, including screens, dialogs, and validation posture.
2026-07-28 22:55:56 +05:30
Sasanka b12adebaa0 feat: add dashboard overview stats endpoint and UI integration
- Implemented `GET /dashboard/stats` in `DashboardController` to provide aggregate counts for stock, GRN, and procurement.
- Created `DashboardStatsDto` and `WarehouseValuationDto` to structure the response data.
- Developed `DashboardService` to fetch and compute necessary statistics from the database.
- Added `IDashboardService` interface for service abstraction.
- Introduced API client methods in `dashboard.ts` for frontend consumption of the new endpoint.
- Defined TypeScript types for dashboard data in `dashboard.ts` to ensure type safety in the frontend.
- Updated UI components in the frontend to reflect changes in the dashboard, including styling adjustments and removal of unused icons.
2026-07-28 14:59:09 +05:30
ImanThiyanga 0b95d6f1cd Merge pull request 'feat: enhance UI components and add vibrant theme support' (#17) from ui-fixers into Dev
Reviewed-on: #17
2026-07-27 06:10:10 +00:00
ImanThiyanga d35b076435 Merge branch 'Dev' into ui-fixers 2026-07-27 06:10:00 +00:00
Sasanka c564916c60 feat: enhance UI components and add vibrant theme support
- Updated layout and styling for various dashboard pages including stock adjustments, counts, transfers, wastage, and vendors to improve responsiveness and visual consistency.
- Introduced a vibrant theme option in the theme toggle component, allowing users to switch between light, dark, and vibrant themes.
- Refactored button styles across login and forgot password pages for better accessibility and visual feedback.
- Improved sidebar navigation with expanded functionality for items without dedicated pages.
- Enhanced data table styling for better readability and user interaction.
- Added new background images to support the vibrant theme.
2026-07-27 11:11:26 +05:30
Dhananjaya99 86bb4d4908 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-07-24 14:33:55 +05:30
Dhananjaya99 816ffbbfb6 setting not found issue 2026-07-24 14:33:49 +05:30
ashan_rusiru ae20bc4e34 Merge pull request 'develop full initial module' (#16) from Feature_HRM into Dev
Reviewed-on: #16
2026-07-24 05:25:13 +00:00
Dhananjaya99 755df494fe develop full initial module 2026-07-23 19:54:56 +05:30
Dhananjaya99 eacc21afad Merge branch 'fixers-7/23' into Dev 2026-07-23 13:02:24 +05:30
ImanThiyanga 5af77fbf0a Merge pull request 'feat: Implement fixed sale price functionality for items' (#14) from fix/grn-varient into Dev
Reviewed-on: #14
2026-07-23 06:48:46 +00:00
ImanThiyanga a1b3985469 feat: Implement fixed sale price functionality for items
- Added a toggle for fixed sale price vs stock value in the item creation form.
- Introduced validation to ensure all variants have a price greater than 0 when fixed price mode is selected.
- Updated the item model to include a nullable salePrice field, which is used for sales only and does not affect GRN/FIFO/ledger.
- Enhanced the GRN page to allow off-PO items and included a refresh button to update the item list without reloading the page.
- Updated documentation to reflect changes in item pricing and GRN handling.
2026-07-23 12:12:32 +05:30
ImanThiyanga 8484601494 Merge pull request 'Dev' (#13) from Dev into production
Reviewed-on: #13
2026-07-22 06:12:34 +00:00
334 changed files with 27628 additions and 803 deletions
@@ -0,0 +1,20 @@
using ERPCore.Dtos.Dashboard;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
[Route("api/v1/dashboard")]
public sealed class DashboardController : ApiControllerBase
{
private readonly IDashboardService _dashboard;
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(ct));
}
@@ -0,0 +1,99 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase
{
private readonly IAttendanceUploadService _attendance;
private readonly ICurrentUser _currentUser;
public AttendanceBatchesController(IAttendanceUploadService attendance, ICurrentUser currentUser)
{
_attendance = attendance;
_currentUser = currentUser;
}
[HttpGet("template.xlsx")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult DownloadTemplate([FromQuery] string? format)
{
var (content, contentType, fileName) = _attendance.GenerateTemplate(string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase));
return File(content, contentType, fileName);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AttendanceUploadBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AttendanceUploadBatchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] AttendanceBatchStatus? status,
[FromQuery] int? periodYear, [FromQuery] int? periodMonth, CancellationToken ct)
=> Ok(await _attendance.ListBatchesAsync(query, status, periodYear, periodMonth, ct));
[HttpGet("{batchId:int}")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AttendanceUploadBatchDto>> GetById(int batchId, CancellationToken ct)
{
var result = await _attendance.GetBatchAsync(batchId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Upload(
[FromForm] UploadAttendanceBatchMetadata metadata, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _attendance.UploadAsync(stream, file.FileName, metadata.PeriodStart, metadata.PeriodEnd, _currentUser.AuditUserId, ct);
return Created($"/api/v1/attendance-batches/{result.AttendanceUploadBatchId}", result);
}
[HttpGet("{batchId:int}/records")]
[ProducesResponseType(typeof(List<AttendanceRecordDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceRecordDto>>> ListRecords(
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceRecordDto>> UpdateRecord(
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResolveDuplicate(int batchId, [FromBody] ResolveDuplicateRequest request, CancellationToken ct)
{
await _attendance.ResolveDuplicateAsync(batchId, request, ct);
return NoContent();
}
[HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct));
[HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct)
=> Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct));
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Branch master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/branches")]
public sealed class BranchesController : ApiControllerBase
{
private readonly IBranchService _branches;
public BranchesController(IBranchService branches) => _branches = branches;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BranchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BranchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _branches.ListAsync(query, status, ct));
[HttpGet("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BranchDto>> GetById(int branchId, CancellationToken ct)
{
var result = await _branches.GetAsync(branchId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BranchDto>> Create([FromBody] CreateBranchRequest request, CancellationToken ct)
{
var result = await _branches.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
}
[HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchDto>> Update(int branchId, [FromBody] UpdateBranchRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _branches.UpdateAsync(branchId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,65 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase
{
private readonly IDepartmentService _departments;
public DepartmentsController(IDepartmentService departments) => _departments = departments;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DepartmentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DepartmentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _departments.ListAsync(query, status, ct));
[HttpGet("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DepartmentDto>> GetById(int departmentId, CancellationToken ct)
{
var result = await _departments.GetAsync(departmentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DepartmentDto>> Create([FromBody] CreateDepartmentRequest request, CancellationToken ct)
{
var result = await _departments.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
}
[HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<DepartmentDto>> Update(int departmentId, [FromBody] UpdateDepartmentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _departments.UpdateAsync(departmentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase
{
private readonly IDesignationService _designations;
public DesignationsController(IDesignationService designations) => _designations = designations;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DesignationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DesignationDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _designations.ListAsync(query, status, ct));
[HttpGet("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DesignationDto>> GetById(int designationId, CancellationToken ct)
{
var result = await _designations.GetAsync(designationId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DesignationDto>> Create([FromBody] CreateDesignationRequest request, CancellationToken ct)
{
var result = await _designations.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
}
[HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<DesignationDto>> Update(int designationId, [FromBody] UpdateDesignationRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _designations.UpdateAsync(designationId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,217 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary>
[Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase
{
private readonly IEmployeeService _employees;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeDocumentService _documents;
private readonly ILeaveBalanceService _leaveBalances;
private readonly IEmployeeSalaryStructureService _salaryStructures;
private readonly IEmployeeLoanService _loans;
private readonly ICurrentUser _currentUser;
public EmployeesController(
IEmployeeService employees, IEmployeeUserLinkService links, IEmployeeDocumentService documents,
ILeaveBalanceService leaveBalances, IEmployeeSalaryStructureService salaryStructures,
IEmployeeLoanService loans, ICurrentUser currentUser)
{
_employees = employees;
_links = links;
_documents = documents;
_leaveBalances = leaveBalances;
_salaryStructures = salaryStructures;
_loans = loans;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmployeeListItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmployeeListItemDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EmployeeStatus? status,
[FromQuery] int? departmentId, [FromQuery] int? designationId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeDetailDto>> GetById(int employeeId, CancellationToken ct)
{
var result = await _employees.GetAsync(employeeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeDetailDto>> Create([FromBody] CreateEmployeeRequest request, CancellationToken ct)
{
var result = await _employees.CreateAsync(request, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
}
[HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmployeeDetailDto>> Update(int employeeId, [FromBody] UpdateEmployeeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employees.UpdateAsync(employeeId, request, expected, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employeeId, [FromBody] UpdateEmployeeStatusRequest request, CancellationToken ct)
{
await _employees.SetStatusAsync(employeeId, request.Status, ct);
return NoContent();
}
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> LinkUser(int employeeId, [FromBody] LinkUserRequest request, CancellationToken ct)
{
await _links.LinkAsync(employeeId, request.UserId, ct);
return NoContent();
}
[HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UnlinkUser(int employeeId, CancellationToken ct)
{
await _links.UnlinkAsync(employeeId, ct);
return NoContent();
}
[HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ReplaceBankDetails(
int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct)
=> Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<LeaveBalanceDto>>> UpdateLeaveBalances(
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeSalaryStructureDto>> CreateSalaryStructure(
int employeeId, [FromBody] CreateSalaryStructureRequest request, CancellationToken ct)
{
var result = await _salaryStructures.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
}
[HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct));
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> GetLoan(int employeeId, int loanId, CancellationToken ct)
{
var result = await _loans.GetAsync(employeeId, loanId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> CreateLoan(int employeeId, [FromBody] CreateEmployeeLoanRequest request, CancellationToken ct)
{
var result = await _loans.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
}
[HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct));
[HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<EmployeeDocumentDto>> UploadDocument(
int employeeId, [FromForm] UploadEmployeeDocumentRequest request, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _documents.UploadAsync(
employeeId, request, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
}
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadDocument(int employeeId, int documentId, CancellationToken ct)
{
var (content, fileName, contentType) = await _documents.DownloadAsync(employeeId, documentId, ct);
return File(content, contentType, fileName);
}
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetDocumentStatus(
int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct)
{
await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase
{
private readonly IEmploymentTypeService _employmentTypes;
public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmploymentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmploymentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _employmentTypes.ListAsync(query, status, ct));
[HttpGet("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmploymentTypeDto>> GetById(int employmentTypeId, CancellationToken ct)
{
var result = await _employmentTypes.GetAsync(employmentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmploymentTypeDto>> Create([FromBody] CreateEmploymentTypeRequest request, CancellationToken ct)
{
var result = await _employmentTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
}
[HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmploymentTypeDto>> Update(int employmentTypeId, [FromBody] UpdateEmploymentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employmentTypes.UpdateAsync(employmentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase
{
private readonly IHrDocumentTypeService _types;
public HrDocumentTypesController(IHrDocumentTypeService types) => _types = types;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<HrDocumentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<HrDocumentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _types.ListAsync(query, status, ct));
[HttpGet("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<HrDocumentTypeDto>> GetById(int hrDocumentTypeId, CancellationToken ct)
{
var result = await _types.GetAsync(hrDocumentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<HrDocumentTypeDto>> Create([FromBody] CreateHrDocumentTypeRequest request, CancellationToken ct)
{
var result = await _types.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
}
[HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<HrDocumentTypeDto>> Update(int hrDocumentTypeId, [FromBody] UpdateHrDocumentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _types.UpdateAsync(hrDocumentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,52 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase
{
private readonly IHrReportService _reports;
public HrReportsController(IHrReportService reports) => _reports = reports;
[HttpGet("attendance-summary")]
[ProducesResponseType(typeof(List<AttendanceSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceSummaryRowDto>>> AttendanceSummary(
[FromQuery] int periodYear, [FromQuery] int periodMonth, [FromQuery] int? departmentId, CancellationToken ct)
=> Ok(await _reports.AttendanceSummaryAsync(periodYear, periodMonth, departmentId, ct));
[HttpGet("overtime")]
[ProducesResponseType(typeof(List<OvertimeReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<OvertimeReportRowDto>>> Overtime(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct));
[HttpGet("late-arrivals")]
[ProducesResponseType(typeof(List<LateArrivalReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LateArrivalReportRowDto>>> LateArrivals(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
=> Ok(await _reports.SalaryHistoryAsync(employeeId, ct));
[HttpGet("leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceReportRowDto>>> LeaveBalances([FromQuery] int year, CancellationToken ct)
=> Ok(await _reports.LeaveBalanceReportAsync(year, ct));
[HttpGet("document-expiry")]
[ProducesResponseType(typeof(List<DocumentExpiryReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<DocumentExpiryReportRowDto>>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct)
=> Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct));
}
@@ -0,0 +1,71 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase
{
private readonly ILeaveRequestService _requests;
private readonly ICurrentUser _currentUser;
public LeaveRequestsController(ILeaveRequestService requests, ICurrentUser currentUser)
{
_requests = requests;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveRequestDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveRequestDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? employeeId, [FromQuery] LeaveRequestStatus? status, CancellationToken ct)
=> Ok(await _requests.ListAsync(query, employeeId, status, ct));
[HttpGet("{leaveRequestId:int}")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveRequestDto>> GetById(int leaveRequestId, CancellationToken ct)
{
var result = await _requests.GetAsync(leaveRequestId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<LeaveRequestDto>> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct)
{
var result = await _requests.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
}
[HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Cancel(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.CancelAsync(leaveRequestId, ct));
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase
{
private readonly ILeaveTypeService _leaveTypes;
public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _leaveTypes.ListAsync(query, status, ct));
[HttpGet("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveTypeDto>> GetById(int leaveTypeId, CancellationToken ct)
{
var result = await _leaveTypes.GetAsync(leaveTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveTypeDto>> Create([FromBody] CreateLeaveTypeRequest request, CancellationToken ct)
{
var result = await _leaveTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
}
[HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<LeaveTypeDto>> Update(int leaveTypeId, [FromBody] UpdateLeaveTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _leaveTypes.UpdateAsync(leaveTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,85 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase
{
private readonly IPayrollRunService _payrollRuns;
private readonly ICurrentUser _currentUser;
public PayrollRunsController(IPayrollRunService payrollRuns, ICurrentUser currentUser)
{
_payrollRuns = payrollRuns;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PayrollRunDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PayrollRunDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? periodYear, [FromQuery] int? periodMonth,
[FromQuery] PayrollRunStatus? status, CancellationToken ct)
=> Ok(await _payrollRuns.ListAsync(query, periodYear, periodMonth, status, ct));
[HttpGet("{payrollRunId:int}")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollRunDto>> GetById(int payrollRunId, CancellationToken ct)
{
var result = await _payrollRuns.GetAsync(payrollRunId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payrollRunId:int}/lines")]
[ProducesResponseType(typeof(List<PayrollLineDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollLineDto>>> ListLines(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ListLinesAsync(payrollRunId, ct));
[HttpGet("{payrollRunId:int}/lines/{lineId:int}")]
[ProducesResponseType(typeof(PayrollLineDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollLineDetailDto>> GetLine(int payrollRunId, int lineId, CancellationToken ct)
{
var result = await _payrollRuns.GetLineAsync(payrollRunId, lineId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PayrollRunDto>> Generate([FromBody] GeneratePayrollRunRequest request, CancellationToken ct)
{
var result = await _payrollRuns.GenerateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
}
[HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<List<PayslipDto>>> GeneratePayslips(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct));
}
@@ -0,0 +1,33 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase
{
private readonly IPayrollStatutorySettingService _settings;
private readonly ICurrentUser _currentUser;
public PayrollStatutorySettingsController(IPayrollStatutorySettingService settings, ICurrentUser currentUser)
{
_settings = settings;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<PayrollStatutorySettingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
{
var result = await _settings.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-statutory-settings/{result.PayrollStatutorySettingId}", result);
}
}
@@ -0,0 +1,32 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase
{
private readonly IPayslipService _payslips;
public PayslipsController(IPayslipService payslips) => _payslips = payslips;
[HttpGet("{payslipId:int}")]
[ProducesResponseType(typeof(PayslipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayslipDto>> GetById(int payslipId, CancellationToken ct)
{
var result = await _payslips.GetAsync(payslipId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payslipId:int}/view")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> View(int payslipId, CancellationToken ct)
{
var html = await _payslips.RenderHtmlAsync(payslipId, ct);
return html is null ? NotFound() : Content(html, "text/html");
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase
{
private readonly ISalaryComponentService _components;
public SalaryComponentsController(ISalaryComponentService components) => _components = components;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalaryComponentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalaryComponentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _components.ListAsync(query, status, ct));
[HttpGet("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalaryComponentDto>> GetById(int salaryComponentId, CancellationToken ct)
{
var result = await _components.GetAsync(salaryComponentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalaryComponentDto>> Create([FromBody] CreateSalaryComponentRequest request, CancellationToken ct)
{
var result = await _components.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
}
[HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalaryComponentDto>> Update(int salaryComponentId, [FromBody] UpdateSalaryComponentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _components.UpdateAsync(salaryComponentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,28 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase
{
private readonly ITaxSlabService _taxSlabs;
public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs;
[HttpGet]
[ProducesResponseType(typeof(List<TaxSlabDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TaxSlabDto>> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct)
{
var result = await _taxSlabs.CreateAsync(request, ct);
return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result);
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
private readonly IWorkShiftService _shifts;
public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WorkShiftDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WorkShiftDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _shifts.ListAsync(query, status, ct));
[HttpGet("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WorkShiftDto>> GetById(int workShiftId, CancellationToken ct)
{
var result = await _shifts.GetAsync(workShiftId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WorkShiftDto>> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct)
{
var result = await _shifts.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<WorkShiftDto>> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
}
@@ -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;
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2D.3).</summary>
[Route("api/v1/production-runs")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> 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<ActionResult<RunGraphDto>> 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<ActionResult<RunGraphDto>> 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<ActionResult<RunStageDto>> 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<ActionResult<StartStageResultDto>> 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<ActionResult<RunStageDto>> 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<ActionResult<ApproveStageResultDto>> 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<ActionResult<TransferResultDto>> 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<ActionResult<ReturnLeftoverResultDto>> 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<ActionResult<RejectIntakeResultDto>> 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));
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TerminalRejectResultDto>> 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<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CancelAsync(runId, request, ct));
}
@@ -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;
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
[Route("api/v1/production-templates")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> 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<ActionResult<TemplateGraphDto>> 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<ActionResult<TemplateGraphDto>> 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<ActionResult<TemplateGraphDto>> 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<IActionResult> SetStatus(
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
{
await _templates.SetStatusAsync(templateId, request.Status, ct);
return NoContent();
}
}
+13 -1
View File
@@ -1,4 +1,5 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -13,8 +14,19 @@ namespace ERPCore.Controllers;
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
public UsersController(IUserManagementService users) => _users = users;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links)
{
_users = users;
_links = links;
}
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<EmployeeMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct)));
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
+3
View File
@@ -14,4 +14,7 @@ public static class DocumentTypes
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
public const string Production = "PRD";
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/day attendance row (FR-HR-ATT). <see cref="WorkShiftId"/> is
/// snapshotted from the employee's shift at ingestion time (docs/12-BACKEND-HRM.md
/// A.3) so a later shift reassignment never retroactively changes historical
/// Late/OT figures. Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceRecord
{
public int AttendanceRecordId { get; set; }
public int? AttendanceUploadBatchId { get; set; }
public AttendanceUploadBatch? AttendanceUploadBatch { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime AttendanceDate { get; set; }
public TimeSpan? CheckIn { get; set; }
public TimeSpan? CheckOut { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int WorkingMinutes { get; set; }
public int LateMinutes { get; set; }
public int EarlyLeaveMinutes { get; set; }
public int OvertimeMinutes { get; set; }
public AttendanceStatus AttendanceStatus { get; set; }
public RowValidationStatus RowValidationStatus { get; set; } = RowValidationStatus.Valid;
public int? DuplicateOfAttendanceRecordId { get; set; }
public string? Notes { get; set; }
public bool IsManualOverride { get; set; }
public int? EditedBy { get; set; }
public DateTime? EditedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Attendance upload batch (FR-HR-ATT) — the transactional document driving the
/// exact status flow Draft→Validated→Confirmed→UsedInPayroll. Scoped to exactly
/// one payroll period, numbered via <see cref="NumberSequence"/> (docType "ATT").
/// Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceUploadBatch
{
public int AttendanceUploadBatchId { get; set; }
public string DocNo { get; set; } = string.Empty;
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
public AttendanceSourceType SourceType { get; set; }
public string? OriginalFileName { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public AttendanceBatchStatus Status { get; set; } = AttendanceBatchStatus.Draft;
public int? ConfirmedBy { get; set; }
public DateTime? ConfirmedAt { get; set; }
public int RowCountTotal { get; set; }
public int RowCountDuplicate { get; set; }
public int RowCountError { get; set; }
public uint RowVersion { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>.
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Branch
{
public int BranchId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Department master (FR-HR-MD-01) — unlimited self-nesting for a real org chart
/// (unlike the two-level-capped <see cref="Category"/>); cycle prevention is a
/// service-level check on write, not a DB constraint. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Department
{
public int DepartmentId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int? ParentDepartmentId { get; set; }
public Department? ParentDepartment { get; set; }
public int? HeadEmployeeId { get; set; }
public Employee? HeadEmployee { get; set; }
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Job title master (FR-HR-MD-01), standalone — not FK'd to Department, since a
/// title like "Accountant" can exist in multiple departments. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Designation
{
public int DesignationId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,76 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
/// account): not every employee has a login, and not every login belongs to an
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
/// year-scoping is the wrong shape for an identifier that must never look "reset".
/// </summary>
public class Employee
{
public int EmployeeId { get; set; }
public string EmployeeCode { get; set; } = string.Empty;
// Identity
public string FullName { get; set; } = string.Empty;
public string? Nic { get; set; }
public DateTime? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
public string? Nationality { get; set; }
public string? ProfilePhotoPath { get; set; }
// Contact
/// <summary>The field used for the bidirectional Employee&lt;-&gt;User email cross-check.</summary>
public string? Email { get; set; }
public string? PersonalMobile { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? PostalCode { get; set; }
public string? Country { get; set; }
// Emergency contact
public string? EmergencyContactName { get; set; }
public string? EmergencyContactRelationship { get; set; }
public string? EmergencyContactPhone { get; set; }
// Employment
public DateTime HireDate { get; set; }
public DateTime? ConfirmationDate { get; set; }
public DateTime? LastWorkingDate { get; set; }
public int DepartmentId { get; set; }
public Department? Department { get; set; }
public int DesignationId { get; set; }
public Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; }
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int? ReportingManagerId { get; set; }
public Employee? ReportingManager { get; set; }
// Statutory (Sri Lanka)
public string? EpfNumber { get; set; }
public string? EtfNumber { get; set; }
public string? TaxIdentificationNumber { get; set; }
/// <summary>Optional login account link (unique — one User backs at most one Employee).</summary>
public int? UserId { get; set; }
public User? User { get; set; }
public EmployeeStatus Status { get; set; } = EmployeeStatus.Active;
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public int? UpdatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Employee bank account (FR-HR-MD-03), one-to-many — a future split-payment
/// improvement is possible since this isn't a 1:1 scalar set. Exactly one row per
/// employee is <see cref="IsPrimary"/>; payroll disbursement targets it.
/// Model: docs/12-BACKEND-HRM.md Part C.2.
/// </summary>
public class EmployeeBankDetail
{
public int EmployeeBankDetailId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public string BankName { get; set; } = string.Empty;
public string BranchName { get; set; } = string.Empty;
public string AccountNumber { get; set; } = string.Empty;
public string AccountHolderName { get; set; } = string.Empty;
public string? SwiftCode { get; set; }
public bool IsPrimary { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Uploaded staff document (FR-HR-DOC-02..04) — the user's "Doc". <see cref="StoredFileName"/>/
/// <see cref="RelativePath"/> are server-generated (never the client's filename), so the
/// file is only ever reachable through <see cref="Services.Interfaces.IFileStorageService"/>,
/// never a guessable static path. Archived, not deleted, so the audit trail of what was
/// once on file is retained. Model: docs/12-BACKEND-HRM.md Part C.3.
/// </summary>
public class EmployeeDocument
{
public int EmployeeDocumentId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int HrDocumentTypeId { get; set; }
public HrDocumentType? HrDocumentType { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public DateTime? IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public string? Notes { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public int? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
public EmployeeDocumentStatus Status { get; set; } = EmployeeDocumentStatus.Active;
public uint RowVersion { get; set; }
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Loan/Advance (FR-HR-PAY-03) — <see cref="LoanKind"/> discriminates, structurally
/// identical otherwise. <see cref="OutstandingBalance"/> is denormalized (parallel to
/// <c>StockLayer.QtyRemaining</c>). Numbered via <see cref="NumberSequence"/> (docType "LOAN").
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class EmployeeLoan
{
public int EmployeeLoanId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public LoanKind LoanKind { get; set; }
public decimal PrincipalAmount { get; set; }
public decimal InterestRate { get; set; }
public decimal InstallmentAmount { get; set; }
public int NumberOfInstallments { get; set; }
public int StartYear { get; set; }
public int StartMonth { get; set; }
public decimal OutstandingBalance { get; set; }
public LoanStatus Status { get; set; } = LoanStatus.Active;
public int ApprovedBy { get; set; }
public DateTime ApprovedAt { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public List<LoanInstallment> Installments { get; set; } = new();
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Effective-dated salary structure header (FR-HR-PAY-02) — the audit trail a
/// salary revision needs (docs/12-BACKEND-HRM.md §13): exactly one row with
/// <see cref="EffectiveTo"/> null (the current one) per employee at a time.
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class EmployeeSalaryStructure
{
public int EmployeeSalaryStructureId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public decimal BasicSalary { get; set; }
public string Currency { get; set; } = "LKR";
public SalaryStructureStatus Status { get; set; } = SalaryStructureStatus.Active;
public int ApprovedBy { get; set; }
public DateTime ApprovedAt { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public List<EmployeeSalaryStructureLine> Lines { get; set; } = new();
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Entities;
/// <summary>Allowance/other-deduction line on a salary structure. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class EmployeeSalaryStructureLine
{
public int EmployeeSalaryStructureLineId { get; set; }
public int EmployeeSalaryStructureId { get; set; }
public EmployeeSalaryStructure? EmployeeSalaryStructure { get; set; }
public int SalaryComponentId { get; set; }
public SalaryComponent? SalaryComponent { get; set; }
public decimal Amount { get; set; }
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Labor category master (FR-HR-MD-01) — a master, not an enum, mirroring
/// <see cref="Brand"/>: employment categories change with company/labor-law
/// policy without wanting a code deploy. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class EmploymentType
{
public int EmploymentTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,24 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Staff document catalog (FR-HR-DOC-01) — the user's "DocType": a category of
/// document (NIC, contract, certificate...), not the uploaded file itself (see
/// <see cref="EmployeeDocument"/>, the "Doc"). Deactivated, not deleted, when
/// referenced. Model: docs/12-BACKEND-HRM.md Part C.3.
/// </summary>
public class HrDocumentType
{
public int HrDocumentTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public HrDocumentCategory Category { get; set; }
public bool RequiredAtOnboarding { get; set; }
public bool ExpiryTracked { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
+8
View File
@@ -33,6 +33,14 @@ public class Item
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
/// <summary>
/// Optional fixed selling price used by Sales only. <c>null</c> 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).
/// </summary>
public decimal? SalePrice { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -0,0 +1,24 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/type/year leave entitlement (FR-HR-LV-03). Unique on
/// (EmployeeId, LeaveTypeId, Year). RemainingDays is a computed projection, not
/// stored. Model: docs/12-BACKEND-HRM.md Part C.5.
/// </summary>
public class LeaveBalance
{
public int LeaveBalanceId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int LeaveTypeId { get; set; }
public LeaveType? LeaveType { get; set; }
public int Year { get; set; }
public decimal EntitledDays { get; set; }
public decimal TakenDays { get; set; }
public decimal CarriedForwardDays { get; set; }
public decimal AdjustmentDays { get; set; }
public uint RowVersion { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Leave request (FR-HR-LV-02) — transactional document, numbered via
/// <see cref="NumberSequence"/> (docType "LV"). Model: docs/12-BACKEND-HRM.md Part C.5.
/// </summary>
public class LeaveRequest
{
public int LeaveRequestId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int LeaveTypeId { get; set; }
public LeaveType? LeaveType { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public decimal DaysCount { get; set; }
public string? Reason { get; set; }
public LeaveRequestStatus Status { get; set; } = LeaveRequestStatus.Draft;
public int? ApprovedBy { get; set; }
public DateTime? ApprovedAt { get; set; }
public string? RejectionReason { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>Leave type master (FR-HR-LV-01). Model: docs/12-BACKEND-HRM.md Part C.5.</summary>
public class LeaveType
{
public int LeaveTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsPaid { get; set; } = true;
/// <summary>Feeds Payroll's No-Pay deduction when true (docs/12-BACKEND-HRM.md §6).</summary>
public bool CountsAsNoPay { get; set; }
public decimal AccrualPerYear { get; set; }
public bool CarryForwardAllowed { get; set; }
public int? MaxCarryForwardDays { get; set; }
public bool RequiresApproval { get; set; } = true;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Loan installment ledger row. <see cref="PayrollRunId"/> is stamped only when the
/// consuming <see cref="PayrollRun"/> reaches Locked (docs/12-BACKEND-HRM.md A.4).
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class LoanInstallment
{
public int LoanInstallmentId { get; set; }
public int EmployeeLoanId { get; set; }
public EmployeeLoan? EmployeeLoan { get; set; }
public int InstallmentNumber { get; set; }
public int DueYear { get; set; }
public int DueMonth { get; set; }
public decimal ScheduledAmount { get; set; }
public decimal? PaidAmount { get; set; }
public int? PayrollRunId { get; set; }
public PayrollRun? PayrollRun { get; set; }
public LoanInstallmentStatus Status { get; set; } = LoanInstallmentStatus.Pending;
public uint RowVersion { get; set; }
}
@@ -0,0 +1,41 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-employee payroll summary row (FR-HR-PAY-05). EpfEmployerAmount/EtfEmployerAmount
/// are informational/liability only, never subtracted from NetSalary
/// (docs/12-BACKEND-HRM.md B.4). Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollLine
{
public int PayrollLineId { get; set; }
public int PayrollRunId { get; set; }
public PayrollRun? PayrollRun { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public decimal BasicSalary { get; set; }
public decimal TotalAllowances { get; set; }
public decimal OvertimeAmount { get; set; }
public decimal GrossSalary { get; set; }
public decimal LateDeductionAmount { get; set; }
public decimal NoPayAmount { get; set; }
public decimal LoanDeductionAmount { get; set; }
public decimal EpfEmployeeAmount { get; set; }
public decimal EpfEmployerAmount { get; set; }
public decimal EtfEmployerAmount { get; set; }
public decimal TaxAmount { get; set; }
public decimal OtherDeductionsAmount { get; set; }
public decimal NetSalary { get; set; }
public int WorkingDays { get; set; }
public int PresentDays { get; set; }
public int AbsentDays { get; set; }
public int LeaveDays { get; set; }
public int OtMinutesTotal { get; set; }
public int LateMinutesTotal { get; set; }
public uint RowVersion { get; set; }
public List<PayrollLineComponent> Components { get; set; } = new();
}
@@ -0,0 +1,18 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>The detailed Basic/Transport/Meal/OT/Late/No-Pay/Loan/EPF/ETF/Tax breakdown. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class PayrollLineComponent
{
public int PayrollLineComponentId { get; set; }
public int PayrollLineId { get; set; }
public PayrollLine? PayrollLine { get; set; }
public PayrollLineComponentCategory ComponentCategory { get; set; }
/// <summary>Set only for structure-sourced Allowance/OtherDeduction lines; null for system-computed lines.</summary>
public int? SalaryComponentId { get; set; }
public SalaryComponent? SalaryComponent { get; set; }
public string Label { get; set; } = string.Empty;
public decimal Amount { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Payroll run (FR-HR-PAY-05/06) — the transactional document. Numbered via
/// <see cref="NumberSequence"/> (docType "PAY"). Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollRun
{
public int PayrollRunId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int PeriodYear { get; set; }
public int PeriodMonth { get; set; }
/// <summary>Null = company-wide run.</summary>
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public PayrollRunStatus Status { get; set; } = PayrollRunStatus.Draft;
public int GeneratedBy { get; set; }
public DateTime GeneratedAt { get; set; }
public int? ApprovedBy { get; set; }
public DateTime? ApprovedAt { get; set; }
public int? LockedBy { get; set; }
public DateTime? LockedAt { get; set; }
public int? UnlockedBy { get; set; }
public DateTime? UnlockedAt { get; set; }
public string? UnlockReason { get; set; }
public uint RowVersion { get; set; }
public List<PayrollLine> Lines { get; set; } = new();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Effective-dated EPF/ETF rates (FR-HR-PAY-04) — Sri Lanka defaults (EPF 8%
/// employee / 12% employer, ETF 3% employer-only), configurable since government
/// rates can change. Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollStatutorySetting
{
public int PayrollStatutorySettingId { get; set; }
public decimal EpfEmployeeRate { get; set; } = 0.08m;
public decimal EpfEmployerRate { get; set; } = 0.12m;
public decimal EtfEmployerRate { get; set; } = 0.03m;
public decimal OtMultiplierDefault { get; set; } = 1.5m;
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Thin generation/release marker over a <see cref="PayrollLine"/> — ships as an
/// HTML print view in this phase, per the confirmed decision (no PDF dependency).
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class Payslip
{
public int PayslipId { get; set; }
public int PayrollLineId { get; set; }
public PayrollLine? PayrollLine { get; set; }
public DateTime GeneratedAt { get; set; }
public DateTime? ReleasedAt { get; set; }
public int? ReleasedBy { get; set; }
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One execution instance of a template (FR-MFG-08), numbered <c>PRD-2026-00001</c>.
/// Every stage, input, output and edge is <b>copied</b> from the template at creation
/// with quantities scaled by <see cref="ScaleFactor"/>, so a completed run stays
/// readable even if the template is later edited (FR-MFG-06).
/// <para>The run's <b>cost pool</b> is derived, never stored:
/// <c>Σ RunStageInput.ConsumedValue Σ RunStageInput.ReturnedValue</c>. The terminal
/// approve divides it by the good quantity to cost the finished layer, then closes it
/// (FR-MFG-13, <c>409 RUN_COST_CLOSED</c>).</para>
/// Mutable aggregate with a <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
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; }
/// <summary>Stock inputs are consumed from, and the finished good received into, this warehouse.</summary>
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.</summary>
public int? OutputBinId { get; set; }
public Bin? OutputBin { get; set; }
/// <summary>Target quantity of the finished item; drives <see cref="ScaleFactor"/>.</summary>
public decimal TargetQty { get; set; }
/// <summary><c>TargetQty / terminalOutput.QtyPerBatch</c>, applied to every copied quantity.</summary>
public decimal ScaleFactor { get; set; }
public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
/// <summary>Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.</summary>
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; }
/// <summary>Set by the terminal approve only. A cancelled run leaves this null.</summary>
public DateTime? CompletedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<RunStage> Stages { get; set; } = new List<RunStage>();
public ICollection<RunEdge> Edges { get; set; } = new List<RunEdge>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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, <c>409 TEMPLATE_IN_USE</c>) — edit-lock replaces versioning, which is
/// why runs copy display fields at creation. Mutable aggregate with a
/// <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
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;
/// <summary>
/// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
/// verbatim and never interpreted server-side.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<TemplateStage> Stages { get; set; } = new List<TemplateStage>();
public ICollection<StageEdge> Edges { get; set; } = new List<StageEdge>();
public ICollection<ProductionRun> Runs { get; set; } = new List<ProductionRun>();
}
@@ -0,0 +1,28 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A parent → child arrow copied from the template's <see cref="StageEdge"/> set at run
/// creation.
/// <para><b>Addition to docs/30 Part C (recorded).</b> The doc's entity model has no run
/// edge table, but the run graph needs its own copy: deriving edges at read time through
/// <c>RunStage.TemplateStageId → STAGE_EDGE</c> 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.</para>
/// <para>Used for the run canvas, child-readiness evaluation and reject-intake's
/// "delivering parents". Note that <b>WIP delivery is routed by
/// <c>RunStageInput.FromRunOutputId</c>, not by these edges</b> — an edge is display and
/// validation only.</para>
/// </summary>
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; }
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One stage of a run — a copy of a <see cref="TemplateStage"/> taken at run creation
/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
/// <para><b>Whether this stage is terminal is derived</b>, never stored: a stage is
/// terminal when it has no outbound <see cref="RunEdge"/>. Storing it would let it
/// drift from the edge set.</para>
/// </summary>
public class RunStage
{
public int RunStageId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>
/// Provenance link back to the template stage. <b>Nullable</b>: 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 <c>SET NULL</c> rather
/// than blocking the edit forever. Everything needed to display a historical run is
/// copied below, which is exactly what FR-MFG-06 anticipates.
/// </summary>
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;
/// <summary>Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).</summary>
public DateTime? ActualStartAt { get; set; }
public DateTime? ActualEndAt { get; set; }
/// <summary>Copied from the template stage; definitions survive a rework.</summary>
public string FieldDefs { get; set; } = "[]";
/// <summary>Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.</summary>
public string? FieldValues { get; set; }
/// <summary>
/// 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 <c>Done</c> and posting two
/// receipts. See the idempotency note in <c>ProductionRunService</c>.
/// </summary>
public uint RowVersion { get; set; }
public ICollection<RunStageInput> Inputs { get; set; } = new List<RunStageInput>();
public ICollection<RunStageOutput> Outputs { get; set; } = new List<RunStageOutput>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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.
/// <para><b>Addition to docs/30 Part C (recorded):</b> <see cref="RunId"/>. 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 <see cref="RunStageId"/> optional and the timeline a single query.</para>
/// </summary>
public class RunStageEvent
{
public int EventId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>Null for run-level events (Cancel).</summary>
public int? RunStageId { get; set; }
public RunStage? RunStage { get; set; }
public RunStageEventType EventType { get; set; }
public string? Note { get; set; }
/// <summary>
/// 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 <c>ProductionJson</c>.
/// </summary>
public string? Payload { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,55 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One input line of a run stage — a copy of a <see cref="StageInput"/> with its
/// quantity scaled at creation, plus the live consumption/delivery figures.
/// Model: docs/30 Part C.
/// <para><b>Stock inputs</b> accumulate <see cref="ConsumedQty"/>/<see cref="ConsumedValue"/>
/// at each start and <see cref="ReturnedQty"/>/<see cref="ReturnedValue"/> on leftover
/// return or run cancel. Those four columns are the whole cost pool
/// (<c>Σ consumed Σ returned</c>) and are deliberately <b>not</b> reset by a terminal
/// reject — already-consumed material stays in the pool (FR-MFG-16).</para>
/// <para><b>Upstream inputs</b> accumulate <see cref="DeliveredQty"/> as parent stages
/// transfer WIP in. The stage becomes Ready only when every upstream input has
/// <c>DeliveredQty &gt;= PlannedQty</c> (FR-MFG-09, an all-parents join).</para>
/// </summary>
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; }
/// <summary>The parent output feeding this input. This — not <see cref="RunEdge"/> — is what routes a transfer.</summary>
public int? FromRunOutputId { get; set; }
public RunStageOutput? FromRunOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
public decimal PlannedQty { get; set; }
/// <summary>
/// Stock inputs only, in the item's <b>base</b> UOM. A start consumes
/// <c>max(0, PlannedQty ConsumedQty)</c> 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).
/// </summary>
public decimal ConsumedQty { get; set; }
public decimal ConsumedValue { get; set; }
/// <summary>Upstream inputs only: accumulated by parent transfers.</summary>
public decimal DeliveredQty { get; set; }
/// <summary>Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.</summary>
public decimal ReturnedQty { get; set; }
public decimal ReturnedValue { get; set; }
}
@@ -0,0 +1,43 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One output line of a run stage — a copy of a <see cref="StageOutput"/> with its
/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
/// Model: docs/30 Part C.
/// <para><b>Available to transfer is derived, never stored:</b>
/// <c>ProducedQty ScrappedQty TransferredQty</c>. Every transfer path checks it and
/// raises <c>422 TRANSFER_EXCEEDS_AVAILABLE</c> (FR-MFG-12).</para>
/// <para>Scrap cost is <b>absorbed</b> into the run cost pool as normal yield loss — no
/// write-off ledger entry is posted (FR-MFG-11).</para>
/// </summary>
public class RunStageOutput
{
public int RunOutputId { get; set; }
public int RunStageId { get; set; }
public RunStage? RunStage { get; set; }
/// <summary>Null on intermediate (WIP) outputs; set on the terminal output — the finished good.</summary>
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; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
public decimal ProducedQty { get; set; }
public decimal ScrappedQty { get; set; }
/// <summary>Mandatory when <see cref="ScrappedQty"/> &gt; 0, context <c>Production</c> (FR-MFG-11).</summary>
public int? ScrapReasonCodeId { get; set; }
public ReasonCode? ScrapReason { get; set; }
/// <summary>Total WIP handed to children so far, across approve and any later partial transfers.</summary>
public decimal TransferredQty { get; set; }
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>Allowance/ad hoc deduction master (FR-HR-PAY-01). Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class SalaryComponent
{
public int SalaryComponentId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public SalaryComponentType ComponentType { get; set; }
public bool IsTaxable { get; set; }
public bool IsEpfEtfApplicable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// 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
/// <c>ProductionGraphValidator</c> on every save, not by the database (FR-MFG-02).
/// Model: docs/30 Part C.
/// </summary>
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; }
}
@@ -0,0 +1,39 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
/// shapes applies, enforced by <c>ProductionGraphValidator</c>:
/// <list type="bullet">
/// <item><see cref="StageInputSource.Stock"/> — <see cref="ItemId"/> set,
/// <see cref="FromOutputId"/> null. FIFO-consumed from the run warehouse at stage
/// start. Allowed on <i>any</i> stage, e.g. packaging added late.</item>
/// <item><see cref="StageInputSource.Upstream"/> — <see cref="FromOutputId"/> set to
/// an output of a <b>direct parent</b> stage, <see cref="ItemId"/> null. Flows as
/// internal WIP and never touches stock or the ledger.</item>
/// </list>
/// Model: docs/30 Part C.
/// </summary>
public class StageInput
{
public int InputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; null when Upstream.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must belong to a direct parent.</summary>
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; }
}
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
/// <b>internal WIP only</b> — <see cref="ItemId"/> 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 <b>must</b> reference a real Item (the finished good), which
/// is what the production receipt creates a layer for. Model: docs/30 Part C.
/// </summary>
public class StageOutput
{
public int OutputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
/// <summary>Null on intermediate stages; required on the terminal stage.</summary>
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; }
}
@@ -0,0 +1,19 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Configurable APIT-style marginal tax slab (FR-HR-PAY-04) — government slabs
/// change with the yearly budget, so this is never hardcoded. <see cref="UpperBound"/>
/// null means "and above". Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class TaxSlab
{
public int TaxSlabId { get; set; }
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
public decimal Rate { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,36 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
/// estimated duration, a formula (<see cref="Inputs"/> + <see cref="Outputs"/>) and
/// custom field definitions. Model: docs/30 Part C.
/// </summary>
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;
/// <summary>Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).</summary>
public string? RoleLabel { get; set; }
public int EstimatedMinutes { get; set; }
/// <summary>Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
/// <summary>
/// Custom field definitions as jsonb: <c>[{ key, label, type, options?, required }]</c>
/// (FR-MFG-07). Held as a pre-serialized string, matching the <c>AuditLog.ChangeSet</c>
/// precedent; always written through <c>ProductionJson</c> so the column can only ever
/// hold canonical JSON.
/// </summary>
public string FieldDefs { get; set; } = "[]";
public ICollection<StageInput> Inputs { get; set; } = new List<StageInput>();
public ICollection<StageOutput> Outputs { get; set; } = new List<StageOutput>();
}
+8
View File
@@ -23,6 +23,14 @@ public class User
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
public Guid? AuthUserId { get; set; }
/// <summary>
/// Mirrors the AuthHex identity's email (backfilled at create time by
/// <c>UsersController.Create</c>, best-effort by JIT provisioning otherwise).
/// Used only for the Employee&lt;-&gt;User cross-link soft match (docs/12-BACKEND-HRM.md
/// A.5) — never for authentication, which stays AuthHex's responsibility.
/// </summary>
public string? Email { get; set; }
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; }
public Role? Role { get; set; }
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Attendance baseline (FR-HR-MD-01) — the shift definition Late/Early/OT figures
/// are computed against (docs/12-BACKEND-HRM.md A.3, C.1). <see cref="IsOvernight"/>
/// is explicit rather than inferred from End&lt;Start, since that comparison alone
/// is ambiguous for a shift that starts and ends the same clock time next day.
/// </summary>
public class WorkShift
{
public int WorkShiftId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public TimeSpan StartTime { get; set; }
public TimeSpan EndTime { get; set; }
public bool IsOvernight { get; set; }
public int GraceMinutes { get; set; } = 15;
public int BreakMinutes { get; set; } = 60;
public int StandardWorkingMinutes { get; set; } = 480;
public decimal OtMultiplier { get; set; } = 1.5m;
/// <summary>Bitmask, bit 0 = Monday .. bit 6 = Sunday.</summary>
public int WorkingDaysMask { get; set; } = 0b0111111;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Attendance upload batch lifecycle (FR-HR-ATT, docs/12-BACKEND-HRM.md C.4) —
/// exactly the flow specified by the business: once <see cref="Confirmed"/> it
/// becomes payroll's source of truth; once <see cref="UsedInPayroll"/> it is
/// immutable even to Unlock (a payroll run must be unlocked/regenerated first).
/// </summary>
public enum AttendanceBatchStatus
{
Draft,
Validated,
Confirmed,
UsedInPayroll
}
@@ -0,0 +1,13 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Origin of an attendance batch (docs/12-BACKEND-HRM.md C.4). <see cref="BiometricDevice"/>
/// is a reserved future integration seam (docs §B.7) — no device feed exists yet.
/// </summary>
public enum AttendanceSourceType
{
Excel,
Csv,
Manual,
BiometricDevice
}
@@ -0,0 +1,17 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Per-day attendance classification (docs/12-BACKEND-HRM.md C.4). Lateness/OT are
/// derived facts (LateMinutes/OvertimeMinutes &gt; 0) on an otherwise Present record,
/// not separate statuses. <see cref="OnLeave"/> is derived from an overlapping
/// Approved LeaveRequest with no uploaded punch (§6).
/// </summary>
public enum AttendanceStatus
{
Present,
Absent,
HalfDay,
OnLeave,
Holiday,
WeekOff
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 <c>fieldType</c>).
/// Lives inside the <c>field_defs</c> 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.
/// <see cref="Select"/> is the only type that reads <c>options</c>.
/// </summary>
public enum CustomFieldType
{
Text,
Number,
Checkbox,
Date,
Select
}
@@ -0,0 +1,11 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Uploaded staff document status (docs/12-BACKEND-HRM.md C.3). Never hard-deleted —
/// archived instead, mirroring the deactivate-not-delete master convention (FR-MD-08).
/// </summary>
public enum EmployeeDocumentStatus
{
Active,
Archived
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Employee lifecycle status (docs/12-BACKEND-HRM.md C.2). An Employee is never
/// hard-deleted; separation is recorded here instead (with <c>LastWorkingDate</c>
/// set), matching the deactivate-not-delete convention for masters (FR-MD-08)
/// taken one step further since the record must be retained for audit/payroll history.
/// </summary>
public enum EmployeeStatus
{
Active,
Suspended,
Resigned,
Terminated,
Retired
}
+9
View File
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Employee gender (docs/12-BACKEND-HRM.md C.2). Optional field, stored as a string.</summary>
public enum Gender
{
Male,
Female,
Other
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>Staff document catalog category (docs/12-BACKEND-HRM.md C.3). Stored as a string.</summary>
public enum HrDocumentCategory
{
Identity,
Educational,
Contract,
Certification,
Statutory,
Other
}
@@ -0,0 +1,11 @@
namespace ERPCore.Domain.Enums;
/// <summary>Leave request approval lifecycle (FR-HR-LV-02, docs/12-BACKEND-HRM.md C.5).</summary>
public enum LeaveRequestStatus
{
Draft,
Submitted,
Approved,
Rejected,
Cancelled
}
@@ -0,0 +1,13 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// An installment flips Pending→Deducted only when its PayrollRun reaches Locked
/// (docs/12-BACKEND-HRM.md A.4) — never at Generate/Draft, so a discarded/regenerated
/// draft never prematurely consumes it.
/// </summary>
public enum LoanInstallmentStatus
{
Pending,
Deducted,
Skipped
}
+8
View File
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Loan vs Advance discriminator (docs/12-BACKEND-HRM.md C.6) — structurally identical, differ only in intent/labeling.</summary>
public enum LoanKind
{
Loan,
Advance
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum LoanStatus
{
Active,
Closed,
Cancelled
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// EmployerContribution lines (EPF-employer, ETF) are informational/liability only —
/// never subtracted from Net Salary (docs/12-BACKEND-HRM.md B.4).
/// </summary>
public enum PayrollLineComponentCategory
{
Earning,
Deduction,
EmployerContribution
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// PayrollRun approval workflow (FR-HR-PAY-06, docs/12-BACKEND-HRM.md C.6). Maps the
/// business's 5-step flow to 3 stored states: Generate→Draft, Review is a human
/// action (not stored), Approve→Approved, Lock→Locked (the point loan installments
/// and attendance batches are stamped consumed — see A.4), Generate Payslips is an
/// action gated on Locked, not a state.
/// </summary>
public enum PayrollRunStatus
{
Draft,
Approved,
Locked
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
/// <see cref="InProgress"/> 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.
/// </summary>
public enum ProductionRunStatus
{
InProgress,
Completed,
Cancelled
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Status of one stage within a production run (docs/30 §B.4, §D.5).
/// <code>
/// 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)
/// </code>
/// 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
/// <see cref="RunStageEventType"/> instead. Stored as a string.
/// </summary>
public enum ProductionStageStatus
{
Waiting,
Ready,
InProgress,
Done,
Approved
}
@@ -5,5 +5,8 @@ public enum ReasonContext
{
Adjustment,
Return,
Count
Count,
/// <summary>Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).</summary>
Production
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>Per-record outcome of the attendance upload validation pipeline (docs/12-BACKEND-HRM.md §B.3.4).</summary>
public enum RowValidationStatus
{
Valid,
DuplicateWithinBatch,
DuplicateConfirmed,
EmployeeNotFound,
InvalidDateTime,
Error
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Kind of entry in a run's immutable history (docs/30 Part C, <c>RUN_STAGE_EVENT</c>).
/// Every mutating action on a run writes exactly one event carrying who/when plus a
/// jsonb payload; <see cref="TerminalReject"/> additionally snapshots the whole run's
/// figures for the rework pass being discarded (FR-MFG-16). Stored as a string.
/// </summary>
public enum RunStageEventType
{
Start,
Complete,
Approve,
Transfer,
RejectIntake,
TerminalReject,
LeftoverReturn,
Cancel,
QuantityEdit
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// SalaryComponent master category (docs/12-BACKEND-HRM.md C.6) — for Allowances and
/// ad hoc Other Deductions only. OT/Late/No-Pay/Loan/EPF/ETF/Tax are system-computed,
/// not user-defined components, to avoid a generic formula engine nobody asked for.
/// </summary>
public enum SalaryComponentType
{
Earning,
Deduction
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Effective-dated salary structure status (docs/12-BACKEND-HRM.md C.6) — exactly one Active (open-ended) row per employee at a time.</summary>
public enum SalaryStructureStatus
{
Active,
Superseded
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Where a stage input's material comes from (FR-MFG-04; docs/30 §D.5).
/// <see cref="Stock"/> inputs reference an Item and are FIFO-consumed from the run
/// warehouse when the stage starts. <see cref="Upstream"/> inputs reference a direct
/// parent stage's output and flow as internal WIP — they never touch the ledger.
/// Stored as a string.
/// </summary>
public enum StageInputSource
{
Stock,
Upstream
}
@@ -0,0 +1,34 @@
namespace ERPCore.Domain;
/// <summary>
/// Additional <c>STOCK_LEDGER.source_doc_type</c> values for manufacturing movements
/// (docs/30 §A.2). <c>source_doc_id</c> is always the <c>run_id</c>, so
/// <c>LIKE 'PRD%'</c> traces every stock movement a run caused.
/// </summary>
/// <remarks>
/// <para><b>Deviation from docs/30 §A.2 (recorded).</b> The doc proposes the long names
/// <c>ProductionIssue</c>/<c>ProductionReceipt</c>/<c>ProductionReturn</c>/
/// <c>ProductionCancelReturn</c>, but both <c>stock_ledger.SourceDocType</c> and
/// <c>journal_entry_stubs.SourceDocType</c> are <c>varchar(10)</c> and every existing
/// value is a short prefix (<c>GRN</c>, <c>TRF</c>, <c>ADJ</c>, <c>PRET</c>). 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.</para>
/// <para>These are <b>not</b> <see cref="DocumentTypes"/> entries — production issues no
/// document per movement. The run's own document number uses
/// <see cref="DocumentTypes.Production"/> (<c>PRD-2026-00001</c>).</para>
/// </remarks>
public static class LedgerSourceTypes
{
/// <summary>Stock consumed by a stage start (FR-MFG-10). Outbound.</summary>
public const string ProductionIssue = "PRDI";
/// <summary>Finished goods received at the terminal approve (FR-MFG-13). Inbound.</summary>
public const string ProductionReceipt = "PRDR";
/// <summary>Unconsumed material returned before receipt (FR-MFG-14). Inbound.</summary>
public const string ProductionReturn = "PRDL";
/// <summary>Net consumed stock returned when a run is cancelled (FR-MFG-17). Inbound.</summary>
public const string ProductionCancelReturn = "PRDC";
}
@@ -0,0 +1,24 @@
namespace ERPCore.Dtos.Dashboard;
/// <summary>
/// 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).
/// </summary>
public sealed record DashboardStatsDto(
int LowStockAlerts,
decimal OnHandTotal,
int OnHandWarehouses,
decimal StockValuationTotal,
IReadOnlyList<WarehouseValuationDto> StockValuationByWarehouse,
int PendingApprovalPurchaseOrders,
int PendingGrns,
int OpenRequisitions,
int PendingCounts,
int OpenRfqs);
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
public sealed record AttendanceUploadBatchDto(
int AttendanceUploadBatchId, string DocNo, DateTime PeriodStart, DateTime PeriodEnd,
AttendanceSourceType SourceType, string? OriginalFileName, int UploadedBy, DateTime UploadedAt,
AttendanceBatchStatus Status, int? ConfirmedBy, DateTime? ConfirmedAt,
int RowCountTotal, int RowCountDuplicate, int RowCountError);
public sealed class UploadAttendanceBatchMetadata
{
[Required] public DateTime PeriodStart { get; set; }
[Required] public DateTime PeriodEnd { get; set; }
}
public sealed record AttendanceRecordDto(
int AttendanceRecordId, int? AttendanceUploadBatchId, int EmployeeId, string? EmployeeCode, string? EmployeeName,
DateTime AttendanceDate, TimeSpan? CheckIn, TimeSpan? CheckOut,
int WorkingMinutes, int LateMinutes, int EarlyLeaveMinutes, int OvertimeMinutes,
AttendanceStatus AttendanceStatus, RowValidationStatus RowValidationStatus,
int? DuplicateOfAttendanceRecordId, string? Notes);
public sealed class UpdateAttendanceRecordRequest
{
public TimeSpan? CheckIn { get; set; }
public TimeSpan? CheckOut { get; set; }
public AttendanceStatus? AttendanceStatus { get; set; }
[StringLength(1000)] public string? Notes { get; set; }
}
public sealed class ResolveDuplicateRequest
{
[Required] public int RecordId { get; set; }
/// <summary>"keep" discards the other duplicate row(s); "discard" removes this row; "supersede" (cross-batch-confirmed only) replaces the prior confirmed record.</summary>
[Required, RegularExpression("^(keep|discard|supersede)$")]
public string Action { get; set; } = string.Empty;
}
public sealed class UnlockAttendanceBatchRequest
{
[Required, StringLength(500)] public string Reason { get; set; } = string.Empty;
}
+46
View File
@@ -0,0 +1,46 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
public sealed record HrDocumentTypeDto(
int HrDocumentTypeId, string Code, string Name, HrDocumentCategory Category,
bool RequiredAtOnboarding, bool ExpiryTracked, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateHrDocumentTypeRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required, EnumDataType(typeof(HrDocumentCategory))] public HrDocumentCategory Category { get; set; }
public bool RequiredAtOnboarding { get; set; }
public bool ExpiryTracked { get; set; }
}
public sealed class UpdateHrDocumentTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required, EnumDataType(typeof(HrDocumentCategory))] public HrDocumentCategory Category { get; set; }
public bool RequiredAtOnboarding { get; set; }
public bool ExpiryTracked { get; set; }
}
public sealed record EmployeeDocumentDto(
int EmployeeDocumentId, int EmployeeId, int HrDocumentTypeId, string? HrDocumentTypeName,
string OriginalFileName, string ContentType, long SizeBytes,
DateTime? IssueDate, DateTime? ExpiryDate, string? Notes,
int UploadedBy, DateTime UploadedAt, EmployeeDocumentStatus Status);
/// <summary>Metadata accompanying a multipart file upload (the file itself is bound separately).</summary>
public sealed class UploadEmployeeDocumentRequest
{
[Required] public int HrDocumentTypeId { get; set; }
public DateTime? IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
[StringLength(1000)] public string? Notes { get; set; }
}
public sealed class UpdateEmployeeDocumentStatusRequest
{
[Required, EnumDataType(typeof(EmployeeDocumentStatus))] public EmployeeDocumentStatus Status { get; set; }
}
+117
View File
@@ -0,0 +1,117 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
public sealed record EmployeeListItemDto(
int EmployeeId, string EmployeeCode, string FullName, string? Email,
int DepartmentId, string? DepartmentName, int DesignationId, string? DesignationName,
int EmploymentTypeId, string? EmploymentTypeName, int? BranchId, string? BranchName,
EmployeeStatus Status, bool HasUserLink, DateTime HireDate);
public sealed record EmployeeDetailDto(
int EmployeeId, string EmployeeCode, string FullName, string? Nic, DateTime? DateOfBirth,
Gender? Gender, string? Nationality, string? ProfilePhotoPath,
string? Email, string? PersonalMobile, string? AddressLine1, string? AddressLine2,
string? City, string? PostalCode, string? Country,
string? EmergencyContactName, string? EmergencyContactRelationship, string? EmergencyContactPhone,
DateTime HireDate, DateTime? ConfirmationDate, DateTime? LastWorkingDate,
int DepartmentId, int DesignationId, int EmploymentTypeId, int? BranchId, int WorkShiftId,
int? ReportingManagerId, string? EpfNumber, string? EtfNumber, string? TaxIdentificationNumber,
int? UserId, EmployeeStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateEmployeeRequest
{
[Required, StringLength(30)] public string EmployeeCode { get; set; } = string.Empty;
[Required, StringLength(200)] public string FullName { get; set; } = string.Empty;
[StringLength(30)] public string? Nic { get; set; }
public DateTime? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
[StringLength(100)] public string? Nationality { get; set; }
[EmailAddress, StringLength(320)] public string? Email { get; set; }
[StringLength(30)] public string? PersonalMobile { get; set; }
[StringLength(200)] public string? AddressLine1 { get; set; }
[StringLength(200)] public string? AddressLine2 { get; set; }
[StringLength(100)] public string? City { get; set; }
[StringLength(20)] public string? PostalCode { get; set; }
[StringLength(100)] public string? Country { get; set; }
[StringLength(200)] public string? EmergencyContactName { get; set; }
[StringLength(100)] public string? EmergencyContactRelationship { get; set; }
[StringLength(30)] public string? EmergencyContactPhone { get; set; }
[Required] public DateTime HireDate { get; set; }
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
public int? BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
[StringLength(30)] public string? EpfNumber { get; set; }
[StringLength(30)] public string? EtfNumber { get; set; }
[StringLength(30)] public string? TaxIdentificationNumber { get; set; }
/// <summary>Explicit, human-confirmed link to an existing User found via email-lookup — never automatic.</summary>
public int? LinkUserId { get; set; }
}
public sealed class UpdateEmployeeRequest
{
[Required, StringLength(200)] public string FullName { get; set; } = string.Empty;
[StringLength(30)] public string? Nic { get; set; }
public DateTime? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
[StringLength(100)] public string? Nationality { get; set; }
[EmailAddress, StringLength(320)] public string? Email { get; set; }
[StringLength(30)] public string? PersonalMobile { get; set; }
[StringLength(200)] public string? AddressLine1 { get; set; }
[StringLength(200)] public string? AddressLine2 { get; set; }
[StringLength(100)] public string? City { get; set; }
[StringLength(20)] public string? PostalCode { get; set; }
[StringLength(100)] public string? Country { get; set; }
[StringLength(200)] public string? EmergencyContactName { get; set; }
[StringLength(100)] public string? EmergencyContactRelationship { get; set; }
[StringLength(30)] public string? EmergencyContactPhone { get; set; }
public DateTime? ConfirmationDate { get; set; }
public DateTime? LastWorkingDate { get; set; }
[Required] public int DepartmentId { get; set; }
[Required] public int DesignationId { get; set; }
[Required] public int EmploymentTypeId { get; set; }
public int? BranchId { get; set; }
[Required] public int WorkShiftId { get; set; }
public int? ReportingManagerId { get; set; }
[StringLength(30)] public string? EpfNumber { get; set; }
[StringLength(30)] public string? EtfNumber { get; set; }
[StringLength(30)] public string? TaxIdentificationNumber { get; set; }
}
public sealed class UpdateEmployeeStatusRequest
{
[Required, EnumDataType(typeof(EmployeeStatus))] public EmployeeStatus Status { get; set; }
}
public sealed record EmployeeBankDetailDto(
int EmployeeBankDetailId, string BankName, string BranchName, string AccountNumber,
string AccountHolderName, string? SwiftCode, bool IsPrimary, EntityStatus Status);
public sealed class UpsertEmployeeBankDetailRequest
{
public int? EmployeeBankDetailId { get; set; }
[Required, StringLength(200)] public string BankName { get; set; } = string.Empty;
[Required, StringLength(200)] public string BranchName { get; set; } = string.Empty;
[Required, StringLength(50)] public string AccountNumber { get; set; } = string.Empty;
[Required, StringLength(200)] public string AccountHolderName { get; set; } = string.Empty;
[StringLength(20)] public string? SwiftCode { get; set; }
public bool IsPrimary { get; set; }
}
public sealed class ReplaceEmployeeBankDetailsRequest
{
[Required] public List<UpsertEmployeeBankDetailRequest> Items { get; set; } = new();
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Hrm;
/// <summary>Advisory match surfaced by the reverse-direction email-lookup (docs/12-BACKEND-HRM.md A.5).</summary>
public sealed record EmployeeMatchDto(int EmployeeId, string EmployeeCode, string FullName, string Email);
public sealed record UserMatchDto(int UserId, string Username, string DisplayName, string Email);
public sealed record EmployeeMatchResponse(EmployeeMatchDto? Match);
public sealed record UserMatchResponse(UserMatchDto? Match);
public sealed class LinkUserRequest
{
[Required] public int UserId { get; set; }
}
+67
View File
@@ -0,0 +1,67 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
public sealed record LeaveTypeDto(
int LeaveTypeId, string Code, string Name, bool IsPaid, bool CountsAsNoPay, decimal AccrualPerYear,
bool CarryForwardAllowed, int? MaxCarryForwardDays, bool RequiresApproval, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateLeaveTypeRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public bool IsPaid { get; set; } = true;
public bool CountsAsNoPay { get; set; }
[Range(0, 365)] public decimal AccrualPerYear { get; set; }
public bool CarryForwardAllowed { get; set; }
public int? MaxCarryForwardDays { get; set; }
public bool RequiresApproval { get; set; } = true;
}
public sealed class UpdateLeaveTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public bool IsPaid { get; set; }
public bool CountsAsNoPay { get; set; }
[Range(0, 365)] public decimal AccrualPerYear { get; set; }
public bool CarryForwardAllowed { get; set; }
public int? MaxCarryForwardDays { get; set; }
public bool RequiresApproval { get; set; }
}
public sealed record LeaveRequestDto(
int LeaveRequestId, string DocNo, int EmployeeId, string? EmployeeName, int LeaveTypeId, string? LeaveTypeName,
DateTime StartDate, DateTime EndDate, decimal DaysCount, string? Reason,
LeaveRequestStatus Status, int? ApprovedBy, DateTime? ApprovedAt, string? RejectionReason, DateTime CreatedAt);
public sealed class CreateLeaveRequestRequest
{
[Required] public int EmployeeId { get; set; }
[Required] public int LeaveTypeId { get; set; }
[Required] public DateTime StartDate { get; set; }
[Required] public DateTime EndDate { get; set; }
[StringLength(1000)] public string? Reason { get; set; }
}
public sealed class RejectLeaveRequestRequest
{
[Required, StringLength(1000)] public string Reason { get; set; } = string.Empty;
}
public sealed record LeaveBalanceDto(
int LeaveBalanceId, int EmployeeId, int LeaveTypeId, string? LeaveTypeName, int Year,
decimal EntitledDays, decimal TakenDays, decimal CarriedForwardDays, decimal AdjustmentDays, decimal RemainingDays);
public sealed class LeaveBalanceAdjustmentItem
{
[Required] public int LeaveTypeId { get; set; }
public decimal AdjustmentDays { get; set; }
}
public sealed class UpdateLeaveBalancesRequest
{
[Required] public int Year { get; set; }
[Required] public List<LeaveBalanceAdjustmentItem> Items { get; set; } = new();
}
+111
View File
@@ -0,0 +1,111 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
// Narrow DTOs for the five HRM org masters — server-controlled fields (status,
// ids, timestamps) excluded from create/update requests (02-SECURITY B.6/C.1).
public sealed record BranchDto(
int BranchId, string Code, string Name, string? Address, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateBranchRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
}
public sealed class UpdateBranchRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(500)] public string? Address { get; set; }
}
public sealed record DepartmentDto(
int DepartmentId, string Code, string Name, int? ParentDepartmentId, int? HeadEmployeeId,
int? BranchId, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateDepartmentRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public int? ParentDepartmentId { get; set; }
public int? HeadEmployeeId { get; set; }
public int? BranchId { get; set; }
}
public sealed class UpdateDepartmentRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public int? ParentDepartmentId { get; set; }
public int? HeadEmployeeId { get; set; }
public int? BranchId { get; set; }
}
public sealed record DesignationDto(
int DesignationId, string Code, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateDesignationRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateDesignationRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed record EmploymentTypeDto(
int EmploymentTypeId, string Code, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateEmploymentTypeRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateEmploymentTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed record WorkShiftDto(
int WorkShiftId, string Code, string Name, TimeSpan StartTime, TimeSpan EndTime, bool IsOvernight,
int GraceMinutes, int BreakMinutes, int StandardWorkingMinutes, decimal OtMultiplier, int WorkingDaysMask,
EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateWorkShiftRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required] public TimeSpan StartTime { get; set; }
[Required] public TimeSpan EndTime { get; set; }
public bool IsOvernight { get; set; }
[Range(0, 240)] public int GraceMinutes { get; set; } = 15;
[Range(0, 240)] public int BreakMinutes { get; set; } = 60;
[Range(1, 1440)] public int StandardWorkingMinutes { get; set; } = 480;
[Range(1, 5)] public decimal OtMultiplier { get; set; } = 1.5m;
[Range(0, 127)] public int WorkingDaysMask { get; set; } = 0b0111111;
}
public sealed class UpdateWorkShiftRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required] public TimeSpan StartTime { get; set; }
[Required] public TimeSpan EndTime { get; set; }
public bool IsOvernight { get; set; }
[Range(0, 240)] public int GraceMinutes { get; set; }
[Range(0, 240)] public int BreakMinutes { get; set; }
[Range(1, 1440)] public int StandardWorkingMinutes { get; set; }
[Range(1, 5)] public decimal OtMultiplier { get; set; }
[Range(0, 127)] public int WorkingDaysMask { get; set; }
}
/// <summary>Shared by all five masters' PATCH .../status endpoints.</summary>
public sealed class UpdateHrMasterStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
+130
View File
@@ -0,0 +1,130 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Hrm;
// --- Salary components ---
public sealed record SalaryComponentDto(
int SalaryComponentId, string Code, string Name, SalaryComponentType ComponentType,
bool IsTaxable, bool IsEpfEtfApplicable, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateSalaryComponentRequest
{
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[Required, EnumDataType(typeof(SalaryComponentType))] public SalaryComponentType ComponentType { get; set; }
public bool IsTaxable { get; set; }
public bool IsEpfEtfApplicable { get; set; }
}
public sealed class UpdateSalaryComponentRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public bool IsTaxable { get; set; }
public bool IsEpfEtfApplicable { get; set; }
}
// --- Salary structure ---
public sealed record EmployeeSalaryStructureLineDto(int SalaryComponentId, string? SalaryComponentName, decimal Amount);
public sealed record EmployeeSalaryStructureDto(
int EmployeeSalaryStructureId, int EmployeeId, DateTime EffectiveFrom, DateTime? EffectiveTo,
decimal BasicSalary, string Currency, SalaryStructureStatus Status,
List<EmployeeSalaryStructureLineDto> Lines, DateTime CreatedAt);
public sealed class SalaryStructureLineRequest
{
[Required] public int SalaryComponentId { get; set; }
[Range(0, double.MaxValue)] public decimal Amount { get; set; }
}
public sealed class CreateSalaryStructureRequest
{
[Required] public DateTime EffectiveFrom { get; set; }
[Range(0, double.MaxValue)] public decimal BasicSalary { get; set; }
public List<SalaryStructureLineRequest> Lines { get; set; } = new();
}
// --- Loans ---
public sealed record LoanInstallmentDto(
int LoanInstallmentId, int InstallmentNumber, int DueYear, int DueMonth,
decimal ScheduledAmount, decimal? PaidAmount, int? PayrollRunId, LoanInstallmentStatus Status);
public sealed record EmployeeLoanDto(
int EmployeeLoanId, string DocNo, int EmployeeId, LoanKind LoanKind, decimal PrincipalAmount,
decimal InterestRate, decimal InstallmentAmount, int NumberOfInstallments, int StartYear, int StartMonth,
decimal OutstandingBalance, LoanStatus Status, List<LoanInstallmentDto> Installments, DateTime CreatedAt);
public sealed class CreateEmployeeLoanRequest
{
[Required, EnumDataType(typeof(LoanKind))] public LoanKind LoanKind { get; set; }
[Range(0.01, double.MaxValue)] public decimal PrincipalAmount { get; set; }
[Range(0, 1)] public decimal InterestRate { get; set; }
[Range(0.01, double.MaxValue)] public decimal InstallmentAmount { get; set; }
[Range(1, 360)] public int NumberOfInstallments { get; set; }
[Range(2000, 2100)] public int StartYear { get; set; }
[Range(1, 12)] public int StartMonth { get; set; }
}
// --- Statutory settings ---
public sealed record PayrollStatutorySettingDto(
int PayrollStatutorySettingId, decimal EpfEmployeeRate, decimal EpfEmployerRate, decimal EtfEmployerRate,
decimal OtMultiplierDefault, DateTime EffectiveFrom, DateTime? EffectiveTo);
public sealed class UpsertPayrollStatutorySettingRequest
{
[Range(0, 1)] public decimal EpfEmployeeRate { get; set; } = 0.08m;
[Range(0, 1)] public decimal EpfEmployerRate { get; set; } = 0.12m;
[Range(0, 1)] public decimal EtfEmployerRate { get; set; } = 0.03m;
[Range(1, 5)] public decimal OtMultiplierDefault { get; set; } = 1.5m;
[Required] public DateTime EffectiveFrom { get; set; }
}
public sealed record TaxSlabDto(int TaxSlabId, DateTime EffectiveFrom, DateTime? EffectiveTo, decimal LowerBound, decimal? UpperBound, decimal Rate);
public sealed class CreateTaxSlabRequest
{
[Required] public DateTime EffectiveFrom { get; set; }
[Range(0, double.MaxValue)] public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
[Range(0, 1)] public decimal Rate { get; set; }
}
// --- Payroll run ---
public sealed record PayrollLineComponentDto(
PayrollLineComponentCategory ComponentCategory, int? SalaryComponentId, string Label, decimal Amount, int SortOrder);
public sealed record PayrollLineDto(
int PayrollLineId, int PayrollRunId, int EmployeeId, string? EmployeeCode, string? EmployeeName,
decimal BasicSalary, decimal TotalAllowances, decimal OvertimeAmount, decimal GrossSalary,
decimal LateDeductionAmount, decimal NoPayAmount, decimal LoanDeductionAmount,
decimal EpfEmployeeAmount, decimal EpfEmployerAmount, decimal EtfEmployerAmount,
decimal TaxAmount, decimal OtherDeductionsAmount, decimal NetSalary,
int WorkingDays, int PresentDays, int AbsentDays, int LeaveDays, int OtMinutesTotal, int LateMinutesTotal);
public sealed record PayrollLineDetailDto(PayrollLineDto Line, List<PayrollLineComponentDto> Components);
public sealed record PayrollRunDto(
int PayrollRunId, string DocNo, int PeriodYear, int PeriodMonth, int? BranchId, PayrollRunStatus Status,
int GeneratedBy, DateTime GeneratedAt, int? ApprovedBy, DateTime? ApprovedAt,
int? LockedBy, DateTime? LockedAt, int? UnlockedBy, DateTime? UnlockedAt, string? UnlockReason,
decimal TotalGross, decimal TotalNet, int EmployeeCount);
public sealed class GeneratePayrollRunRequest
{
[Range(2000, 2100)] public int PeriodYear { get; set; }
[Range(1, 12)] public int PeriodMonth { get; set; }
public int? BranchId { get; set; }
}
public sealed class UnlockPayrollRunRequest
{
[Required, StringLength(500)] public string Reason { get; set; } = string.Empty;
}
public sealed record PayslipDto(int PayslipId, int PayrollLineId, DateTime GeneratedAt, DateTime? ReleasedAt, int? ReleasedBy);
+26
View File
@@ -0,0 +1,26 @@
namespace ERPCore.Dtos.Hrm;
public sealed record AttendanceSummaryRowDto(
int EmployeeId, string EmployeeCode, string EmployeeName, string? DepartmentName,
int PresentDays, int AbsentDays, int LeaveDays, int HalfDays, int OtMinutesTotal, int LateMinutesTotal);
public sealed record OvertimeReportRowDto(
int EmployeeId, string EmployeeCode, string EmployeeName, DateTime AttendanceDate, int OvertimeMinutes);
public sealed record LateArrivalReportRowDto(
int EmployeeId, string EmployeeCode, string EmployeeName, DateTime AttendanceDate, int LateMinutes);
public sealed record PayrollRegisterRowDto(
int PayrollLineId, int EmployeeId, string EmployeeCode, string EmployeeName,
decimal GrossSalary, decimal TotalDeductions, decimal NetSalary);
public sealed record SalaryHistoryRowDto(
int EmployeeSalaryStructureId, DateTime EffectiveFrom, DateTime? EffectiveTo, decimal BasicSalary, string Status);
public sealed record LeaveBalanceReportRowDto(
int EmployeeId, string EmployeeCode, string EmployeeName, string LeaveTypeName,
decimal EntitledDays, decimal TakenDays, decimal RemainingDays);
public sealed record DocumentExpiryReportRowDto(
int EmployeeDocumentId, int EmployeeId, string EmployeeCode, string EmployeeName,
string DocumentTypeName, DateTime ExpiryDate, int DaysUntilExpiry);
+6 -2
View File
@@ -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);
/// <summary>A single per-warehouse reorder policy row.</summary>
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<ItemReorderDto> Reorder,
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> 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; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[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; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
}
public sealed class UpdateItemStatusRequest
+113
View File
@@ -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.2D.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 ---------------------------------------------------------------
/// <summary>Per-status stage counts driving the board's progress strip (FR-MFG-18, docs/21 §3).</summary>
public sealed record StageSummaryDto(int Waiting, int Ready, int InProgress, int Done, int Approved);
/// <summary>Row on the run board (docs/30 §D.2 <c>GET /production-runs</c>).</summary>
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);
/// <summary>
/// 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.
/// </summary>
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);
/// <summary>
/// One output of a run stage. <c>AvailableToTransfer</c> is derived — produced scrapped
/// transferred (FR-MFG-12) — and never stored.
/// </summary>
public sealed record RunStageOutputDto(
int RunOutputId, int? ItemId, string Name, int UomId,
decimal PlannedQty, decimal ProducedQty, decimal ScrappedQty, int? ScrapReasonCodeId,
decimal TransferredQty, decimal AvailableToTransfer);
/// <summary>
/// One stage of a run. <c>IsTerminal</c> (no outbound edge) and <c>IsEntry</c> (no inbound
/// edge) are derived from the run edge set rather than stored, so they cannot drift from it.
/// <c>ActualMinutes</c> is the elapsed whole minutes once the stage has finished, null while
/// it is still running (FR-MFG-19). <c>FieldValues</c> is the raw jsonb captured at complete,
/// passed through verbatim.
/// </summary>
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<FieldDefDto> FieldDefs, JsonElement? FieldValues,
IReadOnlyList<RunStageInputDto> Inputs, IReadOnlyList<RunStageOutputDto> 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);
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
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<RunStageDto> Stages, IReadOnlyList<RunEdgeDto> Edges, IReadOnlyList<RunEventDto> Events,
int CreatedBy, DateTime CreatedAt, DateTime? CompletedAt);
// --- requests ----------------------------------------------------------------
public sealed class CreateRunRequest
{
[Range(1, int.MaxValue)]
public int TemplateId { get; set; }
/// <summary>Quantity of the finished item; drives the whole graph's scale factor (FR-MFG-08).</summary>
[Range(0.0001, double.MaxValue)]
public decimal TargetQty { get; set; }
[Range(1, int.MaxValue)]
public int WarehouseId { get; set; }
/// <summary>Optional bin for the finished goods; must belong to <see cref="WarehouseId"/>.</summary>
public int? OutputBinId { get; set; }
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
public List<StageQuantityLine> Inputs { get; set; } = new();
public List<StageQuantityLine> Outputs { get; set; } = new();
}
public sealed class StageQuantityLine
{
/// <summary>The <c>runInputId</c> or <c>runOutputId</c> being adjusted.</summary>
[Range(1, int.MaxValue)]
public int Id { get; set; }
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
}
@@ -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 -------------------------------------------------------------------
/// <summary>One FIFO layer a stage start drew from, at that layer's cost.</summary>
public sealed record ConsumedLayerDto(int LayerId, decimal Qty, decimal UnitCost);
/// <summary>What a single Stock input consumed at start (FR-MFG-10).</summary>
public sealed record ConsumedInputDto(
int RunInputId, int ItemId, decimal Qty, decimal Value, IReadOnlyList<ConsumedLayerDto> ConsumedLayers);
public sealed record StartStageResultDto(
int RunStageId, ProductionStageStatus Status, DateTime? ActualStartAt,
IReadOnlyList<ConsumedInputDto> Consumed, IReadOnlyList<int> LedgerRefs,
RunStageDto Stage);
// --- complete ----------------------------------------------------------------
public sealed class CompleteStageRequest
{
[Required, MinLength(1)]
public List<CompleteOutputLine> Outputs { get; set; } = new();
/// <summary>
/// Values for the stage's custom fields, keyed by <c>fieldDefs[].key</c>. Every field
/// marked required must be present and non-empty (<c>400 REQUIRED_FIELD_MISSING</c>).
/// </summary>
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; }
/// <summary>Mandatory once <see cref="ScrappedQty"/> &gt; 0; must be a Production reason.</summary>
public int? ScrapReasonCodeId { get; set; }
}
// --- approve / transfer ------------------------------------------------------
/// <summary>
/// One WIP hand-off from a parent output to a child input. Deliveries route by
/// <c>fromRunOutputId</c>, not by edge — see the note on <c>RunEdge</c>.
/// </summary>
public sealed record TransferDto(
int RunOutputId, int RunInputId, int ChildRunStageId, decimal Qty,
decimal ChildDeliveredQty, ProductionStageStatus ChildStatus);
/// <summary>The finished-goods layer a terminal approve created (FR-MFG-13).</summary>
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<TransferDto> Transfers,
ReceiptDto? Receipt, CostPoolDto? CostPool, IReadOnlyList<int> LedgerRefs,
RunStageDto Stage);
public sealed class ApproveStageRequest
{
/// <summary>
/// Optional partial transfers. Omitted or empty means transfer the full available
/// quantity of every output (FR-MFG-12).
/// </summary>
public List<TransferLine> 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; }
/// <summary>
/// Optional explicit target. When an output feeds several child inputs the server
/// otherwise fills them in <c>runInputId</c> order; naming one removes the ambiguity.
/// </summary>
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<TransferDto> Transfers, RunStageDto Stage);
// --- leftover return ---------------------------------------------------------
public sealed class ReturnLeftoverRequest
{
/// <summary>
/// Quantity to return, in the item's <b>base</b> UOM — the same unit
/// <c>consumedQty</c>/<c>returnedQty</c> are stored in, since the return posts straight
/// to stock. Cannot exceed consumed already returned.
/// </summary>
[Range(0.0001, double.MaxValue)]
public decimal Qty { get; set; }
/// <summary>Mandatory, and must be a Production-context reason (FR-MFG-14).</summary>
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<int> LedgerRefs, CostPoolDto CostPool);
// --- rejection / rework ------------------------------------------------------
public sealed class RejectRequest
{
[StringLength(500)]
public string? Note { get; set; }
}
/// <summary>One parent whose delivered work was pulled back by a reject-intake (FR-MFG-15).</summary>
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<PulledBackDto> 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);
/// <summary>
/// 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).
/// </summary>
public sealed record ScrapWriteOffDto(int RunOutputId, string Name, decimal Qty);
public sealed record CancelRunResultDto(
int RunId, ProductionRunStatus Status,
IReadOnlyList<CancelReturnDto> Returns,
IReadOnlyList<ScrapWriteOffDto> ScrappedWrittenOff,
IReadOnlyList<int> LedgerRefs);
@@ -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-<uuid>" 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 ---------------------------------------------------------------
/// <summary>
/// Row on the template list (docs/30 §D.1 <c>GET /production-templates</c>).
/// </summary>
/// <remarks>
/// <c>StageNames</c> 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.
/// </remarks>
public sealed record TemplateSummaryDto(
int TemplateId, string Code, string Name, EntityStatus Status,
int StageCount, IReadOnlyList<string> StageNames,
int ActiveRunCount, int CreatedBy, DateTime CreatedAt);
/// <summary>One custom field definition, serialized verbatim into the stage's <c>field_defs</c> jsonb.</summary>
public sealed record FieldDefDto(
string Key, string Label, CustomFieldType Type, IReadOnlyList<string>? 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<FieldDefDto> FieldDefs,
IReadOnlyList<StageInputDto> Inputs, IReadOnlyList<StageOutputDto> Outputs);
public sealed record TemplateEdgeDto(
int EdgeId, int ParentStageId, int ChildStageId, string ParentKey, string ChildKey);
/// <summary>
/// A canvas-only grouping box or divider line. Round-tripped verbatim: no server-side
/// meaning whatsoever, and invisible to the graph validator.
/// </summary>
public sealed record CanvasAnnotationDto(
string Kind, decimal PosX, decimal PosY, decimal Width, decimal Height,
string? Label, decimal? Rotation);
/// <summary>Full graph (docs/30 §D.1 <c>GET /production-templates/{id}</c>).</summary>
/// <remarks>
/// <c>ActiveRunCount</c> and <c>Annotations</c> 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 <c>UpdateAsync</c> enforces — without it the builder would need a second
/// request to the list endpoint just to know whether to disable itself.
/// </remarks>
public sealed record TemplateGraphDto(
int TemplateId, string Code, string Name, string? Description, EntityStatus Status,
IReadOnlyList<TemplateStageDto> Stages, IReadOnlyList<TemplateEdgeDto> Edges,
IReadOnlyList<CanvasAnnotationDto> 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<SaveStageRequest> Stages { get; set; } = new();
/// <summary>Empty is legal — a single-stage template is both entry and terminal.</summary>
public List<SaveEdgeRequest> Edges { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
[MaxLength(200)]
public List<CanvasAnnotationDto> Annotations { get; set; } = new();
}
public sealed class SaveStageRequest
{
/// <summary>Existing stage id as a string, or a client-minted <c>tmp-*</c> key for a new stage.</summary>
[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; }
/// <summary>Canvas coordinates, stored verbatim and never interpreted server-side.</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
public List<FieldDefDto> FieldDefs { get; set; } = new();
public List<SaveInputRequest> Inputs { get; set; } = new();
public List<SaveOutputRequest> Outputs { get; set; } = new();
}
public sealed class SaveInputRequest
{
[Required]
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; must be null when Upstream.</summary>
public int? ItemId { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must name an output of a direct parent.</summary>
[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
{
/// <summary>Existing output id as a string, or a <c>tmp-*</c> key. Referenced by Upstream inputs.</summary>
[Required, StringLength(60, MinimumLength = 1)]
public string Key { get; set; } = string.Empty;
/// <summary>Required on the terminal stage's single output; must be null on every other output.</summary>
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;
}
/// <summary>Body of <c>PATCH /production-templates/{id}/status</c> (docs/30 §D.1).</summary>
public sealed class UpdateTemplateStatusRequest
{
[Required]
public EntityStatus Status { get; set; }
}
+7 -1
View File
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Users;
public sealed record ManagedUserDto(
int UserId, string Username, string DisplayName, EntityStatus Status,
int UserId, string Username, string DisplayName, string? Email, EntityStatus Status,
int? RoleId, string? RoleCode, string? RoleName);
/// <summary>
@@ -24,6 +24,12 @@ public sealed class CreateUserRequest
public string? MobileNumber { get; set; }
/// <summary>Left empty to auto-generate (AuthHex emails it to <see cref="Email"/>).</summary>
public string? Password { get; set; }
/// <summary>
/// Explicit, human-confirmed link to an existing unlinked Employee found via
/// email-lookup — never automatic, even on an exact email match (docs/12-BACKEND-HRM.md A.5).
/// </summary>
public int? LinkEmployeeId { get; set; }
}
public sealed class UpdateUserRoleRequest
+2
View File
@@ -7,6 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,30 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class AttendanceRecordConfiguration : IEntityTypeConfiguration<AttendanceRecord>
{
public void Configure(EntityTypeBuilder<AttendanceRecord> builder)
{
builder.ToTable("hr_attendance_records");
builder.HasKey(r => r.AttendanceRecordId);
builder.Property(r => r.Notes).HasMaxLength(1000);
builder.Property(r => r.AttendanceStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(r => r.RowValidationStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(r => r.AttendanceUploadBatch).WithMany()
.HasForeignKey(r => r.AttendanceUploadBatchId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(r => r.Employee).WithMany()
.HasForeignKey(r => r.EmployeeId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(r => r.WorkShift).WithMany()
.HasForeignKey(r => r.WorkShiftId).OnDelete(DeleteBehavior.Restrict);
builder.Property(r => r.RowVersion).IsRowVersion();
builder.HasIndex(r => new { r.EmployeeId, r.AttendanceDate });
builder.HasIndex(r => r.AttendanceUploadBatchId);
}
}
@@ -0,0 +1,27 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class AttendanceUploadBatchConfiguration : IEntityTypeConfiguration<AttendanceUploadBatch>
{
public void Configure(EntityTypeBuilder<AttendanceUploadBatch> builder)
{
builder.ToTable("hr_attendance_upload_batches");
builder.HasKey(b => b.AttendanceUploadBatchId);
builder.Property(b => b.DocNo).IsRequired().HasMaxLength(30);
builder.HasIndex(b => b.DocNo).IsUnique();
builder.Property(b => b.OriginalFileName).HasMaxLength(260);
builder.Property(b => b.SourceType).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(b => b.Status).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(b => b.UploadedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
builder.HasIndex(b => new { b.PeriodStart, b.PeriodEnd });
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BranchConfiguration : IEntityTypeConfiguration<Branch>
{
public void Configure(EntityTypeBuilder<Branch> builder)
{
builder.ToTable("hr_branches");
builder.HasKey(b => b.BranchId);
builder.Property(b => b.Code).IsRequired().HasMaxLength(20);
builder.HasIndex(b => b.Code).IsUnique();
builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.Property(b => b.Address).HasMaxLength(500);
builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(b => b.CreatedAt).IsRequired();
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
}

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