Compare commits

..

1 Commits

Author SHA1 Message Date
Sasanka 47683ddd0f toggle screen for product configurations 2026-07-17 11:34:46 +05:30
375 changed files with 4617 additions and 31477 deletions
-7
View File
@@ -29,10 +29,3 @@ yarn-error.log*
.DS_Store
Thumbs.db
.idea/
# ── Migrations ─────────────────────────────────────────────────────────
# New EF Core migrations are not committed. Note the 4 migrations already in
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
# not apply to tracked files — so edits to those still get committed as normal.
# Untracking them too takes `git rm --cached`.
**/Migrations/
+3 -48
View File
@@ -1,5 +1,4 @@
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
@@ -26,27 +25,12 @@ public sealed class AuthController : ControllerBase
private readonly IAuthUserService _users;
private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
}
/// <summary>
/// Authoritative current-session info for the frontend: role + the sidebar nav
/// codes it may see (docs/10 C.9 shadow-Role sync). Replaces the frontend's
/// previous reliance on a stale, untrusted `roleId` cached in localStorage.
/// </summary>
[HttpGet("me")]
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
{
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
return Ok(await _roles.GetMeAsync(roleCode, ct));
}
// ---- Session-issuing (UserManager) ------------------------------------
@@ -141,45 +125,16 @@ public sealed class AuthController : ControllerBase
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
/// <summary>
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
/// <para>
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
/// </para>
/// <para>
/// The cookies are cleared even if the upstream revoke fails or no user can be
/// resolved: a logout that leaves the caller holding a live session cookie is worse
/// than one that leaves a stale session server-side (which lapses on its own).
/// </para>
/// </summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
{
var userId = request?.UserId ?? ResolveTokenUserId();
if (userId is not null)
{
try
{
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
}
catch (DomainException)
{
// Upstream unreachable or already-revoked — fall through and clear anyway.
}
}
await _users.LogoutUserAsync(request, ct);
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
private Guid? ResolveTokenUserId()
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
[HttpPut("me")]
[ValidateCsrf]
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
@@ -1,67 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Brands;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
[Route("api/v1/brands")]
public sealed class BrandsController : ApiControllerBase
{
private readonly IBrandService _brands;
public BrandsController(IBrandService brands) => _brands = brands;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BrandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BrandDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _brands.ListAsync(query, status, ct));
[HttpGet("{brandId:int}")]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BrandDto>> GetById(int brandId, CancellationToken ct)
{
var result = await _brands.GetAsync(brandId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BrandDto>> Create([FromBody] CreateBrandRequest request, CancellationToken ct)
{
var result = await _brands.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/brands/{result.Value.BrandId}", result.Value);
}
[HttpPut("{brandId:int}")]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BrandDto>> Update(int brandId, [FromBody] UpdateBrandRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _brands.UpdateAsync(brandId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{brandId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct)
{
await _brands.SetStatusAsync(brandId, request.Status, ct);
return NoContent();
}
}
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
@@ -6,11 +5,7 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories
/// nested beneath each category. The hierarchy is exactly two levels deep — the old
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
/// </summary>
/// <summary>Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).</summary>
[Route("api/v1/categories")]
public sealed class CategoriesController : ApiControllerBase
{
@@ -18,79 +13,19 @@ public sealed class CategoriesController : ApiControllerBase
public CategoriesController(ICategoryService categories) => _categories = categories;
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListAsync(query, status, ct));
[HttpGet("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CategoryDto>> GetById(int categoryId, CancellationToken ct)
{
var result = await _categories.GetAsync(categoryId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
[HttpPost]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
{
var result = await _categories.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
}
[HttpPut("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CategoryDto>> Update(
int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _categories.UpdateAsync(categoryId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{categoryId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct)
{
await _categories.SetStatusAsync(categoryId, request.Status, ct);
return NoContent();
}
// Subcategories — nested under their parent category (docs/11 §2.3).
// Updates live on SubCategoriesController at /api/v1/subcategories/{id}.
[HttpGet("{categoryId:int}/subcategories")]
[ProducesResponseType(typeof(PagedResponse<SubCategoryDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> ListSubCategories(
int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct));
[HttpPost("{categoryId:int}/subcategories")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SubCategoryDto>> CreateSubCategory(
int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct)
{
var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value);
var dto = await _categories.CreateAsync(request, ct);
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
}
}
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,14 +12,6 @@ public sealed class GrnsController : ApiControllerBase
public GrnsController(IGrnService grns) => _grns = grns;
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,99 +0,0 @@
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));
}
@@ -1,64 +0,0 @@
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();
}
}
@@ -1,65 +0,0 @@
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();
}
}
@@ -1,64 +0,0 @@
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();
}
}
@@ -1,217 +0,0 @@
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();
}
}
@@ -1,64 +0,0 @@
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();
}
}
@@ -1,64 +0,0 @@
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();
}
}
@@ -1,52 +0,0 @@
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));
}
@@ -1,71 +0,0 @@
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));
}
@@ -1,64 +0,0 @@
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();
}
}
@@ -1,85 +0,0 @@
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));
}
@@ -1,33 +0,0 @@
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);
}
}
@@ -1,32 +0,0 @@
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");
}
}
@@ -1,64 +0,0 @@
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();
}
}
@@ -1,28 +0,0 @@
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);
}
}
@@ -1,64 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
private readonly IWorkShiftService _shifts;
public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WorkShiftDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WorkShiftDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _shifts.ListAsync(query, status, ct));
[HttpGet("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WorkShiftDto>> GetById(int workShiftId, CancellationToken ct)
{
var result = await _shifts.GetAsync(workShiftId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WorkShiftDto>> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct)
{
var result = await _shifts.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<WorkShiftDto>> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
}
@@ -1,73 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.ItemTypes;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material
/// dimension names. <c>GET</c> is the reason this master exists: it populates the item
/// builder's dropdown. Items never reference an item type; the chosen values are encoded
/// into the client-generated SKU (docs/10 Part C.9).
/// </summary>
[Route("api/v1/item-types")]
public sealed class ItemTypesController : ApiControllerBase
{
private readonly IItemTypeService _itemTypes;
public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes;
/// <summary>Feeds the frontend item-builder dropdown; filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ItemTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _itemTypes.ListAsync(query, status, ct));
[HttpGet("{itemTypeId:int}")]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemTypeDto>> GetById(int itemTypeId, CancellationToken ct)
{
var result = await _itemTypes.GetAsync(itemTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ItemTypeDto>> Create([FromBody] CreateItemTypeRequest request, CancellationToken ct)
{
var result = await _itemTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/item-types/{result.Value.ItemTypeId}", result.Value);
}
[HttpPut("{itemTypeId:int}")]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ItemTypeDto>> Update(int itemTypeId, [FromBody] UpdateItemTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _itemTypes.UpdateAsync(itemTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{itemTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct)
{
await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct);
return NoContent();
}
}
@@ -21,11 +21,9 @@ public sealed class ItemsController : ApiControllerBase
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[HttpGet("{itemId:int}")]
@@ -1,39 +0,0 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Rbac;
using ERPCore.Repositories.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Controllers;
/// <summary>
/// Read-only sidebar nav tree, used by the Role permission-assignment checkbox
/// UI and by `GET /auth/me` (see AuthController) to resolve a role's visible codes.
/// NavItem/SubNavItem rows are seeded (NavItemConfiguration/SubNavItemConfiguration)
/// to match the frontend's hardcoded sidebar — not admin-editable in this phase.
/// </summary>
[Route("api/v1/nav")]
public sealed class NavController : ApiControllerBase
{
private readonly IRepository<NavItem> _navItems;
public NavController(IRepository<NavItem> navItems) => _navItems = navItems;
[HttpGet]
[ProducesResponseType(typeof(List<NavItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<NavItemDto>>> GetTree(CancellationToken ct)
{
var items = await _navItems.Query().AsNoTracking()
.Include(n => n.Children)
.OrderBy(n => n.SortOrder)
.ToListAsync(ct);
var dto = items.Select(n => new NavItemDto(
n.NavItemId, n.Code, n.Label, n.Icon, n.Href, n.SortOrder,
n.Children.OrderBy(c => c.SortOrder)
.Select(c => new SubNavItemDto(c.SubNavItemId, c.Code, c.Label, c.Icon, c.Href, c.SortOrder))
.ToList())).ToList();
return Ok(dto);
}
}
@@ -1,46 +0,0 @@
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
/// feature gate for subcategories/brands/item-types.
/// <para>
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only.
/// A dedicated <c>CONFIG_MANAGE</c> permission is reserved for when per-endpoint RBAC
/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute
/// with no other change. Until then any ERP-admitted user can flip these flags; that is
/// the accepted Phase-1 posture, consistent with every other endpoint.
/// </para>
/// </summary>
[Route("api/v1/product-config")]
public sealed class ProductConfigController : ApiControllerBase
{
private readonly IProductConfigService _config;
public ProductConfigController(IProductConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProductConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ProductConfigDto>> Update(
[FromBody] UpdateProductConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -57,25 +57,6 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
[HttpPost("{poId:int}/submit")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Submit(int poId, CancellationToken ct)
=> Ok(await _pos.SubmitAsync(poId, ct));
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
[HttpDelete("{poId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int poId, CancellationToken ct)
{
await _pos.DeleteAsync(poId, ct);
return NoContent();
}
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
[HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
@@ -1,4 +1,3 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -13,23 +12,6 @@ public sealed class PurchaseReturnsController : ApiControllerBase
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
@@ -1,4 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
@@ -16,9 +15,8 @@ public sealed class RequisitionsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, status, ct));
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, ct));
[HttpGet("{requisitionId:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,13 +12,6 @@ public sealed class RfqsController : ApiControllerBase
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
/// <summary>List RFQs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
=> Ok(await _rfqs.ListAsync(query, status, ct));
[HttpGet("{rfqId:int}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,88 +0,0 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
[Route("api/v1/roles")]
public sealed class RolesController : ApiControllerBase
{
private readonly IRoleService _roles;
public RolesController(IRoleService roles) => _roles = roles;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RoleDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RoleDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _roles.ListAsync(query, status, ct));
[HttpGet("{roleId:int}")]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RoleDto>> GetById(int roleId, CancellationToken ct)
{
var result = await _roles.GetAsync(roleId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<RoleDto>> Create([FromBody] CreateRoleRequest request, CancellationToken ct)
{
var result = await _roles.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/roles/{result.Value.RoleId}", result.Value);
}
[HttpPut("{roleId:int}")]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RoleDto>> Update(int roleId, [FromBody] UpdateRoleRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _roles.UpdateAsync(roleId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{roleId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int roleId, [FromBody] UpdateRoleStatusRequest request, CancellationToken ct)
{
await _roles.SetStatusAsync(roleId, request.Status, ct);
return NoContent();
}
[HttpDelete("{roleId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int roleId, CancellationToken ct)
{
await _roles.DeleteAsync(roleId, ct);
return NoContent();
}
[HttpGet("{roleId:int}/permissions")]
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RolePermissionsDto>> GetPermissions(int roleId, CancellationToken ct)
=> Ok(await _roles.GetPermissionsAsync(roleId, ct));
[HttpPut("{roleId:int}/permissions")]
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RolePermissionsDto>> AssignPermissions(
int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
=> Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
}
@@ -1,4 +1,3 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -13,23 +12,6 @@ public sealed class StockAdjustmentsController : ApiControllerBase
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>List posted adjustments, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
{
var dto = await _adjustments.GetAsync(adjustmentId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
+2 -16
View File
@@ -24,26 +24,12 @@ public sealed class StockController : ApiControllerBase
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
/// this document post?" — the ledger's document reference is polymorphic, so there is
/// no FK to navigate instead (docs/10 C.9).
/// </summary>
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,13 +12,6 @@ public sealed class StockCountsController : ApiControllerBase
public StockCountsController(ICountService counts) => _counts = counts;
/// <summary>List counts, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,5 +1,3 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -14,14 +12,6 @@ public sealed class StockTransfersController : ApiControllerBase
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
/// <summary>List transfers, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -1,56 +0,0 @@
using ERPCore.Dtos.Categories;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3).
/// Listing and creation live under the parent category on <see cref="CategoriesController"/>,
/// since a subcategory only exists in the context of one.
/// </summary>
[Route("api/v1/subcategories")]
public sealed class SubCategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
public SubCategoriesController(ICategoryService categories) => _categories = categories;
[HttpGet("{subCategoryId:int}")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SubCategoryDto>> GetById(int subCategoryId, CancellationToken ct)
{
var result = await _categories.GetSubCategoryAsync(subCategoryId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Renames a subcategory. It cannot be moved to another category — see the request DTO.</summary>
[HttpPut("{subCategoryId:int}")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SubCategoryDto>> Update(
int subCategoryId, [FromBody] UpdateSubCategoryRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _categories.UpdateSubCategoryAsync(subCategoryId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{subCategoryId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct)
{
await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct);
return NoContent();
}
}
@@ -1,65 +0,0 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// User management: local shadow `User` list/detail + role assignment, and
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
/// </summary>
[Route("api/v1/users")]
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
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)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _users.ListAsync(query, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
[ProducesResponseType(typeof(List<UserTypeOptionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<UserTypeOptionDto>>> ListUserTypes(CancellationToken ct)
=> Ok(await _users.ListUserTypesAsync(ct));
[HttpGet("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> GetById(int userId, CancellationToken ct)
{
var result = await _users.GetAsync(userId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
var result = await _users.CreateAsync(request, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
[HttpPut("{userId:int}/role")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
}
@@ -1,40 +0,0 @@
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; }
}
@@ -1,31 +0,0 @@
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
@@ -1,23 +0,0 @@
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; }
}
-21
View File
@@ -1,21 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Brand master (FR-MD-09). Referenced optionally by <see cref="Item.BrandId"/>.
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Brand
{
public int BrandId { get; set; }
public string Name { get; set; } = string.Empty;
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; }
}
+5 -15
View File
@@ -1,25 +1,15 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
/// below is <see cref="SubCategory"/>; categories no longer self-nest (the former
/// <c>parent_id</c> tree was replaced in migration #2).
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Category
{
public int CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
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; }
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
public int? ParentId { get; set; }
public Category? Parent { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>();
}
@@ -1,26 +0,0 @@
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; }
}
@@ -1,19 +0,0 @@
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; }
}
@@ -1,76 +0,0 @@
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; }
}
@@ -1,28 +0,0 @@
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; }
}
@@ -1,36 +0,0 @@
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; }
}
@@ -1,35 +0,0 @@
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();
}
@@ -1,30 +0,0 @@
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();
}
@@ -1,12 +0,0 @@
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; }
}
@@ -1,20 +0,0 @@
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; }
}
+3 -30
View File
@@ -3,13 +3,9 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the gross cost received at:
/// entered on the line, defaulting to the PO price when omitted (a per-receipt price
/// override is now permitted — see docs/02-SECURITY C.3, revised). <see cref="PoUnitPrice"/>
/// snapshots the PO price at receipt so the variance survives later PO edits.
/// <see cref="NetUnitCost"/> = unitCost after trade discount — this is what the FIFO layer
/// costs at (VAT never enters stock value; it is recoverable input tax).
/// <see cref="ReceivedValue"/> = qty × netUnitCost (after discount, before VAT).
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
/// </summary>
public class GrnLine
@@ -35,30 +31,7 @@ public class GrnLine
public Batch? Batch { get; set; }
public decimal Qty { get; set; }
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
public decimal UnitCost { get; set; }
/// <summary>Snapshot of the PO line price at receipt; null for direct receipts.</summary>
public decimal? PoUnitPrice { get; set; }
/// <summary>Trade discount percentage (0100), entered.</summary>
public decimal DiscountPct { get; set; }
/// <summary>UnitCost × (1 DiscountPct/100) — the inventory (FIFO layer) cost.</summary>
public decimal NetUnitCost { get; set; }
/// <summary>VAT percentage (0100), entered. Recoverable — does not affect stock value.</summary>
public decimal VatPct { get; set; }
/// <summary>Qty × NetUnitCost × VatPct/100.</summary>
public decimal VatAmount { get; set; }
/// <summary>Qty × NetUnitCost (after discount, before VAT).</summary>
public decimal ReceivedValue { get; set; }
/// <summary>Qty × NetUnitCost + VatAmount — payable to the vendor.</summary>
public decimal LineTotal { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
}
@@ -1,24 +0,0 @@
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; }
}
+1 -16
View File
@@ -17,30 +17,15 @@ public class Item
public int CategoryId { get; set; }
public Category? Category { get; set; }
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
public int? SubCategoryId { get; set; }
public SubCategory? SubCategory { get; set; }
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
public int? DefaultVendorId { get; set; }
public Vendor? DefaultVendor { get; set; }
public StockNature StockNature { get; set; }
public ItemType ItemType { 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; }
@@ -1,31 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> enum became.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ItemType
{
public int ItemTypeId { get; set; }
public string Name { get; set; } = string.Empty;
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; }
}
@@ -1,24 +0,0 @@
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; }
}
@@ -1,31 +0,0 @@
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; }
}
@@ -1,23 +0,0 @@
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; }
}
@@ -1,26 +0,0 @@
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; }
}
@@ -1,22 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A top-level sidebar entry (mirrors the frontend's hardcoded nav list,
/// components/Layouts/AppSidebar.tsx). Seeded to match the current app routes;
/// per-role visibility is controlled via <see cref="Permission"/>/<see cref="RolePermission"/>,
/// not by editing these rows through the UI.
/// </summary>
public class NavItem
{
public int NavItemId { get; set; }
public string Code { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? Icon { get; set; }
public string? Href { get; set; }
public int SortOrder { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public ICollection<SubNavItem> Children { get; set; } = new List<SubNavItem>();
}
@@ -1,41 +0,0 @@
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();
}
@@ -1,18 +0,0 @@
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; }
}
@@ -1,33 +0,0 @@
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();
}
@@ -1,21 +0,0 @@
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; }
}
@@ -1,16 +0,0 @@
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; }
}
@@ -1,18 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A grantable sidebar-visibility unit — exactly one of <see cref="NavItemId"/> /
/// <see cref="SubNavItemId"/> is set (enforced in <c>NavSeedService</c>/service layer,
/// not by a DB constraint). One row is seeded per <see cref="NavItem"/>/<see cref="SubNavItem"/>;
/// <see cref="RolePermission"/> grants it to a role.
/// </summary>
public class Permission
{
public int PermissionId { get; set; }
public string Code { get; set; } = string.Empty;
public int? NavItemId { get; set; }
public int? SubNavItemId { get; set; }
public NavItem? NavItem { get; set; }
public SubNavItem? SubNavItem { get; set; }
}
+1 -1
View File
@@ -22,7 +22,7 @@ public class PoLine
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }//
public decimal UnitPrice { get; set; }
public decimal Tax { get; set; }
public decimal QtyReceived { get; set; }
}
@@ -1,35 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
/// <para>
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
/// so there is nothing on a write to reject; the frontend honours it by hiding the
/// builder's type section. Reads are never gated, so existing data stays visible after a
/// flag is switched off.
/// </para>
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ProductConfig
{
/// <summary>Always 1 — the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
-27
View File
@@ -1,27 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
/// </summary>
public class Role
{
public int RoleId { get; set; }
public Guid AuthRoleId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsSystemRole { 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; }
}
@@ -1,11 +0,0 @@
namespace ERPCore.Domain.Entities;
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
public class RolePermission
{
public int RoleId { get; set; }
public int PermissionId { get; set; }
public Role? Role { get; set; }
public Permission? Permission { get; set; }
}
@@ -1,19 +0,0 @@
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; }
}
@@ -1,26 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Subcategory — the single optional level below <see cref="Category"/> (FR-MD-04).
/// Replaces the former self-referencing CATEGORY.parent_id tree: the hierarchy is
/// exactly two levels deep and cannot nest further. Referenced optionally by
/// <see cref="Item.SubCategoryId"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class SubCategory
{
public int SubCategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public int CategoryId { get; set; }
public Category? Category { 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; }
}
@@ -1,18 +0,0 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>A child sidebar entry under a <see cref="NavItem"/> (e.g. Products' children).</summary>
public class SubNavItem
{
public int SubNavItemId { get; set; }
public int NavItemId { get; set; }
public string Code { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? Icon { get; set; }
public string? Href { get; set; }
public int SortOrder { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public NavItem? NavItem { get; set; }
}
@@ -1,19 +0,0 @@
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; }
}
-12
View File
@@ -22,16 +22,4 @@ 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; }
}
@@ -1,32 +0,0 @@
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; }
}
@@ -1,15 +0,0 @@
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
}
@@ -1,13 +0,0 @@
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
}
@@ -1,17 +0,0 @@
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
}
@@ -1,11 +0,0 @@
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
}
@@ -1,16 +0,0 @@
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
@@ -1,9 +0,0 @@
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
}
@@ -1,12 +0,0 @@
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
}
+12
View File
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Item classification (FR-MD-01). Values match the <c>itemType</c> enum in
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
/// </summary>
public enum ItemType
{
Stocked,
NonStocked,
Service
}
@@ -1,11 +0,0 @@
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
}
@@ -1,13 +0,0 @@
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
@@ -1,8 +0,0 @@
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
}
@@ -1,8 +0,0 @@
namespace ERPCore.Domain.Enums;
public enum LoanStatus
{
Active,
Closed,
Cancelled
}
@@ -1,12 +0,0 @@
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
}
@@ -1,15 +0,0 @@
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
}
@@ -1,12 +0,0 @@
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
}
@@ -1,12 +0,0 @@
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
}
@@ -1,8 +0,0 @@
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
}
@@ -1,14 +0,0 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Whether an item holds stock (FR-MD-01). Values match the <c>stockNature</c> enum in
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
/// Renamed from <c>ItemType</c> so that name could be taken by the ItemType master
/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9).
/// </summary>
public enum StockNature
{
Stocked,
NonStocked,
Service
}
-28
View File
@@ -1,28 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Auth;
/// <summary>AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).</summary>
public sealed class AuthHexRoleDto
{
public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
public DateTime CreatedAt { get; set; }
}
public sealed class CreateAuthHexRoleRequest
{
[Required] public string Code { get; set; } = string.Empty;
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
public sealed class UpdateAuthHexRoleRequest
{
[Required] public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
+1 -13
View File
@@ -82,14 +82,6 @@ public sealed class GetUserDetailsResponse
public JsonElement? UserType { get; set; }
}
/// <summary>AuthHex's UserType lookup (ERP_Auth_Service/API_DOCUMENTATION.md, listUserTypes).</summary>
public sealed class UserTypeDto
{
public Guid UserTypeId { get; set; }
public string? Code { get; set; }
public string? Description { get; set; }
}
public sealed class SessionDto
{
public string? SessionId { get; set; }
@@ -183,9 +175,5 @@ public sealed class TwoFaStatusResponse
public sealed class LogoutRequest
{
/// <summary>
/// Optional: AuthHex returns no <c>userId</c> on login, so browsers cannot supply one.
/// When omitted, the controller resolves it from the session token's UserId claim.
/// </summary>
public Guid? UserId { get; set; }
[Required] public Guid UserId { get; set; }
}
-26
View File
@@ -1,26 +0,0 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Brands;
/// <summary>Brand resource (docs/11-BACKEND-PHASE1.md §2.6).</summary>
public sealed record BrandDto(
int BrandId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
public sealed class CreateBrandRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateBrandRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateBrandStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
@@ -1,56 +1,15 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Categories;
// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------
// The hierarchy is exactly two levels: Category → SubCategory. The former
// self-nesting tree (parentId / ?tree=true / CategoryTreeDto) was removed in
// migration #2 — see docs/10 Part C.1.
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
/// <summary>Category resource — the top level.</summary>
public sealed record CategoryDto(
int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>Subcategory resource — the single optional level below a category.</summary>
public sealed record SubCategoryDto(
int SubCategoryId, int CategoryId, string Name, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList<CategoryTreeDto> Children);
public sealed class CreateCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateCategoryStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>Body for <c>POST /categories/{categoryId}/subcategories</c>; the parent comes from the route.</summary>
public sealed class CreateSubCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
/// <summary>
/// Body for <c>PUT /subcategories/{id}</c>. Name only — a subcategory cannot be reparented,
/// since moving one would silently invalidate the category of every item referencing it.
/// </summary>
public sealed class UpdateSubCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateSubCategoryStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
public int? ParentId { get; set; }
}
@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
/// </summary>
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// <para>
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
/// actually bind — an omitted flag is a 400, not an accidental disable.
/// </para>
/// </summary>
public sealed class UpdateProductConfigRequest
{
[Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { get; set; }
}
+2 -18
View File
@@ -7,20 +7,12 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId);
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
public sealed record CreatedLayerDto(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
@@ -47,16 +39,8 @@ public sealed class CreateGrnLineInput
[Required] public int UomId { get; set; }
public int? BinId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
/// <summary>
/// Gross unit cost. Required for direct (no-PO) receipts. For a PO line it is an optional
/// per-receipt price override — when 0/omitted the PO line price is used; when supplied it
/// wins and a variance is recorded against the PO snapshot (docs/02-SECURITY C.3, revised).
/// </summary>
/// <summary>Used only for direct (no-PO) receipts; ignored when <see cref="PoLineId"/> is set.</summary>
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
/// <summary>Trade discount percentage (0100). Reduces the inventory cost.</summary>
[Range(0, 100)] public decimal DiscountPct { get; set; }
/// <summary>VAT percentage (0100). Recoverable — does not affect stock value.</summary>
[Range(0, 100)] public decimal VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
}
@@ -1,44 +0,0 @@
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;
}

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