Compare commits

...

36 Commits

Author SHA1 Message Date
DeepnaPooja 266a2a2c14 implement fe with backend 2026-08-02 01:25:17 +05:30
DeepnaPooja 3c5b476635 sales ,invoice ,slip and free issues BE fixes 2026-08-02 01:12:09 +05:30
DeepnaPooja 59af50bf11 Add sales slip document 2026-08-02 01:07:01 +05:30
DeepnaPooja 0b9d64f911 Add sales invoice document 2026-08-02 01:05:09 +05:30
DeepnaPooja 6258ebd8de implemnet customer master used for both B2B and B2C. 2026-08-02 00:59:16 +05:30
ImanThiyanga 37c8da2ced removed all migration updated 2026-08-01 08:45:10 +05:30
ImanThiyanga b7f9f599eb Merge pull request 'Feat/sales management' (#23) from feat/sales-management into Dev
Reviewed-on: #23
2026-08-01 01:47:27 +00:00
ImanThiyanga f43a8a8486 Merge pull request 'Feat/general ledger service' (#22) from feat/general-ledger-service into Dev
Reviewed-on: #22
2026-08-01 01:46:45 +00:00
HarithaRandunu a271d1832e fix: resolve repo-wide bug with missing HRM and Manufacturing tables in EF model
- Identified and fixed a critical issue where 36 tables existed in the EF model but were absent in the actual database.
- Root cause traced to a `.gitignore` rule that prevented migration files from being tracked, leading to discrepancies between the model snapshot and the database.
- Removed problematic entities from the snapshot, generated a migration to add the missing tables, and confirmed successful application.
- Reverted the `.gitignore` rule to ensure future migrations are tracked properly.
- No changes to existing tables, ensuring no collateral schema drift occurred.
2026-07-31 18:57:32 +05:30
ImanThiyanga 6ebdbb655a changes for db 2026-07-31 18:11:40 +05:30
HarithaRandunu 22657f0910 feat: Implement new General Ledger frontend section with comprehensive report screens and cash/bank account management
- Added a new Ledgers sidebar section for statutory-format financial reports and cash/bank-account management.
- Introduced dedicated GL client for API interactions, handling response envelopes and error management.
- Developed report screens for Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, and a new Tax Report.
- Implemented CSV download functionality alongside existing PDF downloads for all report screens.
- Separated Cash and Bank accounts into distinct tables/endpoints, with updated create forms and unified list view.
- Created a new Accounts section for Cheque Management, moving Cash/Bank Accounts from the Ledgers section.
- Updated RBAC navigation to include new permissions and sub-navigation items for the added features.
- Ensured compliance with GL's updated API contract, including renaming fields and adjusting response shapes.
- Addressed various bugs and presentation issues, enhancing user experience across the new module.
2026-07-31 18:02:03 +05:30
DeepnaPooja 74d3e684d2 Add sales report functions 2026-07-31 17:59:06 +05:30
DeepnaPooja 2dab7051b3 Add discoont rule 2026-07-31 17:59:05 +05:30
DeepnaPooja 4f722432cd add shared pricing resolution with fixed yem price,GRN weighted and FIFO valuation cost fallback 2026-07-31 17:59:05 +05:30
DeepnaPooja 1f9e12e84b Add sales slip document 2026-07-31 17:59:05 +05:30
DeepnaPooja 8b8e79e0fe Add sales invoice document 2026-07-31 17:59:05 +05:30
DeepnaPooja ffbd47f6f9 implemnet customer master used for both B2B and B2C. 2026-07-31 17:58:14 +05:30
ImanThiyanga 76484c7268 Merge pull request 'Refactor: Remove unused imports and clean up code across multiple pages' (#21) from fix/frontend-changers into Dev
Reviewed-on: #21
2026-07-31 05:42:56 +00:00
Sasanka 96f81cb03a Refactor: Remove unused imports and clean up code across multiple pages
- Removed unused Link imports and associated code for navigation in various pages.
- Simplified TableHeader components by removing unnecessary class names.
- Updated alert dialog styles for better consistency with the design system.
- Cleaned up imports in several components to streamline the codebase.
2026-07-31 11:04:32 +05:30
ImanThiyanga cb3f1e5cde Merge branch 'production' into Dev 2026-07-31 05:24:32 +00:00
ImanThiyanga c3fef88bcf Merge pull request 'feat: add production templates API and documentation for manufacturing phase 2' (#19) from feat/production-integration into Dev
Reviewed-on: #19
2026-07-31 04:54:00 +00:00
ImanThiyanga 7d6e597389 feat: add production templates API and documentation for manufacturing phase 2
- Implemented CRUD operations for production templates, including listing, retrieving, creating, updating, and deactivating templates.
- Introduced a new API contract for production runs, detailing the lifecycle from creation to completion, including handling of stock inputs and outputs.
- Documented the architecture, requirements, entity model, and API contract for the manufacturing phase 2, ensuring clarity on the production process and its integration with existing systems.
2026-07-31 10:22:40 +05:30
ImanThiyanga 415ac94ab2 Merge pull request 'Dashborad and prouction' (#18) from Dashborad-and-prouction into Dev
Reviewed-on: #18
2026-07-29 17:08:57 +00:00
Sasanka 4561ef7ba8 feat: implement frontend-only mock for production lines and runs
- Add ProductionTemplatesPage component for managing production templates with a visual representation using React Flow.
- Create LineHeaderNode and LineStageNode components for rendering production line nodes.
- Introduce RunStageNode and RunHeaderNode components for displaying run stages and headers.
- Implement StageProgressStrip for visualizing stage progress in runs.
- Create mock data for production runs and templates to simulate backend functionality.
- Define types for production templates and runs to structure mock data.
- Document frontend Phase 2 specifications for manufacturing processes, including screens, dialogs, and validation posture.
2026-07-28 22:55:56 +05:30
Sasanka b12adebaa0 feat: add dashboard overview stats endpoint and UI integration
- Implemented `GET /dashboard/stats` in `DashboardController` to provide aggregate counts for stock, GRN, and procurement.
- Created `DashboardStatsDto` and `WarehouseValuationDto` to structure the response data.
- Developed `DashboardService` to fetch and compute necessary statistics from the database.
- Added `IDashboardService` interface for service abstraction.
- Introduced API client methods in `dashboard.ts` for frontend consumption of the new endpoint.
- Defined TypeScript types for dashboard data in `dashboard.ts` to ensure type safety in the frontend.
- Updated UI components in the frontend to reflect changes in the dashboard, including styling adjustments and removal of unused icons.
2026-07-28 14:59:09 +05:30
ImanThiyanga 0b95d6f1cd Merge pull request 'feat: enhance UI components and add vibrant theme support' (#17) from ui-fixers into Dev
Reviewed-on: #17
2026-07-27 06:10:10 +00:00
ImanThiyanga d35b076435 Merge branch 'Dev' into ui-fixers 2026-07-27 06:10:00 +00:00
Sasanka c564916c60 feat: enhance UI components and add vibrant theme support
- Updated layout and styling for various dashboard pages including stock adjustments, counts, transfers, wastage, and vendors to improve responsiveness and visual consistency.
- Introduced a vibrant theme option in the theme toggle component, allowing users to switch between light, dark, and vibrant themes.
- Refactored button styles across login and forgot password pages for better accessibility and visual feedback.
- Improved sidebar navigation with expanded functionality for items without dedicated pages.
- Enhanced data table styling for better readability and user interaction.
- Added new background images to support the vibrant theme.
2026-07-27 11:11:26 +05:30
Dhananjaya99 86bb4d4908 Merge branch 'Dev' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into Dev 2026-07-24 14:33:55 +05:30
Dhananjaya99 816ffbbfb6 setting not found issue 2026-07-24 14:33:49 +05:30
ashan_rusiru ae20bc4e34 Merge pull request 'develop full initial module' (#16) from Feature_HRM into Dev
Reviewed-on: #16
2026-07-24 05:25:13 +00:00
Dhananjaya99 755df494fe develop full initial module 2026-07-23 19:54:56 +05:30
Dhananjaya99 eacc21afad Merge branch 'fixers-7/23' into Dev 2026-07-23 13:02:24 +05:30
Sasanka 7366ca93c0 feat: implement theme toggle and enhance UI components 2026-07-23 12:53:26 +05:30
ImanThiyanga 5af77fbf0a Merge pull request 'feat: Implement fixed sale price functionality for items' (#14) from fix/grn-varient into Dev
Reviewed-on: #14
2026-07-23 06:48:46 +00:00
ImanThiyanga 8484601494 Merge pull request 'Dev' (#13) from Dev into production
Reviewed-on: #13
2026-07-22 06:12:34 +00:00
445 changed files with 71216 additions and 10583 deletions
+8 -5
View File
@@ -31,8 +31,11 @@ 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/
# Reverted 2026-07-31: excluding new EF Core migrations while
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
# migrations add` after the initial 4 silently produced a migration git would
# never see, while the (tracked) snapshot's changes committed normally —
# so the snapshot kept claiming tables existed that no migration in git
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
# tables were missing from the actual database for exactly this reason.
# Migrations now stay tracked like any other source file — commit them.
@@ -0,0 +1,69 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Customers;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Customer master endpoints for Phase 1 sales.</summary>
[Route("api/v1/customers")]
public sealed class CustomersController : ApiControllerBase
{
private readonly ICustomerService _customers;
public CustomersController(ICustomerService customers) => _customers = customers;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CustomerDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CustomerDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct)
=> Ok(await _customers.ListAsync(query, status, customerType, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CustomerDto>> GetById(int customerId, CancellationToken ct)
{
var result = await _customers.GetAsync(customerId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CustomerDto>> Create([FromBody] CreateCustomerRequest request, CancellationToken ct)
{
var result = await _customers.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/customers/{result.Value.CustomerId}", result.Value);
}
[HttpPut("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CustomerDto>> Update(int customerId, [FromBody] UpdateCustomerRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _customers.UpdateAsync(customerId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{customerId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int customerId, [FromBody] UpdateCustomerStatusRequest request, CancellationToken ct)
{
await _customers.SetStatusAsync(customerId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,20 @@
using ERPCore.Dtos.Dashboard;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Dashboard overview stats — cross-domain counts, not a stored entity.</summary>
[Route("api/v1/dashboard")]
public sealed class DashboardController : ApiControllerBase
{
private readonly IDashboardService _dashboard;
public DashboardController(IDashboardService dashboard) => _dashboard = dashboard;
/// <summary>Aggregate counts for stock, GRN, and procurement.</summary>
[HttpGet("stats")]
[ProducesResponseType(typeof(DashboardStatsDto), StatusCodes.Status200OK)]
public async Task<ActionResult<DashboardStatsDto>> GetStats(CancellationToken ct)
=> Ok(await _dashboard.GetStatsAsync(ct));
}
@@ -0,0 +1,78 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Thin API alias for free-issue handling. Free issues are modeled as sales slips
/// with line-level <c>IsFreeIssue</c>/<c>FreeQty</c> flags, so this controller reuses
/// the existing sales-slip CRUD surface under a more business-friendly route.
/// </summary>
[Route("api/v1/free-issues")]
public sealed class FreeIssuesController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public FreeIssuesController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<FreeIssueSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<FreeIssueSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListFreeIssuesAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<FreeIssueDto>> GetById(int freeIssueId, CancellationToken ct)
{
var result = await _slips.GetFreeIssueAsync(freeIssueId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{freeIssueId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(freeIssueId, ct));
[HttpPost]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status201Created)]
public async Task<ActionResult<FreeIssueDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/free-issues/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{freeIssueId:int}")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<FreeIssueDto>> Update(int freeIssueId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(freeIssueId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{freeIssueId:int}/post")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Post(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.PostAsync(freeIssueId, ct));
[HttpPost("{freeIssueId:int}/cancel")]
[ProducesResponseType(typeof(FreeIssueDto), StatusCodes.Status200OK)]
public async Task<ActionResult<FreeIssueDto>> Cancel(int freeIssueId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(freeIssueId, ct));
}
@@ -0,0 +1,43 @@
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Generic reverse proxy into the external General Ledger service — forwards every
/// method/path/query/body under this prefix verbatim via
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
/// body) unchanged. No endpoint-specific shape lives here; see
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
/// only and is never exposed to the frontend.
/// </summary>
[Route("api/v1/gl")]
public sealed class GeneralLedgerController : ApiControllerBase
{
private readonly IGeneralLedgerService _gl;
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
[HttpGet("{**path}")]
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
[HttpPost("{**path}")]
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
[HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
StatusCode = result.StatusCode,
Content = result.Body,
ContentType = result.ContentType ?? "application/json"
};
}
}
@@ -0,0 +1,99 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
[Route("api/v1/attendance-batches")]
public sealed class AttendanceBatchesController : ApiControllerBase
{
private readonly IAttendanceUploadService _attendance;
private readonly ICurrentUser _currentUser;
public AttendanceBatchesController(IAttendanceUploadService attendance, ICurrentUser currentUser)
{
_attendance = attendance;
_currentUser = currentUser;
}
[HttpGet("template.xlsx")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult DownloadTemplate([FromQuery] string? format)
{
var (content, contentType, fileName) = _attendance.GenerateTemplate(string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase));
return File(content, contentType, fileName);
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AttendanceUploadBatchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AttendanceUploadBatchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] AttendanceBatchStatus? status,
[FromQuery] int? periodYear, [FromQuery] int? periodMonth, CancellationToken ct)
=> Ok(await _attendance.ListBatchesAsync(query, status, periodYear, periodMonth, ct));
[HttpGet("{batchId:int}")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AttendanceUploadBatchDto>> GetById(int batchId, CancellationToken ct)
{
var result = await _attendance.GetBatchAsync(batchId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Upload(
[FromForm] UploadAttendanceBatchMetadata metadata, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _attendance.UploadAsync(stream, file.FileName, metadata.PeriodStart, metadata.PeriodEnd, _currentUser.AuditUserId, ct);
return Created($"/api/v1/attendance-batches/{result.AttendanceUploadBatchId}", result);
}
[HttpGet("{batchId:int}/records")]
[ProducesResponseType(typeof(List<AttendanceRecordDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceRecordDto>>> ListRecords(
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
[HttpPut("{batchId:int}/records/{recordId:int}")]
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceRecordDto>> UpdateRecord(
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/resolve-duplicate")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResolveDuplicate(int batchId, [FromBody] ResolveDuplicateRequest request, CancellationToken ct)
{
await _attendance.ResolveDuplicateAsync(batchId, request, ct);
return NoContent();
}
[HttpPost("{batchId:int}/validate")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
=> Ok(await _attendance.ValidateAsync(batchId, ct));
[HttpPost("{batchId:int}/confirm")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
[HttpPost("{batchId:int}/unlock")]
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AttendanceUploadBatchDto>> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct)
=> Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct));
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Branch master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/branches")]
public sealed class BranchesController : ApiControllerBase
{
private readonly IBranchService _branches;
public BranchesController(IBranchService branches) => _branches = branches;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BranchDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BranchDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _branches.ListAsync(query, status, ct));
[HttpGet("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BranchDto>> GetById(int branchId, CancellationToken ct)
{
var result = await _branches.GetAsync(branchId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BranchDto>> Create([FromBody] CreateBranchRequest request, CancellationToken ct)
{
var result = await _branches.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
}
[HttpPut("{branchId:int}")]
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BranchDto>> Update(int branchId, [FromBody] UpdateBranchRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _branches.UpdateAsync(branchId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{branchId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _branches.SetStatusAsync(branchId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,65 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/departments")]
public sealed class DepartmentsController : ApiControllerBase
{
private readonly IDepartmentService _departments;
public DepartmentsController(IDepartmentService departments) => _departments = departments;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DepartmentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DepartmentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _departments.ListAsync(query, status, ct));
[HttpGet("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DepartmentDto>> GetById(int departmentId, CancellationToken ct)
{
var result = await _departments.GetAsync(departmentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DepartmentDto>> Create([FromBody] CreateDepartmentRequest request, CancellationToken ct)
{
var result = await _departments.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
}
[HttpPut("{departmentId:int}")]
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<DepartmentDto>> Update(int departmentId, [FromBody] UpdateDepartmentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _departments.UpdateAsync(departmentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{departmentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _departments.SetStatusAsync(departmentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/designations")]
public sealed class DesignationsController : ApiControllerBase
{
private readonly IDesignationService _designations;
public DesignationsController(IDesignationService designations) => _designations = designations;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<DesignationDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<DesignationDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _designations.ListAsync(query, status, ct));
[HttpGet("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DesignationDto>> GetById(int designationId, CancellationToken ct)
{
var result = await _designations.GetAsync(designationId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DesignationDto>> Create([FromBody] CreateDesignationRequest request, CancellationToken ct)
{
var result = await _designations.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
}
[HttpPut("{designationId:int}")]
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<DesignationDto>> Update(int designationId, [FromBody] UpdateDesignationRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _designations.UpdateAsync(designationId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{designationId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _designations.SetStatusAsync(designationId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,217 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>
/// Employee (staff) endpoints, incl. the Employee&lt;-&gt;User cross-link and staff
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
/// </summary>
[Route("api/v1/employees")]
public sealed class EmployeesController : ApiControllerBase
{
private readonly IEmployeeService _employees;
private readonly IEmployeeUserLinkService _links;
private readonly IEmployeeDocumentService _documents;
private readonly ILeaveBalanceService _leaveBalances;
private readonly IEmployeeSalaryStructureService _salaryStructures;
private readonly IEmployeeLoanService _loans;
private readonly ICurrentUser _currentUser;
public EmployeesController(
IEmployeeService employees, IEmployeeUserLinkService links, IEmployeeDocumentService documents,
ILeaveBalanceService leaveBalances, IEmployeeSalaryStructureService salaryStructures,
IEmployeeLoanService loans, ICurrentUser currentUser)
{
_employees = employees;
_links = links;
_documents = documents;
_leaveBalances = leaveBalances;
_salaryStructures = salaryStructures;
_loans = loans;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmployeeListItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmployeeListItemDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EmployeeStatus? status,
[FromQuery] int? departmentId, [FromQuery] int? designationId, [FromQuery] int? branchId, CancellationToken ct)
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
[HttpGet("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeDetailDto>> GetById(int employeeId, CancellationToken ct)
{
var result = await _employees.GetAsync(employeeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeDetailDto>> Create([FromBody] CreateEmployeeRequest request, CancellationToken ct)
{
var result = await _employees.CreateAsync(request, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
}
[HttpPut("{employeeId:int}")]
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmployeeDetailDto>> Update(int employeeId, [FromBody] UpdateEmployeeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employees.UpdateAsync(employeeId, request, expected, _currentUser.AuditUserId, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
[HttpPatch("{employeeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employeeId, [FromBody] UpdateEmployeeStatusRequest request, CancellationToken ct)
{
await _employees.SetStatusAsync(employeeId, request.Status, ct);
return NoContent();
}
[HttpPost("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> LinkUser(int employeeId, [FromBody] LinkUserRequest request, CancellationToken ct)
{
await _links.LinkAsync(employeeId, request.UserId, ct);
return NoContent();
}
[HttpDelete("{employeeId:int}/link-user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UnlinkUser(int employeeId, CancellationToken ct)
{
await _links.UnlinkAsync(employeeId, ct);
return NoContent();
}
[HttpGet("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
[HttpPut("{employeeId:int}/bank-details")]
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ReplaceBankDetails(
int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct)
=> Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
[HttpPut("{employeeId:int}/leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<List<LeaveBalanceDto>>> UpdateLeaveBalances(
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
[HttpGet("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
[HttpPost("{employeeId:int}/salary-structure")]
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmployeeSalaryStructureDto>> CreateSalaryStructure(
int employeeId, [FromBody] CreateSalaryStructureRequest request, CancellationToken ct)
{
var result = await _salaryStructures.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
}
[HttpGet("{employeeId:int}/loans")]
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
=> Ok(await _loans.ListAsync(employeeId, ct));
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> GetLoan(int employeeId, int loanId, CancellationToken ct)
{
var result = await _loans.GetAsync(employeeId, loanId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("{employeeId:int}/loans")]
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmployeeLoanDto>> CreateLoan(int employeeId, [FromBody] CreateEmployeeLoanRequest request, CancellationToken ct)
{
var result = await _loans.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
}
[HttpGet("{employeeId:int}/documents")]
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
=> Ok(await _documents.ListAsync(employeeId, ct));
[HttpPost("{employeeId:int}/documents")]
[RequestSizeLimit(20 * 1024 * 1024)]
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<EmployeeDocumentDto>> UploadDocument(
int employeeId, [FromForm] UploadEmployeeDocumentRequest request, IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var result = await _documents.UploadAsync(
employeeId, request, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
}
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DownloadDocument(int employeeId, int documentId, CancellationToken ct)
{
var (content, fileName, contentType) = await _documents.DownloadAsync(employeeId, documentId, ct);
return File(content, contentType, fileName);
}
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetDocumentStatus(
int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct)
{
await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/employment-types")]
public sealed class EmploymentTypesController : ApiControllerBase
{
private readonly IEmploymentTypeService _employmentTypes;
public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<EmploymentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<EmploymentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _employmentTypes.ListAsync(query, status, ct));
[HttpGet("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<EmploymentTypeDto>> GetById(int employmentTypeId, CancellationToken ct)
{
var result = await _employmentTypes.GetAsync(employmentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<EmploymentTypeDto>> Create([FromBody] CreateEmploymentTypeRequest request, CancellationToken ct)
{
var result = await _employmentTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
}
[HttpPut("{employmentTypeId:int}")]
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<EmploymentTypeDto>> Update(int employmentTypeId, [FromBody] UpdateEmploymentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _employmentTypes.UpdateAsync(employmentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{employmentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/hr-document-types")]
public sealed class HrDocumentTypesController : ApiControllerBase
{
private readonly IHrDocumentTypeService _types;
public HrDocumentTypesController(IHrDocumentTypeService types) => _types = types;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<HrDocumentTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<HrDocumentTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _types.ListAsync(query, status, ct));
[HttpGet("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<HrDocumentTypeDto>> GetById(int hrDocumentTypeId, CancellationToken ct)
{
var result = await _types.GetAsync(hrDocumentTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<HrDocumentTypeDto>> Create([FromBody] CreateHrDocumentTypeRequest request, CancellationToken ct)
{
var result = await _types.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
}
[HttpPut("{hrDocumentTypeId:int}")]
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<HrDocumentTypeDto>> Update(int hrDocumentTypeId, [FromBody] UpdateHrDocumentTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _types.UpdateAsync(hrDocumentTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{hrDocumentTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,52 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
[Route("api/v1/reports/hrm")]
public sealed class HrReportsController : ApiControllerBase
{
private readonly IHrReportService _reports;
public HrReportsController(IHrReportService reports) => _reports = reports;
[HttpGet("attendance-summary")]
[ProducesResponseType(typeof(List<AttendanceSummaryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<AttendanceSummaryRowDto>>> AttendanceSummary(
[FromQuery] int periodYear, [FromQuery] int periodMonth, [FromQuery] int? departmentId, CancellationToken ct)
=> Ok(await _reports.AttendanceSummaryAsync(periodYear, periodMonth, departmentId, ct));
[HttpGet("overtime")]
[ProducesResponseType(typeof(List<OvertimeReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<OvertimeReportRowDto>>> Overtime(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct));
[HttpGet("late-arrivals")]
[ProducesResponseType(typeof(List<LateArrivalReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LateArrivalReportRowDto>>> LateArrivals(
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
[HttpGet("payroll-register")]
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
[HttpGet("salary-history")]
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
=> Ok(await _reports.SalaryHistoryAsync(employeeId, ct));
[HttpGet("leave-balances")]
[ProducesResponseType(typeof(List<LeaveBalanceReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<LeaveBalanceReportRowDto>>> LeaveBalances([FromQuery] int year, CancellationToken ct)
=> Ok(await _reports.LeaveBalanceReportAsync(year, ct));
[HttpGet("document-expiry")]
[ProducesResponseType(typeof(List<DocumentExpiryReportRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<DocumentExpiryReportRowDto>>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct)
=> Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct));
}
@@ -0,0 +1,71 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-requests")]
public sealed class LeaveRequestsController : ApiControllerBase
{
private readonly ILeaveRequestService _requests;
private readonly ICurrentUser _currentUser;
public LeaveRequestsController(ILeaveRequestService requests, ICurrentUser currentUser)
{
_requests = requests;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveRequestDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveRequestDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? employeeId, [FromQuery] LeaveRequestStatus? status, CancellationToken ct)
=> Ok(await _requests.ListAsync(query, employeeId, status, ct));
[HttpGet("{leaveRequestId:int}")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveRequestDto>> GetById(int leaveRequestId, CancellationToken ct)
{
var result = await _requests.GetAsync(leaveRequestId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<LeaveRequestDto>> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct)
{
var result = await _requests.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
}
[HttpPost("{leaveRequestId:int}/submit")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
[HttpPost("{leaveRequestId:int}/approve")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/reject")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{leaveRequestId:int}/cancel")]
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveRequestDto>> Cancel(int leaveRequestId, CancellationToken ct)
=> Ok(await _requests.CancelAsync(leaveRequestId, ct));
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
[Route("api/v1/leave-types")]
public sealed class LeaveTypesController : ApiControllerBase
{
private readonly ILeaveTypeService _leaveTypes;
public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<LeaveTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<LeaveTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _leaveTypes.ListAsync(query, status, ct));
[HttpGet("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LeaveTypeDto>> GetById(int leaveTypeId, CancellationToken ct)
{
var result = await _leaveTypes.GetAsync(leaveTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<LeaveTypeDto>> Create([FromBody] CreateLeaveTypeRequest request, CancellationToken ct)
{
var result = await _leaveTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
}
[HttpPut("{leaveTypeId:int}")]
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<LeaveTypeDto>> Update(int leaveTypeId, [FromBody] UpdateLeaveTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _leaveTypes.UpdateAsync(leaveTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{leaveTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,85 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-runs")]
public sealed class PayrollRunsController : ApiControllerBase
{
private readonly IPayrollRunService _payrollRuns;
private readonly ICurrentUser _currentUser;
public PayrollRunsController(IPayrollRunService payrollRuns, ICurrentUser currentUser)
{
_payrollRuns = payrollRuns;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PayrollRunDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PayrollRunDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? periodYear, [FromQuery] int? periodMonth,
[FromQuery] PayrollRunStatus? status, CancellationToken ct)
=> Ok(await _payrollRuns.ListAsync(query, periodYear, periodMonth, status, ct));
[HttpGet("{payrollRunId:int}")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollRunDto>> GetById(int payrollRunId, CancellationToken ct)
{
var result = await _payrollRuns.GetAsync(payrollRunId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payrollRunId:int}/lines")]
[ProducesResponseType(typeof(List<PayrollLineDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollLineDto>>> ListLines(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ListLinesAsync(payrollRunId, ct));
[HttpGet("{payrollRunId:int}/lines/{lineId:int}")]
[ProducesResponseType(typeof(PayrollLineDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayrollLineDetailDto>> GetLine(int payrollRunId, int lineId, CancellationToken ct)
{
var result = await _payrollRuns.GetLineAsync(payrollRunId, lineId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<PayrollRunDto>> Generate([FromBody] GeneratePayrollRunRequest request, CancellationToken ct)
{
var result = await _payrollRuns.GenerateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
}
[HttpPost("{payrollRunId:int}/approve")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/lock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/unlock")]
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
[HttpPost("{payrollRunId:int}/generate-payslips")]
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<List<PayslipDto>>> GeneratePayslips(int payrollRunId, CancellationToken ct)
=> Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct));
}
@@ -0,0 +1,33 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payroll-statutory-settings")]
public sealed class PayrollStatutorySettingsController : ApiControllerBase
{
private readonly IPayrollStatutorySettingService _settings;
private readonly ICurrentUser _currentUser;
public PayrollStatutorySettingsController(IPayrollStatutorySettingService settings, ICurrentUser currentUser)
{
_settings = settings;
_currentUser = currentUser;
}
[HttpGet]
[ProducesResponseType(typeof(List<PayrollStatutorySettingDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
=> Ok(await _settings.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
{
var result = await _settings.CreateAsync(request, _currentUser.AuditUserId, ct);
return Created($"/api/v1/payroll-statutory-settings/{result.PayrollStatutorySettingId}", result);
}
}
@@ -0,0 +1,32 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/payslips")]
public sealed class PayslipsController : ApiControllerBase
{
private readonly IPayslipService _payslips;
public PayslipsController(IPayslipService payslips) => _payslips = payslips;
[HttpGet("{payslipId:int}")]
[ProducesResponseType(typeof(PayslipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PayslipDto>> GetById(int payslipId, CancellationToken ct)
{
var result = await _payslips.GetAsync(payslipId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{payslipId:int}/view")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> View(int payslipId, CancellationToken ct)
{
var html = await _payslips.RenderHtmlAsync(payslipId, ct);
return html is null ? NotFound() : Content(html, "text/html");
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/salary-components")]
public sealed class SalaryComponentsController : ApiControllerBase
{
private readonly ISalaryComponentService _components;
public SalaryComponentsController(ISalaryComponentService components) => _components = components;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalaryComponentDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalaryComponentDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _components.ListAsync(query, status, ct));
[HttpGet("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalaryComponentDto>> GetById(int salaryComponentId, CancellationToken ct)
{
var result = await _components.GetAsync(salaryComponentId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<SalaryComponentDto>> Create([FromBody] CreateSalaryComponentRequest request, CancellationToken ct)
{
var result = await _components.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
}
[HttpPut("{salaryComponentId:int}")]
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalaryComponentDto>> Update(int salaryComponentId, [FromBody] UpdateSalaryComponentRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _components.UpdateAsync(salaryComponentId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{salaryComponentId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,28 @@
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
[Route("api/v1/tax-slabs")]
public sealed class TaxSlabsController : ApiControllerBase
{
private readonly ITaxSlabService _taxSlabs;
public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs;
[HttpGet]
[ProducesResponseType(typeof(List<TaxSlabDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
=> Ok(await _taxSlabs.ListAsync(ct));
[HttpPost]
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TaxSlabDto>> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct)
{
var result = await _taxSlabs.CreateAsync(request, ct);
return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result);
}
}
@@ -0,0 +1,64 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers.Hrm;
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
[Route("api/v1/work-shifts")]
public sealed class WorkShiftsController : ApiControllerBase
{
private readonly IWorkShiftService _shifts;
public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<WorkShiftDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<WorkShiftDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _shifts.ListAsync(query, status, ct));
[HttpGet("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WorkShiftDto>> GetById(int workShiftId, CancellationToken ct)
{
var result = await _shifts.GetAsync(workShiftId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<WorkShiftDto>> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct)
{
var result = await _shifts.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
}
[HttpPut("{workShiftId:int}")]
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<WorkShiftDto>> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{workShiftId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
{
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,157 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production run endpoints (docs/30-BACKEND-PHASE2.md §D.2D.3).</summary>
[Route("api/v1/production-runs")]
public sealed class ProductionRunsController : ApiControllerBase
{
private readonly IProductionRunService _runs;
public ProductionRunsController(IProductionRunService runs) => _runs = runs;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RunSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RunSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] ProductionRunStatus? status,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
[HttpGet("{runId:int}")]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RunGraphDto>> GetById(int runId, CancellationToken ct)
{
var result = await _runs.GetAsync(runId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunGraphDto>> Create([FromBody] CreateRunRequest request, CancellationToken ct)
{
var result = await _runs.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-runs/{result.Value.RunId}", result.Value);
}
[HttpPut("{runId:int}/stages/{runStageId:int}/quantities")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunStageDto>> UpdateQuantities(
int runId, int runStageId, [FromBody] UpdateStageQuantitiesRequest request, CancellationToken ct)
=> Ok(await _runs.UpdateStageQuantitiesAsync(runId, runStageId, request, ct));
// --- stage actions (docs/30 §D.3) ---------------------------------------
//
// Idempotency-Key is accepted on every action to match the Phase-1 contract (docs/11
// §1.6) but, as in GrnService.ConfirmAsync, it is not stored. Replay safety comes from
// the status guards instead: a double-fire finds the stage already moved on and gets a
// 409, which docs/21 §6 tells the client to treat as a silent refetch. Recorded as a
// deviation from §D.3's "idempotency-key honored".
[HttpPost("{runId:int}/stages/{runStageId:int}/start")]
[ProducesResponseType(typeof(StartStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<StartStageResultDto>> Start(
int runId, int runStageId,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.StartStageAsync(runId, runStageId, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/complete")]
[ProducesResponseType(typeof(RunStageDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RunStageDto>> Complete(
int runId, int runStageId, [FromBody] CompleteStageRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CompleteStageAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/approve")]
[ProducesResponseType(typeof(ApproveStageResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ApproveStageResultDto>> Approve(
int runId, int runStageId, [FromBody] ApproveStageRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ApproveStageAsync(runId, runStageId, request ?? new ApproveStageRequest(), ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/transfer")]
[ProducesResponseType(typeof(TransferResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TransferResultDto>> Transfer(
int runId, int runStageId, [FromBody] TransferRemainderRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.TransferAsync(runId, runStageId, request, ct));
[HttpPost("{runId:int}/inputs/{runInputId:int}/return-leftover")]
[ProducesResponseType(typeof(ReturnLeftoverResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ReturnLeftoverResultDto>> ReturnLeftover(
int runId, int runInputId, [FromBody] ReturnLeftoverRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.ReturnLeftoverAsync(runId, runInputId, request, ct));
[HttpPost("{runId:int}/stages/{runStageId:int}/reject-intake")]
[ProducesResponseType(typeof(RejectIntakeResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<RejectIntakeResultDto>> RejectIntake(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectIntakeAsync(runId, runStageId, request ?? new RejectRequest(), ct));
/// <summary>Terminal reject — resets the whole run for a rework pass (FR-MFG-16).</summary>
[HttpPost("{runId:int}/stages/{runStageId:int}/reject")]
[ProducesResponseType(typeof(TerminalRejectResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<TerminalRejectResultDto>> Reject(
int runId, int runStageId, [FromBody] RejectRequest? request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.RejectTerminalAsync(runId, runStageId, request ?? new RejectRequest(), ct));
[HttpPost("{runId:int}/cancel")]
[ProducesResponseType(typeof(CancelRunResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<CancelRunResultDto>> Cancel(
int runId, [FromBody] CancelRunRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey,
CancellationToken ct)
=> Ok(await _runs.CancelAsync(runId, request, ct));
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Production;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Production template endpoints (docs/30-BACKEND-PHASE2.md §D.1).</summary>
[Route("api/v1/production-templates")]
public sealed class ProductionTemplatesController : ApiControllerBase
{
private readonly IProductionTemplateService _templates;
public ProductionTemplatesController(IProductionTemplateService templates) => _templates = templates;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TemplateSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TemplateSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _templates.ListAsync(query, status, ct));
[HttpGet("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TemplateGraphDto>> GetById(int templateId, CancellationToken ct)
{
var result = await _templates.GetAsync(templateId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Create(
[FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var result = await _templates.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/production-templates/{result.Value.TemplateId}", result.Value);
}
[HttpPut("{templateId:int}")]
[ProducesResponseType(typeof(TemplateGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<TemplateGraphDto>> Update(
int templateId, [FromBody] SaveTemplateRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _templates.UpdateAsync(templateId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{templateId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int templateId, [FromBody] UpdateTemplateStatusRequest request, CancellationToken ct)
{
await _templates.SetStatusAsync(templateId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-invoices")]
public sealed class SalesInvoicesController : ApiControllerBase
{
private readonly ISalesInvoiceService _invoices;
public SalesInvoicesController(ISalesInvoiceService invoices) => _invoices = invoices;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesInvoiceSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesInvoiceSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesInvoiceStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoiceDto>> GetById(int salesInvoiceId, CancellationToken ct)
{
var result = await _invoices.GetAsync(salesInvoiceId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{salesInvoiceId:int}/posting-check")]
[ProducesResponseType(typeof(SalesInvoicePostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesInvoicePostingCheckDto>> PostingCheck(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CheckPostingAsync(salesInvoiceId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesInvoiceDto>> Create([FromBody] CreateSalesInvoiceRequest request, CancellationToken ct)
{
var result = await _invoices.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/sales-invoices/{result.Value.SalesInvoiceId}", result.Value);
}
[HttpPut("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesInvoiceDto>> Update(int salesInvoiceId, [FromBody] UpdateSalesInvoiceRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _invoices.UpdateAsync(salesInvoiceId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{salesInvoiceId:int}/post")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Post(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.PostAsync(salesInvoiceId, ct));
[HttpPost("{salesInvoiceId:int}/cancel")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesInvoiceDto>> Cancel(int salesInvoiceId, CancellationToken ct)
=> Ok(await _invoices.CancelAsync(salesInvoiceId, ct));
}
@@ -0,0 +1,35 @@
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/reports/sales")]
public sealed class SalesReportsController : ApiControllerBase
{
private readonly ISalesReportService _reports;
public SalesReportsController(ISalesReportService reports) => _reports = reports;
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<SalesReportDefinitionDto>), StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<SalesReportDefinitionDto>> ListReports()
=> Ok(_reports.ListReports());
[HttpGet("{reportId}")]
[ProducesResponseType(typeof(SalesReportDefinitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<SalesReportDefinitionDto> GetReport(string reportId)
{
var report = _reports.GetReport(reportId);
return report is null ? NotFound() : Ok(report);
}
[HttpPost("query")]
[ProducesResponseType(typeof(SalesReportQueryResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesReportQueryResponse>> Query([FromBody] SalesReportQueryRequest request, CancellationToken ct)
{
var rows = await _reports.QueryAsync(request.ReportType, request.From, request.To, request.ItemId, request.CustomerId, request.WarehouseId, ct);
return Ok(new SalesReportQueryResponse(request.ReportType, request.From, request.To, rows));
}
}
@@ -0,0 +1,22 @@
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips/{salesSlipId:int}/free-issue-suggestions")]
public sealed class SalesSlipPromotionsController : ApiControllerBase
{
private readonly ISalesPromotionSuggestionService _suggestions;
public SalesSlipPromotionsController(ISalesPromotionSuggestionService suggestions) => _suggestions = suggestions;
[HttpGet]
[ProducesResponseType(typeof(SalesFreeIssueSuggestionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesFreeIssueSuggestionDto>> Get(int salesSlipId, CancellationToken ct)
{
var result = await _suggestions.GetFreeIssueSuggestionsAsync(salesSlipId, ct);
return result is null ? NotFound() : Ok(result);
}
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Sales;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
[Route("api/v1/sales-slips")]
public sealed class SalesSlipsController : ApiControllerBase
{
private readonly ISalesSlipService _slips;
public SalesSlipsController(ISalesSlipService slips) => _slips = slips;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<SalesSlipSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<SalesSlipSummaryDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] SalesSlipStatus? status,
[FromQuery] int? customerId,
[FromQuery] int? warehouseId,
CancellationToken ct)
=> Ok(await _slips.ListAsync(query, status, customerId, warehouseId, ct));
[HttpGet("{salesSlipId:int}")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipDto>> GetById(int salesSlipId, CancellationToken ct)
{
var result = await _slips.GetAsync(salesSlipId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpGet("{salesSlipId:int}/posting-check")]
[ProducesResponseType(typeof(SalesSlipPostingCheckDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SalesSlipPostingCheckDto>> PostingCheck(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CheckPostingAsync(salesSlipId, ct));
[HttpPost]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status201Created)]
public async Task<ActionResult<SalesSlipDto>> Create([FromBody] CreateSalesSlipRequest request, CancellationToken ct)
{
var result = await _slips.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/sales-slips/{result.Value.SalesSlipId}", result.Value);
}
[HttpPut("{salesSlipId:int}")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SalesSlipDto>> Update(int salesSlipId, [FromBody] UpdateSalesSlipRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _slips.UpdateAsync(salesSlipId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost("{salesSlipId:int}/post")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesSlipDto>> Post(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.PostAsync(salesSlipId, ct));
[HttpPost("{salesSlipId:int}/cancel")]
[ProducesResponseType(typeof(SalesSlipDto), StatusCodes.Status200OK)]
public async Task<ActionResult<SalesSlipDto>> Cancel(int salesSlipId, CancellationToken ct)
=> Ok(await _slips.CancelAsync(salesSlipId, ct));
}
+13 -1
View File
@@ -1,4 +1,5 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Hrm;
using ERPCore.Dtos.Users;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -13,8 +14,19 @@ namespace ERPCore.Controllers;
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
private readonly IEmployeeUserLinkService _links;
public UsersController(IUserManagementService users) => _users = users;
public UsersController(IUserManagementService users, IEmployeeUserLinkService links)
{
_users = users;
_links = links;
}
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
[HttpGet("email-lookup")]
[ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<EmployeeMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
=> Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct)));
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
+5
View File
@@ -14,4 +14,9 @@ public static class DocumentTypes
public const string Adjustment = "ADJ";
public const string Count = "CNT";
public const string PurchaseReturn = "PRET";
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
public const string Production = "PRD";
public const string SalesInvoice = "SI";
public const string SalesSlip = "SSL";
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/day attendance row (FR-HR-ATT). <see cref="WorkShiftId"/> is
/// snapshotted from the employee's shift at ingestion time (docs/12-BACKEND-HRM.md
/// A.3) so a later shift reassignment never retroactively changes historical
/// Late/OT figures. Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceRecord
{
public int AttendanceRecordId { get; set; }
public int? AttendanceUploadBatchId { get; set; }
public AttendanceUploadBatch? AttendanceUploadBatch { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime AttendanceDate { get; set; }
public TimeSpan? CheckIn { get; set; }
public TimeSpan? CheckOut { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int WorkingMinutes { get; set; }
public int LateMinutes { get; set; }
public int EarlyLeaveMinutes { get; set; }
public int OvertimeMinutes { get; set; }
public AttendanceStatus AttendanceStatus { get; set; }
public RowValidationStatus RowValidationStatus { get; set; } = RowValidationStatus.Valid;
public int? DuplicateOfAttendanceRecordId { get; set; }
public string? Notes { get; set; }
public bool IsManualOverride { get; set; }
public int? EditedBy { get; set; }
public DateTime? EditedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Attendance upload batch (FR-HR-ATT) — the transactional document driving the
/// exact status flow Draft→Validated→Confirmed→UsedInPayroll. Scoped to exactly
/// one payroll period, numbered via <see cref="NumberSequence"/> (docType "ATT").
/// Model: docs/12-BACKEND-HRM.md Part C.4.
/// </summary>
public class AttendanceUploadBatch
{
public int AttendanceUploadBatchId { get; set; }
public string DocNo { get; set; } = string.Empty;
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
public AttendanceSourceType SourceType { get; set; }
public string? OriginalFileName { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public AttendanceBatchStatus Status { get; set; } = AttendanceBatchStatus.Draft;
public int? ConfirmedBy { get; set; }
public DateTime? ConfirmedAt { get; set; }
public int RowCountTotal { get; set; }
public int RowCountDuplicate { get; set; }
public int RowCountError { get; set; }
public uint RowVersion { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Branch/location master (FR-HR-MD-01) — multi-branch readiness. Referenced
/// optionally by <see cref="Employee.BranchId"/> and <see cref="PayrollRun.BranchId"/>.
/// Deactivated, not deleted, when referenced. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Branch
{
public int BranchId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,38 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Customer master for both B2B and B2C sales.
/// Phase 1 keeps this lean: identity, contact, tax, credit, and default warehouse.
/// </summary>
public class Customer
{
public int CustomerId { get; set; }
public string CustomerCode { get; set; } = string.Empty;
public CustomerType CustomerType { get; set; } = CustomerType.B2C;
public string Name { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? Country { get; set; }
public string? TaxRegistrationNo { get; set; }
public decimal CreditLimit { get; set; }
public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
public Warehouse? DefaultWarehouse { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Department master (FR-HR-MD-01) — unlimited self-nesting for a real org chart
/// (unlike the two-level-capped <see cref="Category"/>); cycle prevention is a
/// service-level check on write, not a DB constraint. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Department
{
public int DepartmentId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public int? ParentDepartmentId { get; set; }
public Department? ParentDepartment { get; set; }
public int? HeadEmployeeId { get; set; }
public Employee? HeadEmployee { get; set; }
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Job title master (FR-HR-MD-01), standalone — not FK'd to Department, since a
/// title like "Accountant" can exist in multiple departments. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class Designation
{
public int DesignationId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,76 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
/// account): not every employee has a login, and not every login belongs to an
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
/// year-scoping is the wrong shape for an identifier that must never look "reset".
/// </summary>
public class Employee
{
public int EmployeeId { get; set; }
public string EmployeeCode { get; set; } = string.Empty;
// Identity
public string FullName { get; set; } = string.Empty;
public string? Nic { get; set; }
public DateTime? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
public string? Nationality { get; set; }
public string? ProfilePhotoPath { get; set; }
// Contact
/// <summary>The field used for the bidirectional Employee&lt;-&gt;User email cross-check.</summary>
public string? Email { get; set; }
public string? PersonalMobile { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? PostalCode { get; set; }
public string? Country { get; set; }
// Emergency contact
public string? EmergencyContactName { get; set; }
public string? EmergencyContactRelationship { get; set; }
public string? EmergencyContactPhone { get; set; }
// Employment
public DateTime HireDate { get; set; }
public DateTime? ConfirmationDate { get; set; }
public DateTime? LastWorkingDate { get; set; }
public int DepartmentId { get; set; }
public Department? Department { get; set; }
public int DesignationId { get; set; }
public Designation? Designation { get; set; }
public int EmploymentTypeId { get; set; }
public EmploymentType? EmploymentType { get; set; }
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public int WorkShiftId { get; set; }
public WorkShift? WorkShift { get; set; }
public int? ReportingManagerId { get; set; }
public Employee? ReportingManager { get; set; }
// Statutory (Sri Lanka)
public string? EpfNumber { get; set; }
public string? EtfNumber { get; set; }
public string? TaxIdentificationNumber { get; set; }
/// <summary>Optional login account link (unique — one User backs at most one Employee).</summary>
public int? UserId { get; set; }
public User? User { get; set; }
public EmployeeStatus Status { get; set; } = EmployeeStatus.Active;
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public int? UpdatedBy { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,28 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Employee bank account (FR-HR-MD-03), one-to-many — a future split-payment
/// improvement is possible since this isn't a 1:1 scalar set. Exactly one row per
/// employee is <see cref="IsPrimary"/>; payroll disbursement targets it.
/// Model: docs/12-BACKEND-HRM.md Part C.2.
/// </summary>
public class EmployeeBankDetail
{
public int EmployeeBankDetailId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public string BankName { get; set; } = string.Empty;
public string BranchName { get; set; } = string.Empty;
public string AccountNumber { get; set; } = string.Empty;
public string AccountHolderName { get; set; } = string.Empty;
public string? SwiftCode { get; set; }
public bool IsPrimary { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Uploaded staff document (FR-HR-DOC-02..04) — the user's "Doc". <see cref="StoredFileName"/>/
/// <see cref="RelativePath"/> are server-generated (never the client's filename), so the
/// file is only ever reachable through <see cref="Services.Interfaces.IFileStorageService"/>,
/// never a guessable static path. Archived, not deleted, so the audit trail of what was
/// once on file is retained. Model: docs/12-BACKEND-HRM.md Part C.3.
/// </summary>
public class EmployeeDocument
{
public int EmployeeDocumentId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int HrDocumentTypeId { get; set; }
public HrDocumentType? HrDocumentType { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredFileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public DateTime? IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public string? Notes { get; set; }
public int UploadedBy { get; set; }
public DateTime UploadedAt { get; set; }
public int? VerifiedBy { get; set; }
public DateTime? VerifiedAt { get; set; }
public EmployeeDocumentStatus Status { get; set; } = EmployeeDocumentStatus.Active;
public uint RowVersion { get; set; }
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Loan/Advance (FR-HR-PAY-03) — <see cref="LoanKind"/> discriminates, structurally
/// identical otherwise. <see cref="OutstandingBalance"/> is denormalized (parallel to
/// <c>StockLayer.QtyRemaining</c>). Numbered via <see cref="NumberSequence"/> (docType "LOAN").
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class EmployeeLoan
{
public int EmployeeLoanId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public LoanKind LoanKind { get; set; }
public decimal PrincipalAmount { get; set; }
public decimal InterestRate { get; set; }
public decimal InstallmentAmount { get; set; }
public int NumberOfInstallments { get; set; }
public int StartYear { get; set; }
public int StartMonth { get; set; }
public decimal OutstandingBalance { get; set; }
public LoanStatus Status { get; set; } = LoanStatus.Active;
public int ApprovedBy { get; set; }
public DateTime ApprovedAt { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public List<LoanInstallment> Installments { get; set; } = new();
}
@@ -0,0 +1,30 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Effective-dated salary structure header (FR-HR-PAY-02) — the audit trail a
/// salary revision needs (docs/12-BACKEND-HRM.md §13): exactly one row with
/// <see cref="EffectiveTo"/> null (the current one) per employee at a time.
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class EmployeeSalaryStructure
{
public int EmployeeSalaryStructureId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public decimal BasicSalary { get; set; }
public string Currency { get; set; } = "LKR";
public SalaryStructureStatus Status { get; set; } = SalaryStructureStatus.Active;
public int ApprovedBy { get; set; }
public DateTime ApprovedAt { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public List<EmployeeSalaryStructureLine> Lines { get; set; } = new();
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Entities;
/// <summary>Allowance/other-deduction line on a salary structure. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class EmployeeSalaryStructureLine
{
public int EmployeeSalaryStructureLineId { get; set; }
public int EmployeeSalaryStructureId { get; set; }
public EmployeeSalaryStructure? EmployeeSalaryStructure { get; set; }
public int SalaryComponentId { get; set; }
public SalaryComponent? SalaryComponent { get; set; }
public decimal Amount { get; set; }
}
@@ -0,0 +1,20 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Labor category master (FR-HR-MD-01) — a master, not an enum, mirroring
/// <see cref="Brand"/>: employment categories change with company/labor-law
/// policy without wanting a code deploy. Model: docs/12-BACKEND-HRM.md Part C.1.
/// </summary>
public class EmploymentType
{
public int EmploymentTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,24 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Staff document catalog (FR-HR-DOC-01) — the user's "DocType": a category of
/// document (NIC, contract, certificate...), not the uploaded file itself (see
/// <see cref="EmployeeDocument"/>, the "Doc"). Deactivated, not deleted, when
/// referenced. Model: docs/12-BACKEND-HRM.md Part C.3.
/// </summary>
public class HrDocumentType
{
public int HrDocumentTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public HrDocumentCategory Category { get; set; }
public bool RequiredAtOnboarding { get; set; }
public bool ExpiryTracked { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,24 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per employee/type/year leave entitlement (FR-HR-LV-03). Unique on
/// (EmployeeId, LeaveTypeId, Year). RemainingDays is a computed projection, not
/// stored. Model: docs/12-BACKEND-HRM.md Part C.5.
/// </summary>
public class LeaveBalance
{
public int LeaveBalanceId { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int LeaveTypeId { get; set; }
public LeaveType? LeaveType { get; set; }
public int Year { get; set; }
public decimal EntitledDays { get; set; }
public decimal TakenDays { get; set; }
public decimal CarriedForwardDays { get; set; }
public decimal AdjustmentDays { get; set; }
public uint RowVersion { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Leave request (FR-HR-LV-02) — transactional document, numbered via
/// <see cref="NumberSequence"/> (docType "LV"). Model: docs/12-BACKEND-HRM.md Part C.5.
/// </summary>
public class LeaveRequest
{
public int LeaveRequestId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public int LeaveTypeId { get; set; }
public LeaveType? LeaveType { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public decimal DaysCount { get; set; }
public string? Reason { get; set; }
public LeaveRequestStatus Status { get; set; } = LeaveRequestStatus.Draft;
public int? ApprovedBy { get; set; }
public DateTime? ApprovedAt { get; set; }
public string? RejectionReason { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,23 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>Leave type master (FR-HR-LV-01). Model: docs/12-BACKEND-HRM.md Part C.5.</summary>
public class LeaveType
{
public int LeaveTypeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsPaid { get; set; } = true;
/// <summary>Feeds Payroll's No-Pay deduction when true (docs/12-BACKEND-HRM.md §6).</summary>
public bool CountsAsNoPay { get; set; }
public decimal AccrualPerYear { get; set; }
public bool CarryForwardAllowed { get; set; }
public int? MaxCarryForwardDays { get; set; }
public bool RequiresApproval { get; set; } = true;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Loan installment ledger row. <see cref="PayrollRunId"/> is stamped only when the
/// consuming <see cref="PayrollRun"/> reaches Locked (docs/12-BACKEND-HRM.md A.4).
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class LoanInstallment
{
public int LoanInstallmentId { get; set; }
public int EmployeeLoanId { get; set; }
public EmployeeLoan? EmployeeLoan { get; set; }
public int InstallmentNumber { get; set; }
public int DueYear { get; set; }
public int DueMonth { get; set; }
public decimal ScheduledAmount { get; set; }
public decimal? PaidAmount { get; set; }
public int? PayrollRunId { get; set; }
public PayrollRun? PayrollRun { get; set; }
public LoanInstallmentStatus Status { get; set; } = LoanInstallmentStatus.Pending;
public uint RowVersion { get; set; }
}
@@ -0,0 +1,41 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Per-employee payroll summary row (FR-HR-PAY-05). EpfEmployerAmount/EtfEmployerAmount
/// are informational/liability only, never subtracted from NetSalary
/// (docs/12-BACKEND-HRM.md B.4). Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollLine
{
public int PayrollLineId { get; set; }
public int PayrollRunId { get; set; }
public PayrollRun? PayrollRun { get; set; }
public int EmployeeId { get; set; }
public Employee? Employee { get; set; }
public decimal BasicSalary { get; set; }
public decimal TotalAllowances { get; set; }
public decimal OvertimeAmount { get; set; }
public decimal GrossSalary { get; set; }
public decimal LateDeductionAmount { get; set; }
public decimal NoPayAmount { get; set; }
public decimal LoanDeductionAmount { get; set; }
public decimal EpfEmployeeAmount { get; set; }
public decimal EpfEmployerAmount { get; set; }
public decimal EtfEmployerAmount { get; set; }
public decimal TaxAmount { get; set; }
public decimal OtherDeductionsAmount { get; set; }
public decimal NetSalary { get; set; }
public int WorkingDays { get; set; }
public int PresentDays { get; set; }
public int AbsentDays { get; set; }
public int LeaveDays { get; set; }
public int OtMinutesTotal { get; set; }
public int LateMinutesTotal { get; set; }
public uint RowVersion { get; set; }
public List<PayrollLineComponent> Components { get; set; } = new();
}
@@ -0,0 +1,18 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>The detailed Basic/Transport/Meal/OT/Late/No-Pay/Loan/EPF/ETF/Tax breakdown. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class PayrollLineComponent
{
public int PayrollLineComponentId { get; set; }
public int PayrollLineId { get; set; }
public PayrollLine? PayrollLine { get; set; }
public PayrollLineComponentCategory ComponentCategory { get; set; }
/// <summary>Set only for structure-sourced Allowance/OtherDeduction lines; null for system-computed lines.</summary>
public int? SalaryComponentId { get; set; }
public SalaryComponent? SalaryComponent { get; set; }
public string Label { get; set; } = string.Empty;
public decimal Amount { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Payroll run (FR-HR-PAY-05/06) — the transactional document. Numbered via
/// <see cref="NumberSequence"/> (docType "PAY"). Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollRun
{
public int PayrollRunId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int PeriodYear { get; set; }
public int PeriodMonth { get; set; }
/// <summary>Null = company-wide run.</summary>
public int? BranchId { get; set; }
public Branch? Branch { get; set; }
public PayrollRunStatus Status { get; set; } = PayrollRunStatus.Draft;
public int GeneratedBy { get; set; }
public DateTime GeneratedAt { get; set; }
public int? ApprovedBy { get; set; }
public DateTime? ApprovedAt { get; set; }
public int? LockedBy { get; set; }
public DateTime? LockedAt { get; set; }
public int? UnlockedBy { get; set; }
public DateTime? UnlockedAt { get; set; }
public string? UnlockReason { get; set; }
public uint RowVersion { get; set; }
public List<PayrollLine> Lines { get; set; } = new();
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Effective-dated EPF/ETF rates (FR-HR-PAY-04) — Sri Lanka defaults (EPF 8%
/// employee / 12% employer, ETF 3% employer-only), configurable since government
/// rates can change. Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class PayrollStatutorySetting
{
public int PayrollStatutorySettingId { get; set; }
public decimal EpfEmployeeRate { get; set; } = 0.08m;
public decimal EpfEmployerRate { get; set; } = 0.12m;
public decimal EtfEmployerRate { get; set; } = 0.03m;
public decimal OtMultiplierDefault { get; set; } = 1.5m;
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public int CreatedBy { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Thin generation/release marker over a <see cref="PayrollLine"/> — ships as an
/// HTML print view in this phase, per the confirmed decision (no PDF dependency).
/// Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class Payslip
{
public int PayslipId { get; set; }
public int PayrollLineId { get; set; }
public PayrollLine? PayrollLine { get; set; }
public DateTime GeneratedAt { get; set; }
public DateTime? ReleasedAt { get; set; }
public int? ReleasedBy { get; set; }
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One execution instance of a template (FR-MFG-08), numbered <c>PRD-2026-00001</c>.
/// Every stage, input, output and edge is <b>copied</b> from the template at creation
/// with quantities scaled by <see cref="ScaleFactor"/>, so a completed run stays
/// readable even if the template is later edited (FR-MFG-06).
/// <para>The run's <b>cost pool</b> is derived, never stored:
/// <c>Σ RunStageInput.ConsumedValue Σ RunStageInput.ReturnedValue</c>. The terminal
/// approve divides it by the good quantity to cost the finished layer, then closes it
/// (FR-MFG-13, <c>409 RUN_COST_CLOSED</c>).</para>
/// Mutable aggregate with a <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
public class ProductionRun
{
public int RunId { get; set; }
public string DocNo { get; set; } = string.Empty;
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
/// <summary>Stock inputs are consumed from, and the finished good received into, this warehouse.</summary>
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
/// <summary>Optional destination bin for the finished goods. Reaches the ledger only — stock layers carry no bin.</summary>
public int? OutputBinId { get; set; }
public Bin? OutputBin { get; set; }
/// <summary>Target quantity of the finished item; drives <see cref="ScaleFactor"/>.</summary>
public decimal TargetQty { get; set; }
/// <summary><c>TargetQty / terminalOutput.QtyPerBatch</c>, applied to every copied quantity.</summary>
public decimal ScaleFactor { get; set; }
public ProductionRunStatus Status { get; set; } = ProductionRunStatus.InProgress;
/// <summary>Incremented by each terminal reject (FR-MFG-16); prior figures live in the event history.</summary>
public int ReworkCount { get; set; }
public int? CancelReasonCodeId { get; set; }
public ReasonCode? CancelReason { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Set by the terminal approve only. A cancelled run leaves this null.</summary>
public DateTime? CompletedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<RunStage> Stages { get; set; } = new List<RunStage>();
public ICollection<RunEdge> Edges { get; set; } = new List<RunEdge>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A reusable production-line definition — the stage graph designed on the canvas
/// (FR-MFG-01). Never hard-deleted once referenced by a run; deactivated instead
/// (FR-MD-08 posture). Editing is locked while any run of it is InProgress
/// (FR-MFG-06, <c>409 TEMPLATE_IN_USE</c>) — edit-lock replaces versioning, which is
/// why runs copy display fields at creation. Mutable aggregate with a
/// <see cref="RowVersion"/> token. Model: docs/30 Part C.
/// </summary>
public class ProductionTemplate
{
public int TemplateId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
/// <summary>
/// Canvas-only annotations (grouping boxes and divider lines) as a jsonb array, stored
/// verbatim and never interpreted server-side.
/// </summary>
/// <remarks>
/// Not in docs/30 Part C — added because the builder canvas already draws these and
/// without somewhere to keep them a save would silently discard the user's layout notes.
/// They carry no graph semantics: no ports, no edges, and the validator never sees them.
/// Nullable so a template that has none stores nothing rather than an empty array.
/// </remarks>
public string? Annotations { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<TemplateStage> Stages { get; set; } = new List<TemplateStage>();
public ICollection<StageEdge> Edges { get; set; } = new List<StageEdge>();
public ICollection<ProductionRun> Runs { get; set; } = new List<ProductionRun>();
}
@@ -0,0 +1,28 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A parent → child arrow copied from the template's <see cref="StageEdge"/> set at run
/// creation.
/// <para><b>Addition to docs/30 Part C (recorded).</b> The doc's entity model has no run
/// edge table, but the run graph needs its own copy: deriving edges at read time through
/// <c>RunStage.TemplateStageId → STAGE_EDGE</c> would let a later template edit silently
/// rewrite completed-run history — the exact thing FR-MFG-06 exists to prevent — and
/// breaks outright once that link is nulled by a stage deletion.</para>
/// <para>Used for the run canvas, child-readiness evaluation and reject-intake's
/// "delivering parents". Note that <b>WIP delivery is routed by
/// <c>RunStageInput.FromRunOutputId</c>, not by these edges</b> — an edge is display and
/// validation only.</para>
/// </summary>
public class RunEdge
{
public int RunEdgeId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
public int ParentRunStageId { get; set; }
public RunStage? ParentRunStage { get; set; }
public int ChildRunStageId { get; set; }
public RunStage? ChildRunStage { get; set; }
}
@@ -0,0 +1,59 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One stage of a run — a copy of a <see cref="TemplateStage"/> taken at run creation
/// (FR-MFG-06), carrying its own live status and actual timings. Model: docs/30 Part C.
/// <para><b>Whether this stage is terminal is derived</b>, never stored: a stage is
/// terminal when it has no outbound <see cref="RunEdge"/>. Storing it would let it
/// drift from the edge set.</para>
/// </summary>
public class RunStage
{
public int RunStageId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>
/// Provenance link back to the template stage. <b>Nullable</b>: a template PUT may
/// delete a stage while completed/cancelled runs still reference it (the edit-lock
/// only blocks edits during InProgress runs), so the FK is <c>SET NULL</c> rather
/// than blocking the edit forever. Everything needed to display a historical run is
/// copied below, which is exactly what FR-MFG-06 anticipates.
/// </summary>
public int? TemplateStageId { get; set; }
public TemplateStage? TemplateStage { get; set; }
// --- copied from the template at run creation (FR-MFG-06) ---
public string Name { get; set; } = string.Empty;
public string? RoleLabel { get; set; }
public int EstimatedMinutes { get; set; }
public decimal PosX { get; set; }
public decimal PosY { get; set; }
public ProductionStageStatus Status { get; set; } = ProductionStageStatus.Waiting;
/// <summary>Stamped at start; preserved across a reject-intake rework so the original start stands (FR-MFG-19).</summary>
public DateTime? ActualStartAt { get; set; }
public DateTime? ActualEndAt { get; set; }
/// <summary>Copied from the template stage; definitions survive a rework.</summary>
public string FieldDefs { get; set; } = "[]";
/// <summary>Captured at complete as a jsonb object; cleared by a terminal reject so required fields are re-answered.</summary>
public string? FieldValues { get; set; }
/// <summary>
/// Concurrency token. Stage actions re-read the stage inside their transaction and
/// let this xmin check serialize concurrent requests — it is what stops two
/// simultaneous terminal approves from both reading <c>Done</c> and posting two
/// receipts. See the idempotency note in <c>ProductionRunService</c>.
/// </summary>
public uint RowVersion { get; set; }
public ICollection<RunStageInput> Inputs { get; set; } = new List<RunStageInput>();
public ICollection<RunStageOutput> Outputs { get; set; } = new List<RunStageOutput>();
public ICollection<RunStageEvent> Events { get; set; } = new List<RunStageEvent>();
}
@@ -0,0 +1,40 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Immutable history of everything that happened to a run (docs/30 Part C). Written by
/// every mutating action and never updated or deleted, so a run's story — including the
/// figures discarded by each rework — survives in full.
/// <para><b>Addition to docs/30 Part C (recorded):</b> <see cref="RunId"/>. The doc hangs
/// events off the stage only, which leaves run-level events (cancel, terminal reject)
/// with no home and forces the detail timeline to join through stages. Keeping both
/// links makes <see cref="RunStageId"/> optional and the timeline a single query.</para>
/// </summary>
public class RunStageEvent
{
public int EventId { get; set; }
public int RunId { get; set; }
public ProductionRun? Run { get; set; }
/// <summary>Null for run-level events (Cancel).</summary>
public int? RunStageId { get; set; }
public RunStage? RunStage { get; set; }
public RunStageEventType EventType { get; set; }
public string? Note { get; set; }
/// <summary>
/// Event-specific detail as jsonb — consumed layers on a Start, pulled-back
/// quantities on a RejectIntake, the full pre-rework snapshot on a TerminalReject.
/// Pre-serialized string, written only through <c>ProductionJson</c>.
/// </summary>
public string? Payload { get; set; }
public int UserId { get; set; }
public User? User { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -0,0 +1,55 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One input line of a run stage — a copy of a <see cref="StageInput"/> with its
/// quantity scaled at creation, plus the live consumption/delivery figures.
/// Model: docs/30 Part C.
/// <para><b>Stock inputs</b> accumulate <see cref="ConsumedQty"/>/<see cref="ConsumedValue"/>
/// at each start and <see cref="ReturnedQty"/>/<see cref="ReturnedValue"/> on leftover
/// return or run cancel. Those four columns are the whole cost pool
/// (<c>Σ consumed Σ returned</c>) and are deliberately <b>not</b> reset by a terminal
/// reject — already-consumed material stays in the pool (FR-MFG-16).</para>
/// <para><b>Upstream inputs</b> accumulate <see cref="DeliveredQty"/> as parent stages
/// transfer WIP in. The stage becomes Ready only when every upstream input has
/// <c>DeliveredQty &gt;= PlannedQty</c> (FR-MFG-09, an all-parents join).</para>
/// </summary>
public class RunStageInput
{
public int RunInputId { get; set; }
public int RunStageId { get; set; }
public RunStage? RunStage { get; set; }
public StageInputSource Source { get; set; }
public int? ItemId { get; set; }
public Item? Item { get; set; }
/// <summary>The parent output feeding this input. This — not <see cref="RunEdge"/> — is what routes a transfer.</summary>
public int? FromRunOutputId { get; set; }
public RunStageOutput? FromRunOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts (FR-MFG-08, <c>409 STAGE_NOT_EDITABLE</c>).</summary>
public decimal PlannedQty { get; set; }
/// <summary>
/// Stock inputs only, in the item's <b>base</b> UOM. A start consumes
/// <c>max(0, PlannedQty ConsumedQty)</c> and adds to these, so a rework restart
/// with an unchanged planned quantity consumes nothing and one with a raised planned
/// quantity consumes only the delta (FR-MFG-16).
/// </summary>
public decimal ConsumedQty { get; set; }
public decimal ConsumedValue { get; set; }
/// <summary>Upstream inputs only: accumulated by parent transfers.</summary>
public decimal DeliveredQty { get; set; }
/// <summary>Leftover returns (FR-MFG-14) and cancel returns (FR-MFG-17), at the consumed weighted cost.</summary>
public decimal ReturnedQty { get; set; }
public decimal ReturnedValue { get; set; }
}
@@ -0,0 +1,43 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One output line of a run stage — a copy of a <see cref="StageOutput"/> with its
/// quantity scaled at creation, plus the live produced/scrapped/transferred figures.
/// Model: docs/30 Part C.
/// <para><b>Available to transfer is derived, never stored:</b>
/// <c>ProducedQty ScrappedQty TransferredQty</c>. Every transfer path checks it and
/// raises <c>422 TRANSFER_EXCEEDS_AVAILABLE</c> (FR-MFG-12).</para>
/// <para>Scrap cost is <b>absorbed</b> into the run cost pool as normal yield loss — no
/// write-off ledger entry is posted (FR-MFG-11).</para>
/// </summary>
public class RunStageOutput
{
public int RunOutputId { get; set; }
public int RunStageId { get; set; }
public RunStage? RunStage { get; set; }
/// <summary>Null on intermediate (WIP) outputs; set on the terminal output — the finished good.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
public Uom? Uom { get; set; }
/// <summary>Scaled at creation; per-run editable until the stage starts.</summary>
public decimal PlannedQty { get; set; }
/// <summary>Recorded at complete. A re-complete after a rework <b>overwrites</b> this, never adds to it.</summary>
public decimal ProducedQty { get; set; }
public decimal ScrappedQty { get; set; }
/// <summary>Mandatory when <see cref="ScrappedQty"/> &gt; 0, context <c>Production</c> (FR-MFG-11).</summary>
public int? ScrapReasonCodeId { get; set; }
public ReasonCode? ScrapReason { get; set; }
/// <summary>Total WIP handed to children so far, across approve and any later partial transfers.</summary>
public decimal TransferredQty { get; set; }
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>Allowance/ad hoc deduction master (FR-HR-PAY-01). Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
public class SalaryComponent
{
public int SalaryComponentId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public SalaryComponentType ComponentType { get; set; }
public bool IsTaxable { get; set; }
public bool IsEpfEtfApplicable { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,41 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class SalesInvoice
{
public int SalesInvoiceId { get; set; }
public string InvoiceNo { get; set; } = string.Empty;
public DateTime InvoiceDate { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string CustomerSnapshotName { get; set; } = string.Empty;
public string? CustomerSnapshotTaxNo { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public SalesInvoiceType InvoiceType { get; set; } = SalesInvoiceType.B2C;
public SalesInvoiceStatus Status { get; set; } = SalesInvoiceStatus.Draft;
public decimal Subtotal { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
public decimal RoundOff { get; set; }
public decimal NetPayable { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<SalesInvoiceLine> Lines { get; set; } = new List<SalesInvoiceLine>();
}
@@ -0,0 +1,38 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class SalesInvoiceLine
{
public int SalesInvoiceLineId { get; set; }
public int SalesInvoiceId { get; set; }
public SalesInvoice? SalesInvoice { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
public decimal DiscountPct { get; set; }
public decimal DiscountAmount { get; set; }
public decimal NetUnitPrice { get; set; }
public decimal LineTotal { get; set; }
public decimal TaxPct { get; set; }
public decimal TaxAmount { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,36 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class SalesSlip
{
public int SalesSlipId { get; set; }
public string SlipNo { get; set; } = string.Empty;
public DateTime SlipDate { get; set; }
public int CustomerId { get; set; }
public Customer? Customer { get; set; }
public string CustomerSnapshotName { get; set; } = string.Empty;
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public int CashierUserId { get; set; }
public User? CashierUser { get; set; }
public SalesSlipStatus Status { get; set; } = SalesSlipStatus.Draft;
public decimal Subtotal { get; set; }
public decimal DiscountTotal { get; set; }
public decimal TaxTotal { get; set; }
public decimal GrandTotal { get; set; }
public decimal PaidAmount { get; set; }
public decimal BalanceAmount { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
public ICollection<SalesSlipLine> Lines { get; set; } = new List<SalesSlipLine>();
}
@@ -0,0 +1,38 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
public class SalesSlipLine
{
public int SalesSlipLineId { get; set; }
public int SalesSlipId { get; set; }
public SalesSlip? SalesSlip { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Qty { get; set; }
public decimal FreeQty { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal UnitPrice { get; set; }
public decimal BaseCost { get; set; }
public string PriceSource { get; set; } = string.Empty;
public SalesDiscountMode DiscountMode { get; set; } = SalesDiscountMode.Percentage;
public decimal DiscountPct { get; set; }
public decimal DiscountAmount { get; set; }
public decimal NetUnitPrice { get; set; }
public decimal LineTotal { get; set; }
public decimal TaxPct { get; set; }
public decimal TaxAmount { get; set; }
public bool IsFreeIssue { get; set; }
public int? ParentLineId { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,21 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A parent → child arrow on the template canvas. The edge set must form a DAG with
/// at least one entry stage and exactly one terminal stage; that is enforced in
/// <c>ProductionGraphValidator</c> on every save, not by the database (FR-MFG-02).
/// Model: docs/30 Part C.
/// </summary>
public class StageEdge
{
public int EdgeId { get; set; }
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
public int ParentStageId { get; set; }
public TemplateStage? ParentStage { get; set; }
public int ChildStageId { get; set; }
public TemplateStage? ChildStage { get; set; }
}
@@ -0,0 +1,39 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// One line of a stage's input formula (FR-MFG-04). Exactly one of the two source
/// shapes applies, enforced by <c>ProductionGraphValidator</c>:
/// <list type="bullet">
/// <item><see cref="StageInputSource.Stock"/> — <see cref="ItemId"/> set,
/// <see cref="FromOutputId"/> null. FIFO-consumed from the run warehouse at stage
/// start. Allowed on <i>any</i> stage, e.g. packaging added late.</item>
/// <item><see cref="StageInputSource.Upstream"/> — <see cref="FromOutputId"/> set to
/// an output of a <b>direct parent</b> stage, <see cref="ItemId"/> null. Flows as
/// internal WIP and never touches stock or the ledger.</item>
/// </list>
/// Model: docs/30 Part C.
/// </summary>
public class StageInput
{
public int InputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
public StageInputSource Source { get; set; }
/// <summary>Required when <see cref="Source"/> is Stock; null when Upstream.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
/// <summary>Required when <see cref="Source"/> is Upstream; must belong to a direct parent.</summary>
public int? FromOutputId { get; set; }
public StageOutput? FromOutput { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public decimal QtyPerBatch { get; set; }
}
@@ -0,0 +1,27 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A named quantity produced by a stage (FR-MFG-05). Intermediate outputs are
/// <b>internal WIP only</b> — <see cref="ItemId"/> is null, no stock and no ledger row
/// is ever written for them. The terminal stage is the exception: it has exactly one
/// output and that output <b>must</b> reference a real Item (the finished good), which
/// is what the production receipt creates a layer for. Model: docs/30 Part C.
/// </summary>
public class StageOutput
{
public int OutputId { get; set; }
public int StageId { get; set; }
public TemplateStage? Stage { get; set; }
/// <summary>Null on intermediate stages; required on the terminal stage.</summary>
public int? ItemId { get; set; }
public Item? Item { get; set; }
public string Name { get; set; } = string.Empty;
public int UomId { get; set; }
public Uom? Uom { get; set; }
public decimal QtyPerBatch { get; set; }
}
@@ -0,0 +1,19 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Configurable APIT-style marginal tax slab (FR-HR-PAY-04) — government slabs
/// change with the yearly budget, so this is never hardcoded. <see cref="UpperBound"/>
/// null means "and above". Model: docs/12-BACKEND-HRM.md Part C.6.
/// </summary>
public class TaxSlab
{
public int TaxSlabId { get; set; }
public DateTime EffectiveFrom { get; set; }
public DateTime? EffectiveTo { get; set; }
public decimal LowerBound { get; set; }
public decimal? UpperBound { get; set; }
public decimal Rate { get; set; }
public DateTime CreatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,36 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// One box on the template canvas (FR-MFG-03): a named step with a role label, an
/// estimated duration, a formula (<see cref="Inputs"/> + <see cref="Outputs"/>) and
/// custom field definitions. Model: docs/30 Part C.
/// </summary>
public class TemplateStage
{
public int StageId { get; set; }
public int TemplateId { get; set; }
public ProductionTemplate? Template { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Free text (e.g. "QA"). Informational only this phase — never enforced (FR-X-01).</summary>
public string? RoleLabel { get; set; }
public int EstimatedMinutes { get; set; }
/// <summary>Canvas coordinates — stored verbatim, never interpreted server-side (FR-MFG-03).</summary>
public decimal PosX { get; set; }
public decimal PosY { get; set; }
/// <summary>
/// Custom field definitions as jsonb: <c>[{ key, label, type, options?, required }]</c>
/// (FR-MFG-07). Held as a pre-serialized string, matching the <c>AuditLog.ChangeSet</c>
/// precedent; always written through <c>ProductionJson</c> so the column can only ever
/// hold canonical JSON.
/// </summary>
public string FieldDefs { get; set; } = "[]";
public ICollection<StageInput> Inputs { get; set; } = new List<StageInput>();
public ICollection<StageOutput> Outputs { get; set; } = new List<StageOutput>();
}
+8
View File
@@ -23,6 +23,14 @@ public class User
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
public Guid? AuthUserId { get; set; }
/// <summary>
/// Mirrors the AuthHex identity's email (backfilled at create time by
/// <c>UsersController.Create</c>, best-effort by JIT provisioning otherwise).
/// Used only for the Employee&lt;-&gt;User cross-link soft match (docs/12-BACKEND-HRM.md
/// A.5) — never for authentication, which stays AuthHex's responsibility.
/// </summary>
public string? Email { get; set; }
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; }
public Role? Role { get; set; }
@@ -0,0 +1,32 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Attendance baseline (FR-HR-MD-01) — the shift definition Late/Early/OT figures
/// are computed against (docs/12-BACKEND-HRM.md A.3, C.1). <see cref="IsOvernight"/>
/// is explicit rather than inferred from End&lt;Start, since that comparison alone
/// is ambiguous for a shift that starts and ends the same clock time next day.
/// </summary>
public class WorkShift
{
public int WorkShiftId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public TimeSpan StartTime { get; set; }
public TimeSpan EndTime { get; set; }
public bool IsOvernight { get; set; }
public int GraceMinutes { get; set; } = 15;
public int BreakMinutes { get; set; } = 60;
public int StandardWorkingMinutes { get; set; } = 480;
public decimal OtMultiplier { get; set; } = 1.5m;
/// <summary>Bitmask, bit 0 = Monday .. bit 6 = Sunday.</summary>
public int WorkingDaysMask { get; set; } = 0b0111111;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public uint RowVersion { get; set; }
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Attendance upload batch lifecycle (FR-HR-ATT, docs/12-BACKEND-HRM.md C.4) —
/// exactly the flow specified by the business: once <see cref="Confirmed"/> it
/// becomes payroll's source of truth; once <see cref="UsedInPayroll"/> it is
/// immutable even to Unlock (a payroll run must be unlocked/regenerated first).
/// </summary>
public enum AttendanceBatchStatus
{
Draft,
Validated,
Confirmed,
UsedInPayroll
}
@@ -0,0 +1,13 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Origin of an attendance batch (docs/12-BACKEND-HRM.md C.4). <see cref="BiometricDevice"/>
/// is a reserved future integration seam (docs §B.7) — no device feed exists yet.
/// </summary>
public enum AttendanceSourceType
{
Excel,
Csv,
Manual,
BiometricDevice
}
@@ -0,0 +1,17 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Per-day attendance classification (docs/12-BACKEND-HRM.md C.4). Lateness/OT are
/// derived facts (LateMinutes/OvertimeMinutes &gt; 0) on an otherwise Present record,
/// not separate statuses. <see cref="OnLeave"/> is derived from an overlapping
/// Approved LeaveRequest with no uploaded punch (§6).
/// </summary>
public enum AttendanceStatus
{
Present,
Absent,
HalfDay,
OnLeave,
Holiday,
WeekOff
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Input type of a stage custom field (FR-MFG-07; docs/30 §D.5 <c>fieldType</c>).
/// Lives inside the <c>field_defs</c> jsonb rather than a column, but is modelled as an
/// enum so a bad value is rejected at the DTO boundary instead of reaching the database.
/// <see cref="Select"/> is the only type that reads <c>options</c>.
/// </summary>
public enum CustomFieldType
{
Text,
Number,
Checkbox,
Date,
Select
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum CustomerType
{
B2B = 1,
B2C = 2,
WalkIn = 3
}
@@ -0,0 +1,11 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Uploaded staff document status (docs/12-BACKEND-HRM.md C.3). Never hard-deleted —
/// archived instead, mirroring the deactivate-not-delete master convention (FR-MD-08).
/// </summary>
public enum EmployeeDocumentStatus
{
Active,
Archived
}
@@ -0,0 +1,16 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Employee lifecycle status (docs/12-BACKEND-HRM.md C.2). An Employee is never
/// hard-deleted; separation is recorded here instead (with <c>LastWorkingDate</c>
/// set), matching the deactivate-not-delete convention for masters (FR-MD-08)
/// taken one step further since the record must be retained for audit/payroll history.
/// </summary>
public enum EmployeeStatus
{
Active,
Suspended,
Resigned,
Terminated,
Retired
}
+9
View File
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
/// <summary>Employee gender (docs/12-BACKEND-HRM.md C.2). Optional field, stored as a string.</summary>
public enum Gender
{
Male,
Female,
Other
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>Staff document catalog category (docs/12-BACKEND-HRM.md C.3). Stored as a string.</summary>
public enum HrDocumentCategory
{
Identity,
Educational,
Contract,
Certification,
Statutory,
Other
}
@@ -0,0 +1,11 @@
namespace ERPCore.Domain.Enums;
/// <summary>Leave request approval lifecycle (FR-HR-LV-02, docs/12-BACKEND-HRM.md C.5).</summary>
public enum LeaveRequestStatus
{
Draft,
Submitted,
Approved,
Rejected,
Cancelled
}
@@ -0,0 +1,13 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// An installment flips Pending→Deducted only when its PayrollRun reaches Locked
/// (docs/12-BACKEND-HRM.md A.4) — never at Generate/Draft, so a discarded/regenerated
/// draft never prematurely consumes it.
/// </summary>
public enum LoanInstallmentStatus
{
Pending,
Deducted,
Skipped
}
+8
View File
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Loan vs Advance discriminator (docs/12-BACKEND-HRM.md C.6) — structurally identical, differ only in intent/labeling.</summary>
public enum LoanKind
{
Loan,
Advance
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum LoanStatus
{
Active,
Closed,
Cancelled
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// EmployerContribution lines (EPF-employer, ETF) are informational/liability only —
/// never subtracted from Net Salary (docs/12-BACKEND-HRM.md B.4).
/// </summary>
public enum PayrollLineComponentCategory
{
Earning,
Deduction,
EmployerContribution
}
@@ -0,0 +1,15 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// PayrollRun approval workflow (FR-HR-PAY-06, docs/12-BACKEND-HRM.md C.6). Maps the
/// business's 5-step flow to 3 stored states: Generate→Draft, Review is a human
/// action (not stored), Approve→Approved, Lock→Locked (the point loan installments
/// and attendance batches are stamped consumed — see A.4), Generate Payslips is an
/// action gated on Locked, not a state.
/// </summary>
public enum PayrollRunStatus
{
Draft,
Approved,
Locked
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Lifecycle of a production run (docs/30 §B.4, §D.5). A run is created
/// <see cref="InProgress"/> and reaches exactly one final state: it completes at the
/// terminal stage's approve (production receipt, FR-MFG-13) or is cancelled with a
/// stock return (FR-MFG-17). Stored as a string.
/// </summary>
public enum ProductionRunStatus
{
InProgress,
Completed,
Cancelled
}
@@ -0,0 +1,22 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Status of one stage within a production run (docs/30 §B.4, §D.5).
/// <code>
/// Waiting ──(all upstream inputs fully delivered)──▶ Ready
/// Ready ──start (FIFO-consume Stock inputs)──▶ InProgress
/// InProgress ──complete (produced/scrap/fields)──▶ Done
/// Done ──approve──▶ Approved (non-terminal: WIP transfers out; terminal: receipt)
/// </code>
/// Rejection is not a resting state (FR-MFG-15/16): a reject immediately produces a
/// rework transition back into this set and is recorded as a
/// <see cref="RunStageEventType"/> instead. Stored as a string.
/// </summary>
public enum ProductionStageStatus
{
Waiting,
Ready,
InProgress,
Done,
Approved
}
@@ -5,5 +5,8 @@ public enum ReasonContext
{
Adjustment,
Return,
Count
Count,
/// <summary>Manufacturing: scrap, leftover return, run cancel (docs/30 §A.2).</summary>
Production
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>Per-record outcome of the attendance upload validation pipeline (docs/12-BACKEND-HRM.md §B.3.4).</summary>
public enum RowValidationStatus
{
Valid,
DuplicateWithinBatch,
DuplicateConfirmed,
EmployeeNotFound,
InvalidDateTime,
Error
}
@@ -0,0 +1,20 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Kind of entry in a run's immutable history (docs/30 Part C, <c>RUN_STAGE_EVENT</c>).
/// Every mutating action on a run writes exactly one event carrying who/when plus a
/// jsonb payload; <see cref="TerminalReject"/> additionally snapshots the whole run's
/// figures for the rework pass being discarded (FR-MFG-16). Stored as a string.
/// </summary>
public enum RunStageEventType
{
Start,
Complete,
Approve,
Transfer,
RejectIntake,
TerminalReject,
LeftoverReturn,
Cancel,
QuantityEdit
}
@@ -0,0 +1,12 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// SalaryComponent master category (docs/12-BACKEND-HRM.md C.6) — for Allowances and
/// ad hoc Other Deductions only. OT/Late/No-Pay/Loan/EPF/ETF/Tax are system-computed,
/// not user-defined components, to avoid a generic formula engine nobody asked for.
/// </summary>
public enum SalaryComponentType
{
Earning,
Deduction
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
/// <summary>Effective-dated salary structure status (docs/12-BACKEND-HRM.md C.6) — exactly one Active (open-ended) row per employee at a time.</summary>
public enum SalaryStructureStatus
{
Active,
Superseded
}
@@ -0,0 +1,7 @@
namespace ERPCore.Domain.Enums;
public enum SalesDiscountMode
{
Percentage = 1,
Amount = 2
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum SalesInvoiceStatus
{
Draft = 1,
Posted = 2,
Cancelled = 3
}
@@ -0,0 +1,9 @@
namespace ERPCore.Domain.Enums;
public enum SalesInvoiceType
{
B2B = 1,
B2C = 2,
Cash = 3,
Credit = 4
}
@@ -0,0 +1,8 @@
namespace ERPCore.Domain.Enums;
public enum SalesSlipStatus
{
Draft = 1,
Posted = 2,
Cancelled = 3
}
@@ -0,0 +1,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Where a stage input's material comes from (FR-MFG-04; docs/30 §D.5).
/// <see cref="Stock"/> inputs reference an Item and are FIFO-consumed from the run
/// warehouse when the stage starts. <see cref="Upstream"/> inputs reference a direct
/// parent stage's output and flow as internal WIP — they never touch the ledger.
/// Stored as a string.
/// </summary>
public enum StageInputSource
{
Stock,
Upstream
}
@@ -0,0 +1,34 @@
namespace ERPCore.Domain;
/// <summary>
/// Additional <c>STOCK_LEDGER.source_doc_type</c> values for manufacturing movements
/// (docs/30 §A.2). <c>source_doc_id</c> is always the <c>run_id</c>, so
/// <c>LIKE 'PRD%'</c> traces every stock movement a run caused.
/// </summary>
/// <remarks>
/// <para><b>Deviation from docs/30 §A.2 (recorded).</b> The doc proposes the long names
/// <c>ProductionIssue</c>/<c>ProductionReceipt</c>/<c>ProductionReturn</c>/
/// <c>ProductionCancelReturn</c>, but both <c>stock_ledger.SourceDocType</c> and
/// <c>journal_entry_stubs.SourceDocType</c> are <c>varchar(10)</c> and every existing
/// value is a short prefix (<c>GRN</c>, <c>TRF</c>, <c>ADJ</c>, <c>PRET</c>). Widening
/// those columns would be a second Phase-1 schema change beyond the single deviation
/// §A.1 declares (NFR-08), so the short codes below extend the existing convention
/// instead. docs/30 §A.2 and §D.5 are amended to match.</para>
/// <para>These are <b>not</b> <see cref="DocumentTypes"/> entries — production issues no
/// document per movement. The run's own document number uses
/// <see cref="DocumentTypes.Production"/> (<c>PRD-2026-00001</c>).</para>
/// </remarks>
public static class LedgerSourceTypes
{
/// <summary>Stock consumed by a stage start (FR-MFG-10). Outbound.</summary>
public const string ProductionIssue = "PRDI";
/// <summary>Finished goods received at the terminal approve (FR-MFG-13). Inbound.</summary>
public const string ProductionReceipt = "PRDR";
/// <summary>Unconsumed material returned before receipt (FR-MFG-14). Inbound.</summary>
public const string ProductionReturn = "PRDL";
/// <summary>Net consumed stock returned when a run is cancelled (FR-MFG-17). Inbound.</summary>
public const string ProductionCancelReturn = "PRDC";
}
@@ -0,0 +1,66 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Customers;
/// <summary>Customer resource used by sales documents.</summary>
public sealed record CustomerDto(
int CustomerId,
string CustomerCode,
CustomerType CustomerType,
string Name,
string? DisplayName,
string? Phone,
string? Email,
string? AddressLine1,
string? AddressLine2,
string? City,
string? Country,
string? TaxRegistrationNo,
decimal CreditLimit,
int CreditDays,
int? DefaultWarehouseId,
EntityStatus Status,
DateTime CreatedAt,
DateTime? UpdatedAt);
public sealed class CreateCustomerRequest
{
[Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
[Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(200)] public string? DisplayName { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(100)] public string? Email { get; set; }
[StringLength(250)] public string? AddressLine1 { get; set; }
[StringLength(250)] public string? AddressLine2 { get; set; }
[StringLength(100)] public string? City { get; set; }
[StringLength(100)] public string? Country { get; set; }
[StringLength(50)] public string? TaxRegistrationNo { get; set; }
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
}
public sealed class UpdateCustomerRequest
{
[Required, StringLength(50)] public string CustomerCode { get; set; } = string.Empty;
[Required, EnumDataType(typeof(CustomerType))] public CustomerType CustomerType { get; set; } = CustomerType.B2C;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(200)] public string? DisplayName { get; set; }
[StringLength(30)] public string? Phone { get; set; }
[StringLength(100)] public string? Email { get; set; }
[StringLength(250)] public string? AddressLine1 { get; set; }
[StringLength(250)] public string? AddressLine2 { get; set; }
[StringLength(100)] public string? City { get; set; }
[StringLength(100)] public string? Country { get; set; }
[StringLength(50)] public string? TaxRegistrationNo { get; set; }
[Range(0, double.MaxValue)] public decimal CreditLimit { get; set; }
[Range(0, int.MaxValue)] public int CreditDays { get; set; }
public int? DefaultWarehouseId { get; set; }
}
public sealed class UpdateCustomerStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}

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