Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4993b202d | |||
| 8555702a76 | |||
| fd95e92eb1 | |||
| 6e008773db | |||
| 179d5b0803 | |||
| 342012a321 | |||
| ee6ac913f1 | |||
| 2661169351 | |||
| 80213cf47d | |||
| 18475fdc9f | |||
| 9a32c5c609 | |||
| 32f40e9d1a | |||
| b2a218e2f8 | |||
| d16a227b54 | |||
| a7ba3d3e04 | |||
| 0ae80395cf | |||
| 15ddac178c | |||
| d37824cecc | |||
| 271c940640 | |||
| f2825900aa | |||
| 6520930aeb | |||
| a8c6b4cb5e | |||
| 8e9974b735 | |||
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec | |||
| f140959b43 | |||
| c31e23c2b9 | |||
| 7219480ca0 | |||
| 8e24ed6375 | |||
| 9f22026784 | |||
| ef105302bd | |||
| d7ee83828c | |||
| eb7b2691df | |||
| 1a0fb4603e | |||
| 5fc5ef59ac | |||
| d1fe164ea2 | |||
| 02f47bd485 | |||
| 45554ceb9a | |||
| 5d0ea3f035 | |||
| 26cf2a146a | |||
| 0750773f94 | |||
| 38c7545413 | |||
| 0d60aeef64 | |||
| c6bc8065a2 | |||
| 4f56d481a2 | |||
| d79371697e | |||
| f7a65b5f7e | |||
| 6b216195c6 | |||
| 266a2a2c14 | |||
| 3c5b476635 | |||
| 59af50bf11 | |||
| 0b9d64f911 | |||
| 6258ebd8de | |||
| 37c8da2ced | |||
| b7f9f599eb | |||
| f43a8a8486 | |||
| a271d1832e | |||
| 6ebdbb655a | |||
| 22657f0910 | |||
| 74d3e684d2 | |||
| 2dab7051b3 | |||
| 4f722432cd | |||
| 1f9e12e84b | |||
| 8b8e79e0fe | |||
| ffbd47f6f9 | |||
| a414dfc4ea | |||
| 3cccaf4c63 | |||
| 76484c7268 | |||
| 96f81cb03a | |||
| b7bd8dca5c | |||
| cb3f1e5cde | |||
| c3fef88bcf | |||
| 7d6e597389 | |||
| 415ac94ab2 | |||
| 4561ef7ba8 | |||
| b12adebaa0 | |||
| 0b95d6f1cd | |||
| d35b076435 | |||
| c564916c60 | |||
| 86bb4d4908 | |||
| 816ffbbfb6 | |||
| ae20bc4e34 | |||
| 755df494fe | |||
| eacc21afad | |||
| 7366ca93c0 | |||
| 5af77fbf0a | |||
| 8484601494 |
+7
-6
@@ -30,9 +30,10 @@ yarn-error.log*
|
||||
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/
|
||||
# ── Playwright E2E (Testing/e2e) ──────────────────────────────────────
|
||||
Testing/e2e/playwright-report/
|
||||
Testing/e2e/test-results/
|
||||
Testing/e2e/.auth/
|
||||
Testing/e2e/blob-report/
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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/bundle-sales")]
|
||||
public sealed class BundleSalesController : ApiControllerBase
|
||||
{
|
||||
private readonly IBundleSaleService _bundles;
|
||||
|
||||
public BundleSalesController(IBundleSaleService bundles) => _bundles = bundles;
|
||||
|
||||
[HttpGet("templates")]
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleTemplateSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleTemplateSummaryDto>>> ListTemplates(
|
||||
[FromQuery] PageQuery query,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListTemplatesAsync(query, ct));
|
||||
|
||||
[HttpGet("templates/{bundleSaleTemplateId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleTemplateDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleSaleTemplateDto>> GetTemplate(int bundleSaleTemplateId, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.GetTemplateAsync(bundleSaleTemplateId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query,
|
||||
[FromQuery] BundleSaleStatus? status,
|
||||
[FromQuery] int? customerId,
|
||||
[FromQuery] int? warehouseId,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||
|
||||
[HttpGet("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleSaleDto>> GetById(int bundleSaleId, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.GetAsync(bundleSaleId, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{bundleSaleId:int}/posting-check")]
|
||||
[ProducesResponseType(typeof(BundleSalePostingCheckDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSalePostingCheckDto>> PostingCheck(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.CheckPostingAsync(bundleSaleId, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Create([FromBody] CreateBundleSaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await _bundles.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/bundle-sales/{result.BundleSaleId}", result);
|
||||
}
|
||||
|
||||
[HttpPut("{bundleSaleId:int}")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Update(int bundleSaleId, [FromBody] UpdateBundleSaleRequest request, CancellationToken ct)
|
||||
{
|
||||
return Ok(await _bundles.UpdateAsync(bundleSaleId, request, ct));
|
||||
}
|
||||
|
||||
[HttpPost("{bundleSaleId:int}/post")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Post(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.PostAsync(bundleSaleId, ct));
|
||||
|
||||
[HttpPost("{bundleSaleId:int}/cancel")]
|
||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleSaleDto>> Cancel(int bundleSaleId, CancellationToken ct)
|
||||
=> Ok(await _bundles.CancelAsync(bundleSaleId, ct));
|
||||
}
|
||||
@@ -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<->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();
|
||||
}
|
||||
}
|
||||
@@ -81,11 +81,4 @@ public sealed class ItemsController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(int itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateReorderAsync(itemId, request, ct));
|
||||
|
||||
/// <summary>Replace the item's UOM conversions (FR-MD-02).</summary>
|
||||
[HttpPut("{itemId:int}/uom-conversions")]
|
||||
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
|
||||
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
|
||||
}
|
||||
|
||||
@@ -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.2–D.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,49 @@
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Sales;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ERPCore.Controllers;
|
||||
|
||||
/// <summary>Sales-return endpoints — customer returns of previously sold goods.</summary>
|
||||
[Route("api/v1/sales-returns")]
|
||||
public sealed class SalesReturnsController : ApiControllerBase
|
||||
{
|
||||
private readonly ISalesReturnService _returns;
|
||||
|
||||
public SalesReturnsController(ISalesReturnService returns) => _returns = returns;
|
||||
|
||||
/// <summary>List posted returns, newest first.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<SalesReturnSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<SalesReturnSummaryDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? customerId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||
=> Ok(await _returns.ListAsync(query, customerId, warehouseId, ct));
|
||||
|
||||
/// <summary>Remaining returnable qty per line of one sales invoice (invoiced qty minus already-returned).</summary>
|
||||
[HttpGet("remaining")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<SalesInvoiceLineRemainingDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SalesInvoiceLineRemainingDto>>> GetRemaining([FromQuery] int salesInvoiceId, CancellationToken ct)
|
||||
=> Ok(await _returns.GetRemainingByInvoiceAsync(salesInvoiceId, ct));
|
||||
|
||||
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
|
||||
[HttpGet("{returnId:int}")]
|
||||
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SalesReturnDto>> GetById(int returnId, CancellationToken ct)
|
||||
{
|
||||
var dto = await _returns.GetAsync(returnId, ct);
|
||||
return dto is null ? NotFound() : Ok(dto);
|
||||
}
|
||||
|
||||
/// <summary>Create + auto-post a return (inbound movement).</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(SalesReturnDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<SalesReturnDto>> Create([FromBody] CreateSalesReturnRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _returns.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/sales-returns/{dto.ReturnId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -14,4 +14,11 @@ 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";
|
||||
public const string BundleSale = "BND";
|
||||
public const string SalesReturn = "SRET";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,33 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class BundleSale
|
||||
{
|
||||
public int BundleSaleId { get; set; }
|
||||
public string BundleNo { get; set; } = string.Empty;
|
||||
public DateTime BundleDate { 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 int BundleSaleTemplateId { get; set; }
|
||||
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
|
||||
public string BundleName { get; set; } = string.Empty;
|
||||
public string BundleCode { get; set; } = string.Empty;
|
||||
public BundleSaleStatus Status { get; set; } = BundleSaleStatus.Draft;
|
||||
public decimal ComponentSubtotal { get; set; }
|
||||
public decimal BundlePrice { get; set; }
|
||||
public decimal MarginAmount { get; set; }
|
||||
public decimal DiscountTotal { get; set; }
|
||||
public decimal TaxTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public int ConcurrencyStamp { get; set; }
|
||||
|
||||
public ICollection<BundleSaleLine> Lines { get; set; } = new List<BundleSaleLine>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class BundleSaleLine
|
||||
{
|
||||
public int BundleSaleLineId { get; set; }
|
||||
public int BundleSaleId { get; set; }
|
||||
public BundleSale? BundleSale { 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 int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
public bool IncludeInBundle { get; set; } = true;
|
||||
public bool IsComponent { get; set; } = true;
|
||||
public int? ParentLineId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class BundleSaleTemplate
|
||||
{
|
||||
public int BundleSaleTemplateId { get; set; }
|
||||
public string TemplateCode { get; set; } = string.Empty;
|
||||
public string TemplateName { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public int ConcurrencyStamp { get; set; }
|
||||
|
||||
public ICollection<BundleSaleTemplateLine> Lines { get; set; } = new List<BundleSaleTemplateLine>();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
public class BundleSaleTemplateLine
|
||||
{
|
||||
public int BundleSaleTemplateLineId { get; set; }
|
||||
public int BundleSaleTemplateId { get; set; }
|
||||
public BundleSaleTemplate? BundleSaleTemplate { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public bool IncludeInBundle { get; set; } = true;
|
||||
public int SortOrder { 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<->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; }
|
||||
}
|
||||
@@ -25,9 +25,6 @@ public class GrnLine
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public int? BinId { get; set; }
|
||||
public Bin? Bin { get; set; }
|
||||
|
||||
@@ -61,4 +58,11 @@ public class GrnLine
|
||||
public decimal LineTotal { get; set; }
|
||||
|
||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
|
||||
/// <summary>
|
||||
/// One row per received unit when <see cref="Item.Warranty"/> is
|
||||
/// <see cref="Enums.Warranty.Warranty"/> — count must equal <see cref="Qty"/>.
|
||||
/// Empty for a non-warranty item.
|
||||
/// </summary>
|
||||
public ICollection<GrnLineWarrantyNumber> WarrantyNumbers { get; set; } = new List<GrnLineWarrantyNumber>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One warranty number captured against a single received unit of a warranty-tracked
|
||||
/// item (<see cref="Item.Warranty"/> = <see cref="Enums.Warranty.Warranty"/>). A GRN line
|
||||
/// for such an item must carry exactly <see cref="GrnLine.Qty"/> of these — one per unit —
|
||||
/// mirroring how a Serial-tracked item requires one serial per unit (docs/10 Part C.3).
|
||||
/// </summary>
|
||||
public class GrnLineWarrantyNumber
|
||||
{
|
||||
public int GrnLineWarrantyNumberId { get; set; }
|
||||
|
||||
public int GrnLineId { get; set; }
|
||||
public GrnLine? GrnLine { get; set; }
|
||||
|
||||
public string WarrantyNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Warranty coverage length in months, selected at receipt (e.g. 3/6/12/18).</summary>
|
||||
public int WarrantyPeriodMonths { 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; }
|
||||
}
|
||||
@@ -24,6 +24,12 @@ public class Item
|
||||
public int? BrandId { get; set; }
|
||||
public Brand? Brand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The stocking unit — the pack the item is counted in (BOTTLE, PACKET, BOX, PCS).
|
||||
/// <b>Every</b> quantity in the system is a count of these: stock layers, the ledger,
|
||||
/// and every document line. Nothing converts, so this is the sole meaning of a
|
||||
/// quantity and cannot be changed once the item has stock history.
|
||||
/// </summary>
|
||||
public int BaseUomId { get; set; }
|
||||
public Uom? BaseUom { get; set; }
|
||||
|
||||
@@ -32,6 +38,9 @@ public class Item
|
||||
|
||||
public StockNature StockNature { get; set; }
|
||||
public TrackingMode TrackingMode { get; set; }
|
||||
public Warranty Warranty { get; set; } = Warranty.NonWarranty;
|
||||
/// <summary>Coverage length in months (see <see cref="Enums.WarrantyPeriods.AllowedMonths"/>) when <see cref="Warranty"/> is Warranty; null otherwise.</summary>
|
||||
public int? WarrantyPeriodMonths { get; set; }
|
||||
public string? TaxClass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -41,6 +50,34 @@ public class Item
|
||||
/// </summary>
|
||||
public decimal? SalePrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How much one pack holds, as the user entered it — <c>500</c> with
|
||||
/// <see cref="ContentUnit"/> <c>Ml</c> for a 500 ml bottle, <c>1.5</c> with <c>L</c>
|
||||
/// for a 1.5 L one. Null (together with the other three) when the item has no
|
||||
/// measurable content: a screw, a label, a service.
|
||||
/// <para>
|
||||
/// Content never affects stock — that is always a pack count. It exists so production
|
||||
/// can express a formula in millilitres or grams and resolve it to packs
|
||||
/// (see <c>IItemMeasure</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A loose bulk item bought by weight is modelled the same way:
|
||||
/// <c>BaseUom = KG, ContentQty = 1, ContentUnit = Kg</c> ⇒ 1000 g per stocked unit.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public decimal? ContentQty { get; set; }
|
||||
public MeasureUnit? ContentUnit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ContentQty"/>/<see cref="ContentUnit"/> normalised to a base unit
|
||||
/// (L→Ml, Kg→G, both ×1000) at write time by <c>ItemContent.Normalize</c>. Server-derived
|
||||
/// and never accepted from a client. <see cref="ContentBaseUnit"/> is therefore only ever
|
||||
/// <see cref="MeasureUnit.Ml"/> or <see cref="MeasureUnit.G"/>.
|
||||
/// <para>Stored rather than recomputed so every consumer reads one settled number.</para>
|
||||
/// </summary>
|
||||
public decimal? ContentBaseQty { get; set; }
|
||||
public MeasureUnit? ContentBaseUnit { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
@@ -50,5 +87,4 @@ public class Item
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
|
||||
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ namespace ERPCore.Domain.Entities;
|
||||
/// Material.
|
||||
/// <para>
|
||||
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
|
||||
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
|
||||
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
|
||||
/// nothing: there is no value table and no join to <see cref="Item"/>. The chosen
|
||||
/// values (Red, S, M) are encoded by the client into the generated SKU
|
||||
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
|
||||
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It does, however, carry one piece of meaning the client acts on:
|
||||
/// <see cref="IsMeasurable"/>. So this is no longer purely a dropdown source.
|
||||
/// </para>
|
||||
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
|
||||
/// which is what the old <c>ItemType</c> enum became.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
@@ -21,6 +24,24 @@ public class ItemType
|
||||
{
|
||||
public int ItemTypeId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When true, this dimension's values are content <b>measurements</b> (500 ml, 1 L) rather
|
||||
/// than plain labels (Red, S). The item builder then captures a number + unit per value and
|
||||
/// stamps that pair onto each generated item's <see cref="Item.ContentQty"/> /
|
||||
/// <see cref="Item.ContentUnit"/>, instead of copying one form-level pair into every variant
|
||||
/// — which is what makes "Coca-Cola in 500 ml / 1 L / 250 ml" three correctly sized items.
|
||||
/// <para>
|
||||
/// This is what lets an apparel <c>Size</c> (S/M/L) stay plain text while a
|
||||
/// <c>Pack Size</c>/<c>Volume</c> dimension carries ml/g/L/kg.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A client hint only: the server never reads it when writing an item. Each item's pair is
|
||||
/// still validated and normalised on its own by <c>ItemContent</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsMeasurable { get; set; }
|
||||
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per employee/type/year leave entitlement (FR-HR-LV-03). Unique on
|
||||
/// (EmployeeId, LeaveTypeId, Year). RemainingDays is a computed projection, not
|
||||
/// stored. Model: docs/12-BACKEND-HRM.md Part C.5.
|
||||
/// </summary>
|
||||
public class LeaveBalance
|
||||
{
|
||||
public int LeaveBalanceId { get; set; }
|
||||
public int EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
public int LeaveTypeId { get; set; }
|
||||
public LeaveType? LeaveType { get; set; }
|
||||
public int Year { get; set; }
|
||||
|
||||
public decimal EntitledDays { get; set; }
|
||||
public decimal TakenDays { get; set; }
|
||||
public decimal CarriedForwardDays { get; set; }
|
||||
public decimal AdjustmentDays { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Leave request (FR-HR-LV-02) — transactional document, numbered via
|
||||
/// <see cref="NumberSequence"/> (docType "LV"). Model: docs/12-BACKEND-HRM.md Part C.5.
|
||||
/// </summary>
|
||||
public class LeaveRequest
|
||||
{
|
||||
public int LeaveRequestId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
public int EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
public int LeaveTypeId { get; set; }
|
||||
public LeaveType? LeaveType { get; set; }
|
||||
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime EndDate { get; set; }
|
||||
public decimal DaysCount { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public LeaveRequestStatus Status { get; set; } = LeaveRequestStatus.Draft;
|
||||
public int? ApprovedBy { get; set; }
|
||||
public DateTime? ApprovedAt { get; set; }
|
||||
public string? RejectionReason { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>Leave type master (FR-HR-LV-01). Model: docs/12-BACKEND-HRM.md Part C.5.</summary>
|
||||
public class LeaveType
|
||||
{
|
||||
public int LeaveTypeId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool IsPaid { get; set; } = true;
|
||||
/// <summary>Feeds Payroll's No-Pay deduction when true (docs/12-BACKEND-HRM.md §6).</summary>
|
||||
public bool CountsAsNoPay { get; set; }
|
||||
public decimal AccrualPerYear { get; set; }
|
||||
public bool CarryForwardAllowed { get; set; }
|
||||
public int? MaxCarryForwardDays { get; set; }
|
||||
public bool RequiresApproval { get; set; } = true;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Loan installment ledger row. <see cref="PayrollRunId"/> is stamped only when the
|
||||
/// consuming <see cref="PayrollRun"/> reaches Locked (docs/12-BACKEND-HRM.md A.4).
|
||||
/// Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||
/// </summary>
|
||||
public class LoanInstallment
|
||||
{
|
||||
public int LoanInstallmentId { get; set; }
|
||||
public int EmployeeLoanId { get; set; }
|
||||
public EmployeeLoan? EmployeeLoan { get; set; }
|
||||
|
||||
public int InstallmentNumber { get; set; }
|
||||
public int DueYear { get; set; }
|
||||
public int DueMonth { get; set; }
|
||||
public decimal ScheduledAmount { get; set; }
|
||||
public decimal? PaidAmount { get; set; }
|
||||
public int? PayrollRunId { get; set; }
|
||||
public PayrollRun? PayrollRun { get; set; }
|
||||
public LoanInstallmentStatus Status { get; set; } = LoanInstallmentStatus.Pending;
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-employee payroll summary row (FR-HR-PAY-05). EpfEmployerAmount/EtfEmployerAmount
|
||||
/// are informational/liability only, never subtracted from NetSalary
|
||||
/// (docs/12-BACKEND-HRM.md B.4). Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||
/// </summary>
|
||||
public class PayrollLine
|
||||
{
|
||||
public int PayrollLineId { get; set; }
|
||||
public int PayrollRunId { get; set; }
|
||||
public PayrollRun? PayrollRun { get; set; }
|
||||
public int EmployeeId { get; set; }
|
||||
public Employee? Employee { get; set; }
|
||||
|
||||
public decimal BasicSalary { get; set; }
|
||||
public decimal TotalAllowances { get; set; }
|
||||
public decimal OvertimeAmount { get; set; }
|
||||
public decimal GrossSalary { get; set; }
|
||||
|
||||
public decimal LateDeductionAmount { get; set; }
|
||||
public decimal NoPayAmount { get; set; }
|
||||
public decimal LoanDeductionAmount { get; set; }
|
||||
public decimal EpfEmployeeAmount { get; set; }
|
||||
public decimal EpfEmployerAmount { get; set; }
|
||||
public decimal EtfEmployerAmount { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
public decimal OtherDeductionsAmount { get; set; }
|
||||
public decimal NetSalary { get; set; }
|
||||
|
||||
public int WorkingDays { get; set; }
|
||||
public int PresentDays { get; set; }
|
||||
public int AbsentDays { get; set; }
|
||||
public int LeaveDays { get; set; }
|
||||
public int OtMinutesTotal { get; set; }
|
||||
public int LateMinutesTotal { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public List<PayrollLineComponent> Components { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>The detailed Basic/Transport/Meal/OT/Late/No-Pay/Loan/EPF/ETF/Tax breakdown. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
|
||||
public class PayrollLineComponent
|
||||
{
|
||||
public int PayrollLineComponentId { get; set; }
|
||||
public int PayrollLineId { get; set; }
|
||||
public PayrollLine? PayrollLine { get; set; }
|
||||
public PayrollLineComponentCategory ComponentCategory { get; set; }
|
||||
/// <summary>Set only for structure-sourced Allowance/OtherDeduction lines; null for system-computed lines.</summary>
|
||||
public int? SalaryComponentId { get; set; }
|
||||
public SalaryComponent? SalaryComponent { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Payroll run (FR-HR-PAY-05/06) — the transactional document. Numbered via
|
||||
/// <see cref="NumberSequence"/> (docType "PAY"). Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||
/// </summary>
|
||||
public class PayrollRun
|
||||
{
|
||||
public int PayrollRunId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
public int PeriodYear { get; set; }
|
||||
public int PeriodMonth { get; set; }
|
||||
/// <summary>Null = company-wide run.</summary>
|
||||
public int? BranchId { get; set; }
|
||||
public Branch? Branch { get; set; }
|
||||
public PayrollRunStatus Status { get; set; } = PayrollRunStatus.Draft;
|
||||
|
||||
public int GeneratedBy { get; set; }
|
||||
public DateTime GeneratedAt { get; set; }
|
||||
public int? ApprovedBy { get; set; }
|
||||
public DateTime? ApprovedAt { get; set; }
|
||||
public int? LockedBy { get; set; }
|
||||
public DateTime? LockedAt { get; set; }
|
||||
public int? UnlockedBy { get; set; }
|
||||
public DateTime? UnlockedAt { get; set; }
|
||||
public string? UnlockReason { get; set; }
|
||||
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public List<PayrollLine> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Effective-dated EPF/ETF rates (FR-HR-PAY-04) — Sri Lanka defaults (EPF 8%
|
||||
/// employee / 12% employer, ETF 3% employer-only), configurable since government
|
||||
/// rates can change. Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||
/// </summary>
|
||||
public class PayrollStatutorySetting
|
||||
{
|
||||
public int PayrollStatutorySettingId { get; set; }
|
||||
public decimal EpfEmployeeRate { get; set; } = 0.08m;
|
||||
public decimal EpfEmployerRate { get; set; } = 0.12m;
|
||||
public decimal EtfEmployerRate { get; set; } = 0.03m;
|
||||
public decimal OtMultiplierDefault { get; set; } = 1.5m;
|
||||
public DateTime EffectiveFrom { get; set; }
|
||||
public DateTime? EffectiveTo { get; set; }
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Thin generation/release marker over a <see cref="PayrollLine"/> — ships as an
|
||||
/// HTML print view in this phase, per the confirmed decision (no PDF dependency).
|
||||
/// Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||
/// </summary>
|
||||
public class Payslip
|
||||
{
|
||||
public int PayslipId { get; set; }
|
||||
public int PayrollLineId { get; set; }
|
||||
public PayrollLine? PayrollLine { get; set; }
|
||||
public DateTime GeneratedAt { get; set; }
|
||||
public DateTime? ReleasedAt { get; set; }
|
||||
public int? ReleasedBy { get; set; }
|
||||
}
|
||||
@@ -15,9 +15,6 @@ public class PoLine
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { 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,59 @@
|
||||
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 >= 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; }
|
||||
|
||||
/// <summary>Copied from the template input: what <see cref="PlannedQty"/> is expressed in.</summary>
|
||||
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
|
||||
|
||||
/// <summary>
|
||||
/// Scaled at creation; per-run editable until the stage starts (FR-MFG-08,
|
||||
/// <c>409 STAGE_NOT_EDITABLE</c>). Expressed in <see cref="QtyUnit"/> — so unlike the
|
||||
/// consumption figures below it is <b>not</b> necessarily a pack count.
|
||||
/// </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,50 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Display label for intermediate WIP; null on the terminal output, whose unit is the
|
||||
/// finished item's base UOM. Never converted — see <see cref="StageOutput.UomId"/>.
|
||||
/// </summary>
|
||||
public int? UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Scaled at creation; per-run editable until the stage starts. Every quantity on an
|
||||
/// output is a pack count, so scrap is recorded in whole broken bottles rather than ml.
|
||||
/// </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"/> > 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,36 @@
|
||||
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 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,32 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sales return header — a customer returns previously sold goods, generating an
|
||||
/// inbound stock movement. Auto-posts with a mandatory reason code, mirroring
|
||||
/// <see cref="PurchaseReturn"/> with the direction reversed.
|
||||
/// </summary>
|
||||
public class SalesReturn
|
||||
{
|
||||
public int ReturnId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int CustomerId { get; set; }
|
||||
public Customer? Customer { get; set; }
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public int ReasonCodeId { get; set; }
|
||||
public ReasonCode? ReasonCode { get; set; }
|
||||
|
||||
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public ICollection<SalesReturnLine> Lines { get; set; } = new List<SalesReturnLine>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Sales-return line referencing the original sales invoice line for traceability.
|
||||
/// <see cref="Qty"/> is in base UOM.
|
||||
/// </summary>
|
||||
public class SalesReturnLine
|
||||
{
|
||||
public int ReturnLineId { get; set; }
|
||||
|
||||
public int ReturnId { get; set; }
|
||||
public SalesReturn? Return { get; set; }
|
||||
|
||||
public int? SalesInvoiceLineId { get; set; }
|
||||
public SalesInvoiceLine? SalesInvoiceLine { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public decimal Qty { 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,36 @@
|
||||
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 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,44 @@
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="QtyPerBatch"/> is expressed in. Stock inputs may use
|
||||
/// <see cref="StageQtyUnit.Content"/> (ml/g) when the item has a content size; Upstream
|
||||
/// inputs are always <see cref="StageQtyUnit.Pack"/> — WIP is counted in the unit its
|
||||
/// source output declares.
|
||||
/// </summary>
|
||||
public StageQtyUnit QtyUnit { get; set; } = StageQtyUnit.Pack;
|
||||
|
||||
public decimal QtyPerBatch { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Display label for intermediate work-in-progress. Required when <see cref="ItemId"/>
|
||||
/// is null and must be null when it is set — a real item's unit is its own base UOM.
|
||||
/// WIP never touches stock or the ledger, so this is never converted, only shown.
|
||||
/// </summary>
|
||||
public int? UomId { get; set; }
|
||||
public Uom? Uom { get; set; }
|
||||
|
||||
/// <summary>Always a pack count: of the WIP unit above, or of the item's base UOM.</summary>
|
||||
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>();
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
|
||||
/// endpoints of a <see cref="UomConversion"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// Unit of Measure (FR-MD-02). A flat lookup, used as an item's base UOM — the pack every
|
||||
/// quantity in the system counts — and as the display label on an intermediate production
|
||||
/// output. There are no conversions between UOMs: an item is stocked in exactly one, and a
|
||||
/// differently sized pack is a different item. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Uom
|
||||
{
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
|
||||
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class UomConversion
|
||||
{
|
||||
public int ConversionId { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int FromUomId { get; set; }
|
||||
public Uom? FromUom { get; set; }
|
||||
|
||||
public int ToUomId { get; set; }
|
||||
public Uom? ToUom { get; set; }
|
||||
|
||||
public decimal Factor { get; set; }
|
||||
}
|
||||
@@ -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<->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<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 > 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,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
public enum BundleSaleStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Posted = 1,
|
||||
Cancelled = 2
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user