Compare commits
57 Commits
login-api
...
96f81cb03a
| Author | SHA1 | Date | |
|---|---|---|---|
| 96f81cb03a | |||
| cb3f1e5cde | |||
| c3fef88bcf | |||
| 7d6e597389 | |||
| 415ac94ab2 | |||
| 4561ef7ba8 | |||
| b12adebaa0 | |||
| 0b95d6f1cd | |||
| d35b076435 | |||
| c564916c60 | |||
| 86bb4d4908 | |||
| 816ffbbfb6 | |||
| ae20bc4e34 | |||
| 755df494fe | |||
| eacc21afad | |||
| 7366ca93c0 | |||
| 5af77fbf0a | |||
| a1b3985469 | |||
| 5cf9588728 | |||
| 8484601494 | |||
| 9260b4de9b | |||
| 46e971ef25 | |||
| f8b9ee8f6c | |||
| 4108062416 | |||
| 03bc85b788 | |||
| 9158cd8c82 | |||
| 951961b798 | |||
| f02c89b3cb | |||
| 295ec5799f | |||
| fe9e8a780f | |||
| 92c4b14a6c | |||
| 80b130dffb | |||
| 62a5d857de | |||
| f72b24fcaa | |||
| 7c5faabc2d | |||
| 582782b0fe | |||
| 250cf89abb | |||
| c9a84e235b | |||
| baaf51ba99 | |||
| 5d18d5d576 | |||
| ed2ee87c68 | |||
| 6c7f53350f | |||
| 0e4bcf174b | |||
| cb9fd7dfa8 | |||
| 4b2914cd5d | |||
| ae7627fcf2 | |||
| 67150425e4 | |||
| 0415794473 | |||
| 9e1aa57987 | |||
| 22f86451e3 | |||
| 7ac30bb454 | |||
| 4e84a15db7 | |||
| 783696fa97 | |||
| 0aa05f10f2 | |||
| 08a4c28868 | |||
| badb26a81f | |||
| 057dd5aedc |
@@ -29,3 +29,10 @@ yarn-error.log*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.idea/
|
.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/
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace ERPCore.Common.Http;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes the PostgreSQL xmin concurrency token (a <see cref="uint"/>) as an
|
||||||
|
/// opaque, quoted HTTP ETag and parses <c>If-Match</c> values back. Round-trips
|
||||||
|
/// via base64 so the value is stable and content-type agnostic
|
||||||
|
/// (docs/11-BACKEND-PHASE1.md §1.6).
|
||||||
|
/// </summary>
|
||||||
|
public static class ETag
|
||||||
|
{
|
||||||
|
/// <summary>Quoted ETag string for a row-version token, e.g. <c>"0RsAAA=="</c>.</summary>
|
||||||
|
public static string From(uint rowVersion)
|
||||||
|
=> "\"" + Convert.ToBase64String(BitConverter.GetBytes(rowVersion)) + "\"";
|
||||||
|
|
||||||
|
/// <summary>Parse an <c>If-Match</c> header value (quoted, optionally weak) to a token.</summary>
|
||||||
|
public static bool TryParse(string? ifMatch, out uint rowVersion)
|
||||||
|
{
|
||||||
|
rowVersion = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(ifMatch)) return false;
|
||||||
|
|
||||||
|
var v = ifMatch.Trim();
|
||||||
|
if (v.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) v = v[2..].Trim();
|
||||||
|
v = v.Trim('"');
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var bytes = Convert.FromBase64String(v);
|
||||||
|
if (bytes.Length != sizeof(uint)) return false;
|
||||||
|
rowVersion = BitConverter.ToUInt32(bytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ERPCore.Common.Http;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pairs a response DTO with the aggregate's current row-version so the controller
|
||||||
|
/// can emit an <c>ETag</c> header without the token leaking into the JSON body.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ETagged<T>(T Value, uint RowVersion);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ERPCore.Common.Http;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base for the v1 API controllers. Centralises ETag / If-Match handling
|
||||||
|
/// (docs/11-BACKEND-PHASE1.md §1.6) so concurrency behaviour is uniform.
|
||||||
|
/// Each controller declares its own explicit lowercase <c>[Route]</c> to match
|
||||||
|
/// the API contract paths (docs/11 §1.1). Every v1 endpoint requires a valid
|
||||||
|
/// AuthHex token satisfying the ERP door policy (docs/10 A.4).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
|
||||||
|
public abstract class ApiControllerBase : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Parse a mandatory <c>If-Match</c> header, or 428 if absent/malformed.</summary>
|
||||||
|
protected uint RequireIfMatch()
|
||||||
|
{
|
||||||
|
var header = Request.Headers.IfMatch.ToString();
|
||||||
|
if (!ETag.TryParse(header, out var rowVersion))
|
||||||
|
throw new DomainException("PRECONDITION_REQUIRED", "A valid If-Match header is required for this update.", 428);
|
||||||
|
return rowVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Emit the strong <c>ETag</c> response header for a row-version token.</summary>
|
||||||
|
protected void SetETag(uint rowVersion) => Response.Headers.ETag = ETag.From(rowVersion);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ERPCore.Dtos.Audit;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only audit trail (FR-X-02; auditor role). Extends the documented §11 API —
|
||||||
|
/// the audit trail is required (AR-01 compensating control) and read access is the
|
||||||
|
/// only way to consume it.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/audit-logs")]
|
||||||
|
public sealed class AuditLogsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAuditService _audit;
|
||||||
|
|
||||||
|
public AuditLogsController(IAuditService audit) => _audit = audit;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<AuditLogDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<AuditLogDto>>> List(
|
||||||
|
[FromQuery] string? entityType, [FromQuery] int? entityId, [FromQuery] int? userId,
|
||||||
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _audit.ListLogsAsync(entityType, entityId, userId, from, to, query, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
using ERPCore.Dtos.Auth;
|
||||||
|
using ERPCore.Dtos.Rbac;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using ERPCore.System.Errors;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fronts the external AuthHex identity service (API_REFERENCE.md) so the
|
||||||
|
/// frontend never calls AuthHex directly. Sessions are delivered as httpOnly
|
||||||
|
/// Secure cookies (docs/02-SECURITY.md §B.2) via <see cref="AuthCookieWriter"/>
|
||||||
|
/// — response bodies never carry raw tokens. Does not inherit
|
||||||
|
/// <see cref="ApiControllerBase"/>: most actions here are pre-session and need
|
||||||
|
/// <see cref="AllowAnonymousAttribute"/>, and the ETag/If-Match handling that
|
||||||
|
/// base provides doesn't apply to auth flows.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Produces("application/json")]
|
||||||
|
[Route("api/v1/auth")]
|
||||||
|
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
|
||||||
|
public sealed class AuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAuthUserService _users;
|
||||||
|
private readonly IAuthRecoveryService _recovery;
|
||||||
|
private readonly IAuthAltService _alt;
|
||||||
|
private readonly IRoleService _roles;
|
||||||
|
|
||||||
|
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
|
||||||
|
{
|
||||||
|
_users = users;
|
||||||
|
_recovery = recovery;
|
||||||
|
_alt = alt;
|
||||||
|
_roles = roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authoritative current-session info for the frontend: role + the sidebar nav
|
||||||
|
/// codes it may see (docs/10 C.9 shadow-Role sync). Replaces the frontend's
|
||||||
|
/// previous reliance on a stale, untrusted `roleId` cached in localStorage.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("me")]
|
||||||
|
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
|
||||||
|
return Ok(await _roles.GetMeAsync(roleCode, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Session-issuing (UserManager) ------------------------------------
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<AuthSessionResponse>> Register([FromBody] RegisterRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _users.RegisterAsync(request, ct);
|
||||||
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
return Ok(result.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<AuthSessionResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _users.LoginAsync(request, ct);
|
||||||
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
return Ok(result.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("login/otp/verify")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyLoginOtp([FromBody] VerifyOtpForLoginRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
||||||
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
return Ok(result.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("refresh-token")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<AuthSessionResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!Request.Cookies.TryGetValue(JwtAuthExtensions.RefreshTokenCookie, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
|
||||||
|
throw new DomainException(ErrorCodes.RefreshTokenMissing, "No refresh session cookie present.", 401);
|
||||||
|
|
||||||
|
var result = await _users.RefreshTokenAsync(refreshToken, request, ct);
|
||||||
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
return Ok(result.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Profile / sessions (UserManager) ---------------------------------
|
||||||
|
|
||||||
|
[HttpGet("users/{userId:guid}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(GetUserDetailsResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<GetUserDetailsResponse>> GetUserDetails(Guid userId, CancellationToken ct)
|
||||||
|
=> Ok(await _users.GetUserDetailsAsync(userId, ct));
|
||||||
|
|
||||||
|
[HttpGet("sessions")]
|
||||||
|
[ProducesResponseType(typeof(List<SessionDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<SessionDto>>> GetSessions(CancellationToken ct)
|
||||||
|
=> Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct));
|
||||||
|
|
||||||
|
[HttpPost("status")]
|
||||||
|
[ValidateCsrf]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<IActionResult> ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("lock")]
|
||||||
|
[ValidateCsrf]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<IActionResult> Lock([FromBody] LockUserAccountRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _users.LockUserAccountAsync(request, RequireBearerToken(), ct);
|
||||||
|
AuthCookieWriter.ClearSession(Response);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("change-password")]
|
||||||
|
[ValidateCsrf]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
|
||||||
|
AuthCookieWriter.ClearSession(Response);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("verify-password")]
|
||||||
|
[ProducesResponseType(typeof(VerifyPasswordResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
|
||||||
|
/// <para>
|
||||||
|
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
|
||||||
|
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
|
||||||
|
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The cookies are cleared even if the upstream revoke fails or no user can be
|
||||||
|
/// resolved: a logout that leaves the caller holding a live session cookie is worse
|
||||||
|
/// than one that leaves a stale session server-side (which lapses on its own).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("logout")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = request?.UserId ?? ResolveTokenUserId();
|
||||||
|
if (userId is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
|
||||||
|
}
|
||||||
|
catch (DomainException)
|
||||||
|
{
|
||||||
|
// Upstream unreachable or already-revoked — fall through and clear anyway.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthCookieWriter.ClearSession(Response);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
|
||||||
|
private Guid? ResolveTokenUserId()
|
||||||
|
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
|
||||||
|
|
||||||
|
[HttpPut("me")]
|
||||||
|
[ValidateCsrf]
|
||||||
|
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<UserSummaryDto?>> UpdateMe([FromBody] UpdateUserRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.UpdateUserAsync(request, RequireBearerToken(), ct));
|
||||||
|
|
||||||
|
// ---- 2FA (UserManager) -------------------------------------------------
|
||||||
|
|
||||||
|
[HttpPost("2fa/initiate")]
|
||||||
|
[ValidateCsrf]
|
||||||
|
[ProducesResponseType(typeof(TwoFaSetupResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TwoFaSetupResponse>> InitiateTwoFa(CancellationToken ct)
|
||||||
|
=> Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct));
|
||||||
|
|
||||||
|
[HttpPost("2fa/complete")]
|
||||||
|
[ValidateCsrf]
|
||||||
|
[ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<CompleteTwoFaSetupResponse>> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct));
|
||||||
|
|
||||||
|
[HttpPost("2fa/verify")]
|
||||||
|
[ValidateCsrf]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<IActionResult> VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("2fa/disable")]
|
||||||
|
[ValidateCsrf]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<IActionResult> DisableTwoFa([FromBody] DisableTwoFaRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _users.DisableTwoFaAsync(request, RequireBearerToken(), ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("2fa/status")]
|
||||||
|
[ProducesResponseType(typeof(TwoFaStatusResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<TwoFaStatusResponse>> GetTwoFaStatus(CancellationToken ct)
|
||||||
|
=> Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct));
|
||||||
|
|
||||||
|
// ---- Recovery -----------------------------------------------------------
|
||||||
|
|
||||||
|
[HttpPost("recovery/forgot-password")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ForgotPasswordResponse>> ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _recovery.ForgotPasswordAsync(request, ct));
|
||||||
|
|
||||||
|
[HttpPost("recovery/verify-otp")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(VerifyRecoveryOtpResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<VerifyRecoveryOtpResponse>> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _recovery.VerifyOtpAsync(request, ct));
|
||||||
|
|
||||||
|
[HttpPost("recovery/reset-password")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _recovery.ResetPasswordAsync(request, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("recovery/reset-password-token")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
public async Task<IActionResult> ResetPasswordWithToken([FromBody] ResetPasswordWithTokenRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _recovery.ResetPasswordWithTokenAsync(request, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Availability / OTP (AltOptionManager) -----------------------------
|
||||||
|
|
||||||
|
[HttpPost("availability")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(IsAvailableResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<IsAvailableResponse>> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _alt.IsAvailableAsync(request, ct));
|
||||||
|
|
||||||
|
[HttpPost("otp/send")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<SendOtpResponse>> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _alt.SendOtpAsync(request, ct));
|
||||||
|
|
||||||
|
[HttpPost("otp/verify")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyAltOtp([FromBody] VerifyAltOtpRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _alt.VerifyOtpAsync(request, ct);
|
||||||
|
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||||
|
return Ok(result.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>The token that authenticated this request — Bearer header if present, else the session cookie.</summary>
|
||||||
|
private string RequireBearerToken()
|
||||||
|
{
|
||||||
|
var header = Request.Headers.Authorization.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return header["Bearer ".Length..];
|
||||||
|
|
||||||
|
if (Request.Cookies.TryGetValue(JwtAuthExtensions.AccessTokenCookie, out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
|
||||||
|
return cookieToken;
|
||||||
|
|
||||||
|
// [Authorize] already guaranteed one of the above was present to authenticate this request.
|
||||||
|
throw new DomainException(ErrorCodes.AuthUpstreamError, "No bearer token found on an authenticated request.", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Brands;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
|
||||||
|
[Route("api/v1/brands")]
|
||||||
|
public sealed class BrandsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBrandService _brands;
|
||||||
|
|
||||||
|
public BrandsController(IBrandService brands) => _brands = brands;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<BrandDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<BrandDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _brands.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{brandId:int}")]
|
||||||
|
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<BrandDto>> GetById(int brandId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _brands.GetAsync(brandId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<BrandDto>> Create([FromBody] CreateBrandRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _brands.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/brands/{result.Value.BrandId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{brandId:int}")]
|
||||||
|
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<BrandDto>> Update(int brandId, [FromBody] UpdateBrandRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _brands.UpdateAsync(brandId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{brandId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _brands.SetStatusAsync(brandId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Categories;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories
|
||||||
|
/// nested beneath each category. The hierarchy is exactly two levels deep — the old
|
||||||
|
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/categories")]
|
||||||
|
public sealed class CategoriesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ICategoryService _categories;
|
||||||
|
|
||||||
|
public CategoriesController(ICategoryService categories) => _categories = categories;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _categories.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{categoryId:int}")]
|
||||||
|
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CategoryDto>> GetById(int categoryId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _categories.GetAsync(categoryId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _categories.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{categoryId:int}")]
|
||||||
|
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<CategoryDto>> Update(
|
||||||
|
int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _categories.UpdateAsync(categoryId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{categoryId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(
|
||||||
|
int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _categories.SetStatusAsync(categoryId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subcategories — nested under their parent category (docs/11 §2.3).
|
||||||
|
// Updates live on SubCategoriesController at /api/v1/subcategories/{id}.
|
||||||
|
|
||||||
|
[HttpGet("{categoryId:int}/subcategories")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<SubCategoryDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> ListSubCategories(
|
||||||
|
int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct));
|
||||||
|
|
||||||
|
[HttpPost("{categoryId:int}/subcategories")]
|
||||||
|
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<SubCategoryDto>> CreateSubCategory(
|
||||||
|
int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,62 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Grn;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Goods-receipt endpoints (docs/11 §4).</summary>
|
||||||
|
[Route("api/v1/grns")]
|
||||||
|
public sealed class GrnsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IGrnService _grns;
|
||||||
|
|
||||||
|
public GrnsController(IGrnService grns) => _grns = grns;
|
||||||
|
|
||||||
|
/// <summary>List GRNs, newest first.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
|
||||||
|
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||||
|
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{grnId:int}")]
|
||||||
|
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<GrnDto>> GetById(int grnId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _grns.GetAsync(grnId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create a Draft GRN against a PO or direct. Cost is PO-derived for PO lines.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<GrnDto>> Create([FromBody] CreateGrnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _grns.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/grns/{dto.GrnId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
|
||||||
|
[HttpPost("{grnId:int}/confirm")]
|
||||||
|
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<GrnConfirmResultDto>> Confirm(
|
||||||
|
int grnId, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken ct)
|
||||||
|
=> Ok(await _grns.ConfirmAsync(grnId, idempotencyKey, ct));
|
||||||
|
|
||||||
|
/// <summary>Release or reject an inspection-hold line (FR-GRN-05).</summary>
|
||||||
|
[HttpPost("{grnId:int}/lines/{grnLineId:int}/release")]
|
||||||
|
[ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ReleaseLineResultDto>> Release(
|
||||||
|
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Attendance upload batch endpoints (docs/13-BACKEND-HRM-API.md §4).</summary>
|
||||||
|
[Route("api/v1/attendance-batches")]
|
||||||
|
public sealed class AttendanceBatchesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAttendanceUploadService _attendance;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
|
public AttendanceBatchesController(IAttendanceUploadService attendance, ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
_attendance = attendance;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("template.xlsx")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public IActionResult DownloadTemplate([FromQuery] string? format)
|
||||||
|
{
|
||||||
|
var (content, contentType, fileName) = _attendance.GenerateTemplate(string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase));
|
||||||
|
return File(content, contentType, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<AttendanceUploadBatchDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<AttendanceUploadBatchDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] AttendanceBatchStatus? status,
|
||||||
|
[FromQuery] int? periodYear, [FromQuery] int? periodMonth, CancellationToken ct)
|
||||||
|
=> Ok(await _attendance.ListBatchesAsync(query, status, periodYear, periodMonth, ct));
|
||||||
|
|
||||||
|
[HttpGet("{batchId:int}")]
|
||||||
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<AttendanceUploadBatchDto>> GetById(int batchId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _attendance.GetBatchAsync(batchId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||||
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<AttendanceUploadBatchDto>> Upload(
|
||||||
|
[FromForm] UploadAttendanceBatchMetadata metadata, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var result = await _attendance.UploadAsync(stream, file.FileName, metadata.PeriodStart, metadata.PeriodEnd, _currentUser.AuditUserId, ct);
|
||||||
|
return Created($"/api/v1/attendance-batches/{result.AttendanceUploadBatchId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{batchId:int}/records")]
|
||||||
|
[ProducesResponseType(typeof(List<AttendanceRecordDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<AttendanceRecordDto>>> ListRecords(
|
||||||
|
int batchId, [FromQuery] RowValidationStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _attendance.ListRecordsAsync(batchId, status, ct));
|
||||||
|
|
||||||
|
[HttpPut("{batchId:int}/records/{recordId:int}")]
|
||||||
|
[ProducesResponseType(typeof(AttendanceRecordDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<AttendanceRecordDto>> UpdateRecord(
|
||||||
|
int batchId, int recordId, [FromBody] UpdateAttendanceRecordRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _attendance.UpdateRecordAsync(batchId, recordId, request, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{batchId:int}/resolve-duplicate")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> ResolveDuplicate(int batchId, [FromBody] ResolveDuplicateRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _attendance.ResolveDuplicateAsync(batchId, request, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{batchId:int}/validate")]
|
||||||
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<AttendanceUploadBatchDto>> Validate(int batchId, CancellationToken ct)
|
||||||
|
=> Ok(await _attendance.ValidateAsync(batchId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{batchId:int}/confirm")]
|
||||||
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<AttendanceUploadBatchDto>> Confirm(int batchId, CancellationToken ct)
|
||||||
|
=> Ok(await _attendance.ConfirmAsync(batchId, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{batchId:int}/unlock")]
|
||||||
|
[ProducesResponseType(typeof(AttendanceUploadBatchDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<AttendanceUploadBatchDto>> Unlock(int batchId, [FromBody] UnlockAttendanceBatchRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _attendance.UnlockAsync(batchId, request.Reason, _currentUser.AuditUserId, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Branch master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Route("api/v1/branches")]
|
||||||
|
public sealed class BranchesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IBranchService _branches;
|
||||||
|
|
||||||
|
public BranchesController(IBranchService branches) => _branches = branches;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<BranchDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<BranchDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _branches.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{branchId:int}")]
|
||||||
|
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<BranchDto>> GetById(int branchId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _branches.GetAsync(branchId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<BranchDto>> Create([FromBody] CreateBranchRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _branches.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/branches/{result.Value.BranchId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{branchId:int}")]
|
||||||
|
[ProducesResponseType(typeof(BranchDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<BranchDto>> Update(int branchId, [FromBody] UpdateBranchRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _branches.UpdateAsync(branchId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{branchId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int branchId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _branches.SetStatusAsync(branchId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Department master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Route("api/v1/departments")]
|
||||||
|
public sealed class DepartmentsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDepartmentService _departments;
|
||||||
|
|
||||||
|
public DepartmentsController(IDepartmentService departments) => _departments = departments;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<DepartmentDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<DepartmentDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _departments.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{departmentId:int}")]
|
||||||
|
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<DepartmentDto>> GetById(int departmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _departments.GetAsync(departmentId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<DepartmentDto>> Create([FromBody] CreateDepartmentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _departments.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/departments/{result.Value.DepartmentId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{departmentId:int}")]
|
||||||
|
[ProducesResponseType(typeof(DepartmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<DepartmentDto>> Update(int departmentId, [FromBody] UpdateDepartmentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _departments.UpdateAsync(departmentId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{departmentId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int departmentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _departments.SetStatusAsync(departmentId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Designation (job title) master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Route("api/v1/designations")]
|
||||||
|
public sealed class DesignationsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDesignationService _designations;
|
||||||
|
|
||||||
|
public DesignationsController(IDesignationService designations) => _designations = designations;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<DesignationDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<DesignationDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _designations.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{designationId:int}")]
|
||||||
|
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<DesignationDto>> GetById(int designationId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _designations.GetAsync(designationId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<DesignationDto>> Create([FromBody] CreateDesignationRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _designations.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/designations/{result.Value.DesignationId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{designationId:int}")]
|
||||||
|
[ProducesResponseType(typeof(DesignationDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<DesignationDto>> Update(int designationId, [FromBody] UpdateDesignationRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _designations.UpdateAsync(designationId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{designationId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int designationId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _designations.SetStatusAsync(designationId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Employee (staff) endpoints, incl. the Employee<->User cross-link and staff
|
||||||
|
/// document sub-resources (docs/13-BACKEND-HRM-API.md §3).
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/employees")]
|
||||||
|
public sealed class EmployeesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IEmployeeService _employees;
|
||||||
|
private readonly IEmployeeUserLinkService _links;
|
||||||
|
private readonly IEmployeeDocumentService _documents;
|
||||||
|
private readonly ILeaveBalanceService _leaveBalances;
|
||||||
|
private readonly IEmployeeSalaryStructureService _salaryStructures;
|
||||||
|
private readonly IEmployeeLoanService _loans;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
|
public EmployeesController(
|
||||||
|
IEmployeeService employees, IEmployeeUserLinkService links, IEmployeeDocumentService documents,
|
||||||
|
ILeaveBalanceService leaveBalances, IEmployeeSalaryStructureService salaryStructures,
|
||||||
|
IEmployeeLoanService loans, ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
_employees = employees;
|
||||||
|
_links = links;
|
||||||
|
_documents = documents;
|
||||||
|
_leaveBalances = leaveBalances;
|
||||||
|
_salaryStructures = salaryStructures;
|
||||||
|
_loans = loans;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<EmployeeListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<EmployeeListItemDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EmployeeStatus? status,
|
||||||
|
[FromQuery] int? departmentId, [FromQuery] int? designationId, [FromQuery] int? branchId, CancellationToken ct)
|
||||||
|
=> Ok(await _employees.ListAsync(query, status, departmentId, designationId, branchId, ct));
|
||||||
|
|
||||||
|
/// <summary>Advisory reverse-direction lookup: does a System User already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
|
||||||
|
[HttpGet("email-lookup")]
|
||||||
|
[ProducesResponseType(typeof(UserMatchResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<UserMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
|
||||||
|
=> Ok(new UserMatchResponse(await _links.FindUserCandidateByEmailAsync(email, ct)));
|
||||||
|
|
||||||
|
[HttpGet("{employeeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<EmployeeDetailDto>> GetById(int employeeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _employees.GetAsync(employeeId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<EmployeeDetailDto>> Create([FromBody] CreateEmployeeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _employees.CreateAsync(request, _currentUser.AuditUserId, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/employees/{result.Value.EmployeeId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{employeeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmployeeDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<EmployeeDetailDto>> Update(int employeeId, [FromBody] UpdateEmployeeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _employees.UpdateAsync(employeeId, request, expected, _currentUser.AuditUserId, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
|
||||||
|
[HttpPatch("{employeeId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int employeeId, [FromBody] UpdateEmployeeStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _employees.SetStatusAsync(employeeId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{employeeId:int}/link-user")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> LinkUser(int employeeId, [FromBody] LinkUserRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _links.LinkAsync(employeeId, request.UserId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{employeeId:int}/link-user")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> UnlinkUser(int employeeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _links.UnlinkAsync(employeeId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{employeeId:int}/bank-details")]
|
||||||
|
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ListBankDetails(int employeeId, CancellationToken ct)
|
||||||
|
=> Ok(await _employees.ListBankDetailsAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[HttpPut("{employeeId:int}/bank-details")]
|
||||||
|
[ProducesResponseType(typeof(List<EmployeeBankDetailDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<List<EmployeeBankDetailDto>>> ReplaceBankDetails(
|
||||||
|
int employeeId, [FromBody] ReplaceEmployeeBankDetailsRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _employees.ReplaceBankDetailsAsync(employeeId, request, ct));
|
||||||
|
|
||||||
|
[HttpGet("{employeeId:int}/leave-balances")]
|
||||||
|
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<LeaveBalanceDto>>> ListLeaveBalances(int employeeId, [FromQuery] int? year, CancellationToken ct)
|
||||||
|
=> Ok(await _leaveBalances.ListAsync(employeeId, year, ct));
|
||||||
|
|
||||||
|
[HttpPut("{employeeId:int}/leave-balances")]
|
||||||
|
[ProducesResponseType(typeof(List<LeaveBalanceDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<List<LeaveBalanceDto>>> UpdateLeaveBalances(
|
||||||
|
int employeeId, [FromBody] UpdateLeaveBalancesRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _leaveBalances.ApplyAdjustmentsAsync(employeeId, request, ct));
|
||||||
|
|
||||||
|
[HttpGet("{employeeId:int}/salary-structure")]
|
||||||
|
[ProducesResponseType(typeof(List<EmployeeSalaryStructureDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<EmployeeSalaryStructureDto>>> GetSalaryStructureHistory(int employeeId, CancellationToken ct)
|
||||||
|
=> Ok(await _salaryStructures.ListHistoryAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{employeeId:int}/salary-structure")]
|
||||||
|
[ProducesResponseType(typeof(EmployeeSalaryStructureDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<EmployeeSalaryStructureDto>> CreateSalaryStructure(
|
||||||
|
int employeeId, [FromBody] CreateSalaryStructureRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _salaryStructures.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
|
||||||
|
return Created($"/api/v1/employees/{employeeId}/salary-structure", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{employeeId:int}/loans")]
|
||||||
|
[ProducesResponseType(typeof(List<EmployeeLoanDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<EmployeeLoanDto>>> ListLoans(int employeeId, CancellationToken ct)
|
||||||
|
=> Ok(await _loans.ListAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{employeeId:int}/loans/{loanId:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<EmployeeLoanDto>> GetLoan(int employeeId, int loanId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _loans.GetAsync(employeeId, loanId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{employeeId:int}/loans")]
|
||||||
|
[ProducesResponseType(typeof(EmployeeLoanDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<EmployeeLoanDto>> CreateLoan(int employeeId, [FromBody] CreateEmployeeLoanRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _loans.CreateAsync(employeeId, request, _currentUser.AuditUserId, ct);
|
||||||
|
return Created($"/api/v1/employees/{employeeId}/loans/{result.EmployeeLoanId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{employeeId:int}/documents")]
|
||||||
|
[ProducesResponseType(typeof(List<EmployeeDocumentDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<EmployeeDocumentDto>>> ListDocuments(int employeeId, CancellationToken ct)
|
||||||
|
=> Ok(await _documents.ListAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{employeeId:int}/documents")]
|
||||||
|
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||||
|
[ProducesResponseType(typeof(EmployeeDocumentDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status413PayloadTooLarge)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<EmployeeDocumentDto>> UploadDocument(
|
||||||
|
int employeeId, [FromForm] UploadEmployeeDocumentRequest request, IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
var result = await _documents.UploadAsync(
|
||||||
|
employeeId, request, stream, file.FileName, file.ContentType, _currentUser.AuditUserId, ct);
|
||||||
|
return Created($"/api/v1/employees/{employeeId}/documents/{result.EmployeeDocumentId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{employeeId:int}/documents/{documentId:int}/download")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> DownloadDocument(int employeeId, int documentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (content, fileName, contentType) = await _documents.DownloadAsync(employeeId, documentId, ct);
|
||||||
|
return File(content, contentType, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{employeeId:int}/documents/{documentId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetDocumentStatus(
|
||||||
|
int employeeId, int documentId, [FromBody] UpdateEmployeeDocumentStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _documents.SetStatusAsync(employeeId, documentId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>EmploymentType master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Route("api/v1/employment-types")]
|
||||||
|
public sealed class EmploymentTypesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IEmploymentTypeService _employmentTypes;
|
||||||
|
|
||||||
|
public EmploymentTypesController(IEmploymentTypeService employmentTypes) => _employmentTypes = employmentTypes;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<EmploymentTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<EmploymentTypeDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _employmentTypes.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{employmentTypeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<EmploymentTypeDto>> GetById(int employmentTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _employmentTypes.GetAsync(employmentTypeId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<EmploymentTypeDto>> Create([FromBody] CreateEmploymentTypeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _employmentTypes.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/employment-types/{result.Value.EmploymentTypeId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{employmentTypeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(EmploymentTypeDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<EmploymentTypeDto>> Update(int employmentTypeId, [FromBody] UpdateEmploymentTypeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _employmentTypes.UpdateAsync(employmentTypeId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{employmentTypeId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int employmentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _employmentTypes.SetStatusAsync(employmentTypeId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Staff document-type catalog ("DocType") endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Route("api/v1/hr-document-types")]
|
||||||
|
public sealed class HrDocumentTypesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IHrDocumentTypeService _types;
|
||||||
|
|
||||||
|
public HrDocumentTypesController(IHrDocumentTypeService types) => _types = types;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<HrDocumentTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<HrDocumentTypeDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _types.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{hrDocumentTypeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<HrDocumentTypeDto>> GetById(int hrDocumentTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _types.GetAsync(hrDocumentTypeId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<HrDocumentTypeDto>> Create([FromBody] CreateHrDocumentTypeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _types.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/hr-document-types/{result.Value.HrDocumentTypeId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{hrDocumentTypeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(HrDocumentTypeDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<HrDocumentTypeDto>> Update(int hrDocumentTypeId, [FromBody] UpdateHrDocumentTypeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _types.UpdateAsync(hrDocumentTypeId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{hrDocumentTypeId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int hrDocumentTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _types.SetStatusAsync(hrDocumentTypeId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Read-only HRM reports (FR-HR-RPT, docs/13-BACKEND-HRM-API.md §6). No new entities — aggregation over existing tables.</summary>
|
||||||
|
[Route("api/v1/reports/hrm")]
|
||||||
|
public sealed class HrReportsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IHrReportService _reports;
|
||||||
|
|
||||||
|
public HrReportsController(IHrReportService reports) => _reports = reports;
|
||||||
|
|
||||||
|
[HttpGet("attendance-summary")]
|
||||||
|
[ProducesResponseType(typeof(List<AttendanceSummaryRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<AttendanceSummaryRowDto>>> AttendanceSummary(
|
||||||
|
[FromQuery] int periodYear, [FromQuery] int periodMonth, [FromQuery] int? departmentId, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.AttendanceSummaryAsync(periodYear, periodMonth, departmentId, ct));
|
||||||
|
|
||||||
|
[HttpGet("overtime")]
|
||||||
|
[ProducesResponseType(typeof(List<OvertimeReportRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<OvertimeReportRowDto>>> Overtime(
|
||||||
|
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.OvertimeReportAsync(periodYear, periodMonth, ct));
|
||||||
|
|
||||||
|
[HttpGet("late-arrivals")]
|
||||||
|
[ProducesResponseType(typeof(List<LateArrivalReportRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<LateArrivalReportRowDto>>> LateArrivals(
|
||||||
|
[FromQuery] int periodYear, [FromQuery] int periodMonth, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.LateArrivalReportAsync(periodYear, periodMonth, ct));
|
||||||
|
|
||||||
|
[HttpGet("payroll-register")]
|
||||||
|
[ProducesResponseType(typeof(List<PayrollRegisterRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<PayrollRegisterRowDto>>> PayrollRegister([FromQuery] int payrollRunId, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.PayrollRegisterAsync(payrollRunId, ct));
|
||||||
|
|
||||||
|
[HttpGet("salary-history")]
|
||||||
|
[ProducesResponseType(typeof(List<SalaryHistoryRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<SalaryHistoryRowDto>>> SalaryHistory([FromQuery] int employeeId, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.SalaryHistoryAsync(employeeId, ct));
|
||||||
|
|
||||||
|
[HttpGet("leave-balances")]
|
||||||
|
[ProducesResponseType(typeof(List<LeaveBalanceReportRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<LeaveBalanceReportRowDto>>> LeaveBalances([FromQuery] int year, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.LeaveBalanceReportAsync(year, ct));
|
||||||
|
|
||||||
|
[HttpGet("document-expiry")]
|
||||||
|
[ProducesResponseType(typeof(List<DocumentExpiryReportRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<DocumentExpiryReportRowDto>>> DocumentExpiry([FromQuery] int withinDays, CancellationToken ct)
|
||||||
|
=> Ok(await _reports.DocumentExpiryReportAsync(withinDays, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Leave request endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||||
|
[Route("api/v1/leave-requests")]
|
||||||
|
public sealed class LeaveRequestsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILeaveRequestService _requests;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
|
public LeaveRequestsController(ILeaveRequestService requests, ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
_requests = requests;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<LeaveRequestDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<LeaveRequestDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] int? employeeId, [FromQuery] LeaveRequestStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _requests.ListAsync(query, employeeId, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{leaveRequestId:int}")]
|
||||||
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<LeaveRequestDto>> GetById(int leaveRequestId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _requests.GetAsync(leaveRequestId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<LeaveRequestDto>> Create([FromBody] CreateLeaveRequestRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _requests.CreateAsync(request, _currentUser.AuditUserId, ct);
|
||||||
|
return Created($"/api/v1/leave-requests/{result.LeaveRequestId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{leaveRequestId:int}/submit")]
|
||||||
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<LeaveRequestDto>> Submit(int leaveRequestId, CancellationToken ct)
|
||||||
|
=> Ok(await _requests.SubmitAsync(leaveRequestId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{leaveRequestId:int}/approve")]
|
||||||
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<LeaveRequestDto>> Approve(int leaveRequestId, CancellationToken ct)
|
||||||
|
=> Ok(await _requests.ApproveAsync(leaveRequestId, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{leaveRequestId:int}/reject")]
|
||||||
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<LeaveRequestDto>> Reject(int leaveRequestId, [FromBody] RejectLeaveRequestRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _requests.RejectAsync(leaveRequestId, request.Reason, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{leaveRequestId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(LeaveRequestDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<LeaveRequestDto>> Cancel(int leaveRequestId, CancellationToken ct)
|
||||||
|
=> Ok(await _requests.CancelAsync(leaveRequestId, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Leave type master endpoints (docs/13-BACKEND-HRM-API.md §5).</summary>
|
||||||
|
[Route("api/v1/leave-types")]
|
||||||
|
public sealed class LeaveTypesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILeaveTypeService _leaveTypes;
|
||||||
|
|
||||||
|
public LeaveTypesController(ILeaveTypeService leaveTypes) => _leaveTypes = leaveTypes;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<LeaveTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<LeaveTypeDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _leaveTypes.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{leaveTypeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<LeaveTypeDto>> GetById(int leaveTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _leaveTypes.GetAsync(leaveTypeId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<LeaveTypeDto>> Create([FromBody] CreateLeaveTypeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _leaveTypes.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/leave-types/{result.Value.LeaveTypeId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{leaveTypeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(LeaveTypeDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<LeaveTypeDto>> Update(int leaveTypeId, [FromBody] UpdateLeaveTypeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _leaveTypes.UpdateAsync(leaveTypeId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{leaveTypeId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int leaveTypeId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _leaveTypes.SetStatusAsync(leaveTypeId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Payroll run endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Route("api/v1/payroll-runs")]
|
||||||
|
public sealed class PayrollRunsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPayrollRunService _payrollRuns;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
|
public PayrollRunsController(IPayrollRunService payrollRuns, ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
_payrollRuns = payrollRuns;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<PayrollRunDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<PayrollRunDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] int? periodYear, [FromQuery] int? periodMonth,
|
||||||
|
[FromQuery] PayrollRunStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _payrollRuns.ListAsync(query, periodYear, periodMonth, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{payrollRunId:int}")]
|
||||||
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PayrollRunDto>> GetById(int payrollRunId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _payrollRuns.GetAsync(payrollRunId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{payrollRunId:int}/lines")]
|
||||||
|
[ProducesResponseType(typeof(List<PayrollLineDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<PayrollLineDto>>> ListLines(int payrollRunId, CancellationToken ct)
|
||||||
|
=> Ok(await _payrollRuns.ListLinesAsync(payrollRunId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{payrollRunId:int}/lines/{lineId:int}")]
|
||||||
|
[ProducesResponseType(typeof(PayrollLineDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PayrollLineDetailDto>> GetLine(int payrollRunId, int lineId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _payrollRuns.GetLineAsync(payrollRunId, lineId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<PayrollRunDto>> Generate([FromBody] GeneratePayrollRunRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _payrollRuns.GenerateAsync(request, _currentUser.AuditUserId, ct);
|
||||||
|
return Created($"/api/v1/payroll-runs/{result.PayrollRunId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{payrollRunId:int}/approve")]
|
||||||
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PayrollRunDto>> Approve(int payrollRunId, CancellationToken ct)
|
||||||
|
=> Ok(await _payrollRuns.ApproveAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{payrollRunId:int}/lock")]
|
||||||
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PayrollRunDto>> Lock(int payrollRunId, CancellationToken ct)
|
||||||
|
=> Ok(await _payrollRuns.LockAsync(payrollRunId, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{payrollRunId:int}/unlock")]
|
||||||
|
[ProducesResponseType(typeof(PayrollRunDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PayrollRunDto>> Unlock(int payrollRunId, [FromBody] UnlockPayrollRunRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _payrollRuns.UnlockAsync(payrollRunId, request.Reason, _currentUser.AuditUserId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{payrollRunId:int}/generate-payslips")]
|
||||||
|
[ProducesResponseType(typeof(List<PayslipDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<List<PayslipDto>>> GeneratePayslips(int payrollRunId, CancellationToken ct)
|
||||||
|
=> Ok(await _payrollRuns.GeneratePayslipsAsync(payrollRunId, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Infra.Auth;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Effective-dated EPF/ETF settings (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Route("api/v1/payroll-statutory-settings")]
|
||||||
|
public sealed class PayrollStatutorySettingsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPayrollStatutorySettingService _settings;
|
||||||
|
private readonly ICurrentUser _currentUser;
|
||||||
|
|
||||||
|
public PayrollStatutorySettingsController(IPayrollStatutorySettingService settings, ICurrentUser currentUser)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<PayrollStatutorySettingDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<PayrollStatutorySettingDto>>> List(CancellationToken ct)
|
||||||
|
=> Ok(await _settings.ListAsync(ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(PayrollStatutorySettingDto), StatusCodes.Status201Created)]
|
||||||
|
public async Task<ActionResult<PayrollStatutorySettingDto>> Create([FromBody] UpsertPayrollStatutorySettingRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _settings.CreateAsync(request, _currentUser.AuditUserId, ct);
|
||||||
|
return Created($"/api/v1/payroll-statutory-settings/{result.PayrollStatutorySettingId}", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Payslip retrieval + HTML print view (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Route("api/v1/payslips")]
|
||||||
|
public sealed class PayslipsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPayslipService _payslips;
|
||||||
|
|
||||||
|
public PayslipsController(IPayslipService payslips) => _payslips = payslips;
|
||||||
|
|
||||||
|
[HttpGet("{payslipId:int}")]
|
||||||
|
[ProducesResponseType(typeof(PayslipDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PayslipDto>> GetById(int payslipId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _payslips.GetAsync(payslipId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{payslipId:int}/view")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> View(int payslipId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var html = await _payslips.RenderHtmlAsync(payslipId, ct);
|
||||||
|
return html is null ? NotFound() : Content(html, "text/html");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>SalaryComponent master endpoints (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Route("api/v1/salary-components")]
|
||||||
|
public sealed class SalaryComponentsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISalaryComponentService _components;
|
||||||
|
|
||||||
|
public SalaryComponentsController(ISalaryComponentService components) => _components = components;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<SalaryComponentDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<SalaryComponentDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _components.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{salaryComponentId:int}")]
|
||||||
|
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SalaryComponentDto>> GetById(int salaryComponentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _components.GetAsync(salaryComponentId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<SalaryComponentDto>> Create([FromBody] CreateSalaryComponentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _components.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/salary-components/{result.Value.SalaryComponentId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{salaryComponentId:int}")]
|
||||||
|
[ProducesResponseType(typeof(SalaryComponentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<SalaryComponentDto>> Update(int salaryComponentId, [FromBody] UpdateSalaryComponentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _components.UpdateAsync(salaryComponentId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{salaryComponentId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int salaryComponentId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _components.SetStatusAsync(salaryComponentId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>Configurable APIT-style tax slabs (docs/13-BACKEND-HRM-API.md §6).</summary>
|
||||||
|
[Route("api/v1/tax-slabs")]
|
||||||
|
public sealed class TaxSlabsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITaxSlabService _taxSlabs;
|
||||||
|
|
||||||
|
public TaxSlabsController(ITaxSlabService taxSlabs) => _taxSlabs = taxSlabs;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<TaxSlabDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<TaxSlabDto>>> List(CancellationToken ct)
|
||||||
|
=> Ok(await _taxSlabs.ListAsync(ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(TaxSlabDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<TaxSlabDto>> Create([FromBody] CreateTaxSlabRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _taxSlabs.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/tax-slabs/{result.TaxSlabId}", result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers.Hrm;
|
||||||
|
|
||||||
|
/// <summary>WorkShift master endpoints (docs/13-BACKEND-HRM-API.md §2).</summary>
|
||||||
|
[Route("api/v1/work-shifts")]
|
||||||
|
public sealed class WorkShiftsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IWorkShiftService _shifts;
|
||||||
|
|
||||||
|
public WorkShiftsController(IWorkShiftService shifts) => _shifts = shifts;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<WorkShiftDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<WorkShiftDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _shifts.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{workShiftId:int}")]
|
||||||
|
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<WorkShiftDto>> GetById(int workShiftId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _shifts.GetAsync(workShiftId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<WorkShiftDto>> Create([FromBody] CreateWorkShiftRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _shifts.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/work-shifts/{result.Value.WorkShiftId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{workShiftId:int}")]
|
||||||
|
[ProducesResponseType(typeof(WorkShiftDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<WorkShiftDto>> Update(int workShiftId, [FromBody] UpdateWorkShiftRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _shifts.UpdateAsync(workShiftId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{workShiftId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int workShiftId, [FromBody] UpdateHrMasterStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _shifts.SetStatusAsync(workShiftId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.ItemTypes;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material
|
||||||
|
/// dimension names. <c>GET</c> is the reason this master exists: it populates the item
|
||||||
|
/// builder's dropdown. Items never reference an item type; the chosen values are encoded
|
||||||
|
/// into the client-generated SKU (docs/10 Part C.9).
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/item-types")]
|
||||||
|
public sealed class ItemTypesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IItemTypeService _itemTypes;
|
||||||
|
|
||||||
|
public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes;
|
||||||
|
|
||||||
|
/// <summary>Feeds the frontend item-builder dropdown; filter <c>status=Active</c> for selectable rows.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<ItemTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<ItemTypeDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _itemTypes.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{itemTypeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ItemTypeDto>> GetById(int itemTypeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _itemTypes.GetAsync(itemTypeId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ItemTypeDto>> Create([FromBody] CreateItemTypeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _itemTypes.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/item-types/{result.Value.ItemTypeId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{itemTypeId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<ItemTypeDto>> Update(int itemTypeId, [FromBody] UpdateItemTypeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _itemTypes.UpdateAsync(itemTypeId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{itemTypeId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Items;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Item master endpoints (docs/11-BACKEND-PHASE1.md §2.1–2.2).</summary>
|
||||||
|
[Route("api/v1/items")]
|
||||||
|
public sealed class ItemsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IItemService _items;
|
||||||
|
|
||||||
|
public ItemsController(IItemService items) => _items = items;
|
||||||
|
|
||||||
|
/// <summary>List items with optional filters and paging.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<ItemListItemDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<ItemListItemDto>>> List(
|
||||||
|
[FromQuery] PageQuery query,
|
||||||
|
[FromQuery] EntityStatus? status,
|
||||||
|
[FromQuery] int? categoryId,
|
||||||
|
[FromQuery] int? subCategoryId,
|
||||||
|
[FromQuery] int? brandId,
|
||||||
|
[FromQuery] TrackingMode? trackingMode,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
|
||||||
|
|
||||||
|
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||||
|
[HttpGet("{itemId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ItemDetailDto>> GetById(int itemId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _items.GetAsync(itemId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create an item (SKU unique). Server sets status and timestamps.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<ItemDetailDto>> Create([FromBody] CreateItemRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _items.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/items/{result.Value.ItemId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Full update; requires <c>If-Match</c> (412 on stale ETag).</summary>
|
||||||
|
[HttpPut("{itemId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<ItemDetailDto>> Update(int itemId, [FromBody] UpdateItemRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _items.UpdateAsync(itemId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Activate / deactivate the item (FR-MD-08 — deactivate, not delete).</summary>
|
||||||
|
[HttpPatch("{itemId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _items.SetStatusAsync(itemId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replace the item's per-warehouse reorder settings (FR-MD-05).</summary>
|
||||||
|
[HttpPut("{itemId:int}/reorder")]
|
||||||
|
[ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)]
|
||||||
|
[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,24 @@
|
|||||||
|
using ERPCore.Dtos.Audit;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only GL-ready journal stubs (FR-STK-13; consumed by the Accounting phase).
|
||||||
|
/// Data only — no posting in Phase 1.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/journal-entries")]
|
||||||
|
public sealed class JournalEntriesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAuditService _audit;
|
||||||
|
|
||||||
|
public JournalEntriesController(IAuditService audit) => _audit = audit;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<JournalEntryStubDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<JournalEntryStubDto>>> List(
|
||||||
|
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _audit.ListJournalAsync(sourceDocType, sourceDocId, query, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using ERPCore.Domain.Entities;
|
||||||
|
using ERPCore.Dtos.Rbac;
|
||||||
|
using ERPCore.Repositories.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only sidebar nav tree, used by the Role permission-assignment checkbox
|
||||||
|
/// UI and by `GET /auth/me` (see AuthController) to resolve a role's visible codes.
|
||||||
|
/// NavItem/SubNavItem rows are seeded (NavItemConfiguration/SubNavItemConfiguration)
|
||||||
|
/// to match the frontend's hardcoded sidebar — not admin-editable in this phase.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/nav")]
|
||||||
|
public sealed class NavController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRepository<NavItem> _navItems;
|
||||||
|
|
||||||
|
public NavController(IRepository<NavItem> navItems) => _navItems = navItems;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(List<NavItemDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<NavItemDto>>> GetTree(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var items = await _navItems.Query().AsNoTracking()
|
||||||
|
.Include(n => n.Children)
|
||||||
|
.OrderBy(n => n.SortOrder)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var dto = items.Select(n => new NavItemDto(
|
||||||
|
n.NavItemId, n.Code, n.Label, n.Icon, n.Href, n.SortOrder,
|
||||||
|
n.Children.OrderBy(c => c.SortOrder)
|
||||||
|
.Select(c => new SubNavItemDto(c.SubNavItemId, c.Code, c.Label, c.Icon, c.Href, c.SortOrder))
|
||||||
|
.ToList())).ToList();
|
||||||
|
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ERPCore.Dtos.Config;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
|
||||||
|
/// feature gate for subcategories/brands/item-types.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only.
|
||||||
|
/// A dedicated <c>CONFIG_MANAGE</c> permission is reserved for when per-endpoint RBAC
|
||||||
|
/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute
|
||||||
|
/// with no other change. Until then any ERP-admitted user can flip these flags; that is
|
||||||
|
/// the accepted Phase-1 posture, consistent with every other endpoint.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/product-config")]
|
||||||
|
public sealed class ProductConfigController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IProductConfigService _config;
|
||||||
|
|
||||||
|
public ProductConfigController(IProductConfigService config) => _config = config;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<ProductConfigDto>> Get(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _config.GetAsync(ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<ProductConfigDto>> Update(
|
||||||
|
[FromBody] UpdateProductConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _config.UpdateAsync(request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,93 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Procurement;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Purchase-order endpoints (docs/11 §3.3).</summary>
|
||||||
|
[Route("api/v1/purchase-orders")]
|
||||||
|
public sealed class PurchaseOrdersController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPurchaseOrderService _pos;
|
||||||
|
|
||||||
|
public PurchaseOrdersController(IPurchaseOrderService pos) => _pos = pos;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
|
||||||
|
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{poId:int}")]
|
||||||
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PurchaseOrderDto>> GetById(int poId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _pos.GetAsync(poId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create a PO — auto-approved on creation in Phase 1 (FR-PROC-04). Totals computed server-side.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<PurchaseOrderDto>> Create([FromBody] CreatePurchaseOrderRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _pos.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/purchase-orders/{result.Value.PoId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Edit while open (FR-PROC-05); requires <c>If-Match</c>. 409 PO_NOT_EDITABLE if closed.</summary>
|
||||||
|
[HttpPut("{poId:int}")]
|
||||||
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<PurchaseOrderDto>> Update(int poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _pos.UpdateAsync(poId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
|
||||||
|
[HttpPost("{poId:int}/submit")]
|
||||||
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PurchaseOrderDto>> Submit(int poId, CancellationToken ct)
|
||||||
|
=> Ok(await _pos.SubmitAsync(poId, ct));
|
||||||
|
|
||||||
|
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
|
||||||
|
[HttpDelete("{poId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int poId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _pos.DeleteAsync(poId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
|
||||||
|
[HttpPost("{poId:int}/approve")]
|
||||||
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PurchaseOrderDto>> Approve(int poId, CancellationToken ct)
|
||||||
|
=> Ok(await _pos.ApproveAsync(poId, ct));
|
||||||
|
|
||||||
|
/// <summary>Cancel — 409 if any goods have been received against the PO.</summary>
|
||||||
|
[HttpPost("{poId:int}/cancel")]
|
||||||
|
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<PurchaseOrderDto>> Cancel(int poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _pos.CancelAsync(poId, request.Reason, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Procurement;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Purchase-return endpoints (docs/11 §3.4).</summary>
|
||||||
|
[Route("api/v1/purchase-returns")]
|
||||||
|
public sealed class PurchaseReturnsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IPurchaseReturnService _returns;
|
||||||
|
|
||||||
|
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
|
||||||
|
|
||||||
|
/// <summary>List posted returns, newest first.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||||
|
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
|
||||||
|
|
||||||
|
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
|
||||||
|
[HttpGet("{returnId:int}")]
|
||||||
|
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _returns.GetAsync(returnId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<PurchaseReturnDto>> Create([FromBody] CreatePurchaseReturnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _returns.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/purchase-returns/{dto.ReturnId}", dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Reference;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Reason-code reference endpoints (docs/11 §6).</summary>
|
||||||
|
[Route("api/v1/reason-codes")]
|
||||||
|
public sealed class ReasonCodesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IReasonCodeService _codes;
|
||||||
|
|
||||||
|
public ReasonCodesController(IReasonCodeService codes) => _codes = codes;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<ReasonCodeDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<ReasonCodeDto>>> List(
|
||||||
|
[FromQuery] ReasonContext? context, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _codes.ListAsync(context, query, ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ReasonCodeDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ReasonCodeDto>> Create([FromBody] CreateReasonCodeRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _codes.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/reason-codes/{dto.ReasonCodeId}", dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Procurement;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Purchase-requisition endpoints (docs/11 §3.1).</summary>
|
||||||
|
[Route("api/v1/requisitions")]
|
||||||
|
public sealed class RequisitionsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRequisitionService _requisitions;
|
||||||
|
|
||||||
|
public RequisitionsController(IRequisitionService requisitions) => _requisitions = requisitions;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<RequisitionSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _requisitions.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{requisitionId:int}")]
|
||||||
|
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RequisitionDto>> GetById(int requisitionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _requisitions.GetAsync(requisitionId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<RequisitionDto>> Create([FromBody] CreateRequisitionRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _requisitions.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{requisitionId:int}/submit")]
|
||||||
|
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RequisitionDto>> Submit(int requisitionId, CancellationToken ct)
|
||||||
|
=> Ok(await _requisitions.SubmitAsync(requisitionId, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Procurement;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>RFQ & quotation endpoints (docs/11 §3.2).</summary>
|
||||||
|
[Route("api/v1/rfqs")]
|
||||||
|
public sealed class RfqsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRfqService _rfqs;
|
||||||
|
|
||||||
|
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
|
||||||
|
|
||||||
|
/// <summary>List RFQs, newest first.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _rfqs.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{rfqId:int}")]
|
||||||
|
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RfqDto>> GetById(int rfqId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _rfqs.GetAsync(rfqId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<RfqDto>> Create([FromBody] CreateRfqRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _rfqs.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/rfqs/{dto.RfqId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{rfqId:int}/quotations")]
|
||||||
|
[ProducesResponseType(typeof(VendorQuotationDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<VendorQuotationDto>> AddQuotation(int rfqId, [FromBody] CreateQuotationRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _rfqs.AddQuotationAsync(rfqId, request, ct);
|
||||||
|
return Created($"/api/v1/rfqs/{rfqId}/quotations/{dto.QuotationId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{rfqId:int}/comparison")]
|
||||||
|
[ProducesResponseType(typeof(RfqComparisonDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RfqComparisonDto>> Comparison(int rfqId, CancellationToken ct)
|
||||||
|
=> Ok(await _rfqs.GetComparisonAsync(rfqId, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Rbac;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
|
||||||
|
[Route("api/v1/roles")]
|
||||||
|
public sealed class RolesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRoleService _roles;
|
||||||
|
|
||||||
|
public RolesController(IRoleService roles) => _roles = roles;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<RoleDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<RoleDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _roles.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{roleId:int}")]
|
||||||
|
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RoleDto>> GetById(int roleId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _roles.GetAsync(roleId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<RoleDto>> Create([FromBody] CreateRoleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _roles.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/roles/{result.Value.RoleId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{roleId:int}")]
|
||||||
|
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<RoleDto>> Update(int roleId, [FromBody] UpdateRoleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _roles.UpdateAsync(roleId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{roleId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int roleId, [FromBody] UpdateRoleStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _roles.SetStatusAsync(roleId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{roleId:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Delete(int roleId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _roles.DeleteAsync(roleId, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{roleId:int}/permissions")]
|
||||||
|
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RolePermissionsDto>> GetPermissions(int roleId, CancellationToken ct)
|
||||||
|
=> Ok(await _roles.GetPermissionsAsync(roleId, ct));
|
||||||
|
|
||||||
|
[HttpPut("{roleId:int}/permissions")]
|
||||||
|
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<RolePermissionsDto>> AssignPermissions(
|
||||||
|
int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Stock;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Stock-adjustment endpoints (docs/11 §5.5).</summary>
|
||||||
|
[Route("api/v1/stock-adjustments")]
|
||||||
|
public sealed class StockAdjustmentsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IAdjustmentService _adjustments;
|
||||||
|
|
||||||
|
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
|
||||||
|
|
||||||
|
/// <summary>List posted adjustments, newest first.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
|
||||||
|
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
|
||||||
|
|
||||||
|
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
|
||||||
|
[HttpGet("{adjustmentId:int}")]
|
||||||
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _adjustments.GetAsync(adjustmentId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<AdjustmentDto>> Create([FromBody] CreateAdjustmentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _adjustments.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/stock-adjustments/{dto.AdjustmentId}", dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Procurement;
|
||||||
|
using ERPCore.Dtos.Stock;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Stock enquiry, ledger, valuation and reorder-alert endpoints (docs/11 §5.1–5.3, §5.7).</summary>
|
||||||
|
[Route("api/v1/stock")]
|
||||||
|
public sealed class StockController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IStockService _stock;
|
||||||
|
private readonly IReorderService _reorder;
|
||||||
|
|
||||||
|
public StockController(IStockService stock, IReorderService reorder)
|
||||||
|
{
|
||||||
|
_stock = stock;
|
||||||
|
_reorder = reorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("on-hand")]
|
||||||
|
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||||
|
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
|
||||||
|
|
||||||
|
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
|
||||||
|
[HttpGet("on-hand/list")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
|
||||||
|
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
|
||||||
|
/// this document post?" — the ledger's document reference is polymorphic, so there is
|
||||||
|
/// no FK to navigate instead (docs/10 C.9).
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("ledger")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
|
||||||
|
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
|
||||||
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||||
|
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
|
||||||
|
[FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
|
||||||
|
|
||||||
|
[HttpGet("valuation")]
|
||||||
|
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<StockValuationDto>> Valuation([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||||
|
=> Ok(await _stock.GetValuationAsync(itemId, warehouseId, ct));
|
||||||
|
|
||||||
|
/// <summary>Items at/below their reorder point (FR-STK-10), computed on read.</summary>
|
||||||
|
[HttpGet("reorder-alerts")]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<ReorderAlertDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<ReorderAlertDto>>> ReorderAlerts(
|
||||||
|
[FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _reorder.GetAlertsAsync(warehouseId, query, ct));
|
||||||
|
|
||||||
|
/// <summary>Create a draft requisition for an item's suggested reorder quantity.</summary>
|
||||||
|
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
|
||||||
|
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<RequisitionDto>> SuggestRequisition(
|
||||||
|
int itemId, [FromQuery] int warehouseId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _reorder.CreateSuggestedRequisitionAsync(itemId, warehouseId, ct);
|
||||||
|
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Stock;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Stock-count endpoints (docs/11 §5.6).</summary>
|
||||||
|
[Route("api/v1/stock-counts")]
|
||||||
|
public sealed class StockCountsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ICountService _counts;
|
||||||
|
|
||||||
|
public StockCountsController(ICountService counts) => _counts = counts;
|
||||||
|
|
||||||
|
/// <summary>List counts, newest first.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
|
||||||
|
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{countId:int}")]
|
||||||
|
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<CountDto>> GetById(int countId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _counts.GetAsync(countId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create a count with system quantities snapshotted (immutable).</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(CountDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<CountDto>> Create([FromBody] CreateCountRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _counts.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/stock-counts/{dto.CountId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Enter counted quantities; variance = counted − system.</summary>
|
||||||
|
[HttpPut("{countId:int}/counts")]
|
||||||
|
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<CountDto>> EnterCounts(int countId, [FromBody] EnterCountsRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _counts.EnterCountsAsync(countId, request, ct));
|
||||||
|
|
||||||
|
/// <summary>Post: emit a variance adjustment and close the count.</summary>
|
||||||
|
[HttpPost("{countId:int}/post")]
|
||||||
|
[ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<CountPostResultDto>> Post(int countId, CancellationToken ct)
|
||||||
|
=> Ok(await _counts.PostAsync(countId, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Stock;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Stock-transfer endpoints (docs/11 §5.4).</summary>
|
||||||
|
[Route("api/v1/stock-transfers")]
|
||||||
|
public sealed class StockTransfersController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITransferService _transfers;
|
||||||
|
|
||||||
|
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
|
||||||
|
|
||||||
|
/// <summary>List transfers, newest first.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
|
||||||
|
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
|
||||||
|
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
|
||||||
|
|
||||||
|
[HttpGet("{transferId:int}")]
|
||||||
|
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<TransferDto>> GetById(int transferId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _transfers.GetAsync(transferId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<TransferDto>> Create([FromBody] CreateTransferRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _transfers.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/stock-transfers/{dto.TransferId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dispatch: consume source FIFO layers into in-transit. 409 STOCK_NEGATIVE_BLOCKED if short.</summary>
|
||||||
|
[HttpPost("{transferId:int}/dispatch")]
|
||||||
|
[ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<DispatchResultDto>> Dispatch(int transferId, CancellationToken ct)
|
||||||
|
=> Ok(await _transfers.DispatchAsync(transferId, ct));
|
||||||
|
|
||||||
|
/// <summary>Receive: create the destination layer at the inherited cost (cost-preserving).</summary>
|
||||||
|
[HttpPost("{transferId:int}/receive")]
|
||||||
|
[ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||||
|
public async Task<ActionResult<ReceiveResultDto>> Receive(int transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _transfers.ReceiveAsync(transferId, request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using ERPCore.Dtos.Categories;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3).
|
||||||
|
/// Listing and creation live under the parent category on <see cref="CategoriesController"/>,
|
||||||
|
/// since a subcategory only exists in the context of one.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/subcategories")]
|
||||||
|
public sealed class SubCategoriesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly ICategoryService _categories;
|
||||||
|
|
||||||
|
public SubCategoriesController(ICategoryService categories) => _categories = categories;
|
||||||
|
|
||||||
|
[HttpGet("{subCategoryId:int}")]
|
||||||
|
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<SubCategoryDto>> GetById(int subCategoryId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _categories.GetSubCategoryAsync(subCategoryId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Renames a subcategory. It cannot be moved to another category — see the request DTO.</summary>
|
||||||
|
[HttpPut("{subCategoryId:int}")]
|
||||||
|
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<SubCategoryDto>> Update(
|
||||||
|
int subCategoryId, [FromBody] UpdateSubCategoryRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _categories.UpdateSubCategoryAsync(subCategoryId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
|
||||||
|
[HttpPatch("{subCategoryId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(
|
||||||
|
int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Uoms;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Unit-of-measure endpoints (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||||
|
[Route("api/v1/uoms")]
|
||||||
|
public sealed class UomsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IUomService _uoms;
|
||||||
|
|
||||||
|
public UomsController(IUomService uoms) => _uoms = uoms;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<UomDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<UomDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _uoms.ListAsync(query, ct));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(UomDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<UomDto>> Create([FromBody] CreateUomRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _uoms.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/uoms/{dto.UomId}", dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Hrm;
|
||||||
|
using ERPCore.Dtos.Users;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User management: local shadow `User` list/detail + role assignment, and
|
||||||
|
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/v1/users")]
|
||||||
|
public sealed class UsersController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IUserManagementService _users;
|
||||||
|
private readonly IEmployeeUserLinkService _links;
|
||||||
|
|
||||||
|
public UsersController(IUserManagementService users, IEmployeeUserLinkService links)
|
||||||
|
{
|
||||||
|
_users = users;
|
||||||
|
_links = links;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Advisory forward-direction lookup: does a Staff record already exist with this email? (docs/12-BACKEND-HRM.md A.5)</summary>
|
||||||
|
[HttpGet("email-lookup")]
|
||||||
|
[ProducesResponseType(typeof(EmployeeMatchResponse), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<EmployeeMatchResponse>> EmailLookup([FromQuery] string email, CancellationToken ct)
|
||||||
|
=> Ok(new EmployeeMatchResponse(await _links.FindStaffCandidateByEmailAsync(email, ct)));
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _users.ListAsync(query, ct));
|
||||||
|
|
||||||
|
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||||
|
[HttpGet("user-types")]
|
||||||
|
[ProducesResponseType(typeof(List<UserTypeOptionDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<List<UserTypeOptionDto>>> ListUserTypes(CancellationToken ct)
|
||||||
|
=> Ok(await _users.ListUserTypesAsync(ct));
|
||||||
|
|
||||||
|
[HttpGet("{userId:int}")]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> GetById(int userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _users.GetAsync(userId, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _users.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/users/{result.UserId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{userId:int}/role")]
|
||||||
|
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||||
|
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Vendors;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Vendor master endpoints (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||||
|
[Route("api/v1/vendors")]
|
||||||
|
public sealed class VendorsController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IVendorService _vendors;
|
||||||
|
|
||||||
|
public VendorsController(IVendorService vendors) => _vendors = vendors;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<VendorDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<VendorDto>>> List(
|
||||||
|
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
|
||||||
|
=> Ok(await _vendors.ListAsync(query, status, ct));
|
||||||
|
|
||||||
|
[HttpGet("{vendorId:int}")]
|
||||||
|
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<VendorDto>> GetById(int vendorId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _vendors.GetAsync(vendorId, ct);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<VendorDto>> Create([FromBody] CreateVendorRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await _vendors.CreateAsync(request, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{vendorId:int}")]
|
||||||
|
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
|
||||||
|
public async Task<ActionResult<VendorDto>> Update(int vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = RequireIfMatch();
|
||||||
|
var result = await _vendors.UpdateAsync(vendorId, request, expected, ct);
|
||||||
|
SetETag(result.RowVersion);
|
||||||
|
return Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{vendorId:int}/status")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> SetStatus(int vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using ERPCore.Dtos.Common;
|
||||||
|
using ERPCore.Dtos.Warehouses;
|
||||||
|
using ERPCore.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ERPCore.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Warehouse & bin endpoints (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
||||||
|
[Route("api/v1/warehouses")]
|
||||||
|
public sealed class WarehousesController : ApiControllerBase
|
||||||
|
{
|
||||||
|
private readonly IWarehouseService _warehouses;
|
||||||
|
|
||||||
|
public WarehousesController(IWarehouseService warehouses) => _warehouses = warehouses;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||||
|
=> Ok(await _warehouses.ListAsync(query, ct));
|
||||||
|
|
||||||
|
[HttpGet("{warehouseId:int}")]
|
||||||
|
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<WarehouseDto>> GetById(int warehouseId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _warehouses.GetAsync(warehouseId, ct);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<WarehouseDto>> Create([FromBody] CreateWarehouseRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _warehouses.CreateAsync(request, ct);
|
||||||
|
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{warehouseId:int}/bins")]
|
||||||
|
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<BinDto>>> ListBins(int warehouseId, CancellationToken ct)
|
||||||
|
=> Ok(await _warehouses.ListBinsAsync(warehouseId, ct));
|
||||||
|
|
||||||
|
[HttpPost("{warehouseId:int}/bins")]
|
||||||
|
[ProducesResponseType(typeof(BinDto), StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<ActionResult<BinDto>> CreateBin(int warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dto = await _warehouses.CreateBinAsync(warehouseId, request, ct);
|
||||||
|
return Created($"/api/v1/warehouses/{warehouseId}/bins/{dto.BinId}", dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ERPCore.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Document-type prefixes for <see cref="Entities.NumberSequence"/> and the
|
||||||
|
/// generated document numbers (docs/10 §B.8.2). One prefix per numbered document.
|
||||||
|
/// </summary>
|
||||||
|
public static class DocumentTypes
|
||||||
|
{
|
||||||
|
public const string Requisition = "PR";
|
||||||
|
public const string Rfq = "RFQ";
|
||||||
|
public const string PurchaseOrder = "PO";
|
||||||
|
public const string Grn = "GRN";
|
||||||
|
public const string Transfer = "TRF";
|
||||||
|
public const string Adjustment = "ADJ";
|
||||||
|
public const string Count = "CNT";
|
||||||
|
public const string PurchaseReturn = "PRET";
|
||||||
|
|
||||||
|
/// <summary>Production run (docs/30 FR-MFG-08) — <c>PRD-2026-00001</c>.</summary>
|
||||||
|
public const string Production = "PRD";
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per employee/day attendance row (FR-HR-ATT). <see cref="WorkShiftId"/> is
|
||||||
|
/// snapshotted from the employee's shift at ingestion time (docs/12-BACKEND-HRM.md
|
||||||
|
/// A.3) so a later shift reassignment never retroactively changes historical
|
||||||
|
/// Late/OT figures. Model: docs/12-BACKEND-HRM.md Part C.4.
|
||||||
|
/// </summary>
|
||||||
|
public class AttendanceRecord
|
||||||
|
{
|
||||||
|
public int AttendanceRecordId { get; set; }
|
||||||
|
public int? AttendanceUploadBatchId { get; set; }
|
||||||
|
public AttendanceUploadBatch? AttendanceUploadBatch { get; set; }
|
||||||
|
public int EmployeeId { get; set; }
|
||||||
|
public Employee? Employee { get; set; }
|
||||||
|
|
||||||
|
public DateTime AttendanceDate { get; set; }
|
||||||
|
public TimeSpan? CheckIn { get; set; }
|
||||||
|
public TimeSpan? CheckOut { get; set; }
|
||||||
|
public int WorkShiftId { get; set; }
|
||||||
|
public WorkShift? WorkShift { get; set; }
|
||||||
|
|
||||||
|
public int WorkingMinutes { get; set; }
|
||||||
|
public int LateMinutes { get; set; }
|
||||||
|
public int EarlyLeaveMinutes { get; set; }
|
||||||
|
public int OvertimeMinutes { get; set; }
|
||||||
|
|
||||||
|
public AttendanceStatus AttendanceStatus { get; set; }
|
||||||
|
public RowValidationStatus RowValidationStatus { get; set; } = RowValidationStatus.Valid;
|
||||||
|
public int? DuplicateOfAttendanceRecordId { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsManualOverride { get; set; }
|
||||||
|
public int? EditedBy { get; set; }
|
||||||
|
public DateTime? EditedAt { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attendance upload batch (FR-HR-ATT) — the transactional document driving the
|
||||||
|
/// exact status flow Draft→Validated→Confirmed→UsedInPayroll. Scoped to exactly
|
||||||
|
/// one payroll period, numbered via <see cref="NumberSequence"/> (docType "ATT").
|
||||||
|
/// Model: docs/12-BACKEND-HRM.md Part C.4.
|
||||||
|
/// </summary>
|
||||||
|
public class AttendanceUploadBatch
|
||||||
|
{
|
||||||
|
public int AttendanceUploadBatchId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
public DateTime PeriodStart { get; set; }
|
||||||
|
public DateTime PeriodEnd { get; set; }
|
||||||
|
public AttendanceSourceType SourceType { get; set; }
|
||||||
|
public string? OriginalFileName { get; set; }
|
||||||
|
|
||||||
|
public int UploadedBy { get; set; }
|
||||||
|
public DateTime UploadedAt { get; set; }
|
||||||
|
public AttendanceBatchStatus Status { get; set; } = AttendanceBatchStatus.Draft;
|
||||||
|
public int? ConfirmedBy { get; set; }
|
||||||
|
public DateTime? ConfirmedAt { get; set; }
|
||||||
|
|
||||||
|
public int RowCountTotal { get; set; }
|
||||||
|
public int RowCountDuplicate { get; set; }
|
||||||
|
public int RowCountError { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable audit trail entry (FR-X-02) — the compensating control for the deferred
|
||||||
|
/// RBAC (02-SECURITY AR-01/B.3). One row per create/update/delete of an audited
|
||||||
|
/// entity, capturing who / when / what changed (old→new in <see cref="ChangeSet"/>).
|
||||||
|
/// Written automatically by <c>ErpDbContext.SaveChangesAsync</c>. Append-only at the
|
||||||
|
/// app level; DB-role revocation of UPDATE/DELETE is deferred hardening (B.3).
|
||||||
|
/// Model: docs/10 Part C.7.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLog
|
||||||
|
{
|
||||||
|
public int AuditId { get; set; }
|
||||||
|
public int UserId { get; set; }
|
||||||
|
public string EntityType { get; set; } = string.Empty;
|
||||||
|
public int EntityId { get; set; }
|
||||||
|
public AuditAction Action { get; set; }
|
||||||
|
/// <summary>JSON change set: field→value (create/delete) or field→{old,new} (update).</summary>
|
||||||
|
public string ChangeSet { get; set; } = "{}";
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batch/lot for a batch-tracked item (FR-GRN-04, FR-WH-03). Expiry drives FEFO
|
||||||
|
/// picking of perishables. Model: docs/10 Part C.4.
|
||||||
|
/// </summary>
|
||||||
|
public class Batch
|
||||||
|
{
|
||||||
|
public int BatchId { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public string BatchNo { get; set; } = string.Empty;
|
||||||
|
public DateOnly? ExpiryDate { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bin / storage location within a warehouse (FR-MD-07, FR-WH-02). Stock is
|
||||||
|
/// tracked to bin level. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class Bin
|
||||||
|
{
|
||||||
|
public int BinId { get; set; }
|
||||||
|
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string? BinType { 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,21 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Brand master (FR-MD-09). Referenced optionally by <see cref="Item.BrandId"/>.
|
||||||
|
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||||
|
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class Brand
|
||||||
|
{
|
||||||
|
public int BrandId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
|
||||||
|
/// below is <see cref="SubCategory"/>; categories no longer self-nest (the former
|
||||||
|
/// <c>parent_id</c> tree was replaced in migration #2).
|
||||||
|
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
|
||||||
|
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class Category
|
||||||
|
{
|
||||||
|
public int CategoryId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Goods Receipt Note header (FR-GRN-01/02). Raised against a PO or direct
|
||||||
|
/// (<see cref="PoId"/> null). On confirm each line creates a FIFO layer and posts
|
||||||
|
/// an inbound ledger entry. Mutable aggregate with an <see cref="RowVersion"/>
|
||||||
|
/// concurrency token (docs/10 C.10). Model: docs/10 Part C.3.
|
||||||
|
/// </summary>
|
||||||
|
public class Grn
|
||||||
|
{
|
||||||
|
public int GrnId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int? PoId { get; set; }
|
||||||
|
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||||
|
|
||||||
|
public int VendorId { get; set; }
|
||||||
|
public Vendor? Vendor { get; set; }
|
||||||
|
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
public GrnStatus Status { get; set; } = GrnStatus.Draft;
|
||||||
|
|
||||||
|
public int CreatedBy { get; set; }
|
||||||
|
public User? Creator { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? PostedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token.</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<GrnLine> Lines { get; set; } = new List<GrnLine>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the gross cost received at:
|
||||||
|
/// entered on the line, defaulting to the PO price when omitted (a per-receipt price
|
||||||
|
/// override is now permitted — see docs/02-SECURITY C.3, revised). <see cref="PoUnitPrice"/>
|
||||||
|
/// snapshots the PO price at receipt so the variance survives later PO edits.
|
||||||
|
/// <see cref="NetUnitCost"/> = unitCost after trade discount — this is what the FIFO layer
|
||||||
|
/// costs at (VAT never enters stock value; it is recoverable input tax).
|
||||||
|
/// <see cref="ReceivedValue"/> = qty × netUnitCost (after discount, before VAT).
|
||||||
|
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
|
||||||
|
/// </summary>
|
||||||
|
public class GrnLine
|
||||||
|
{
|
||||||
|
public int GrnLineId { get; set; }
|
||||||
|
|
||||||
|
public int GrnId { get; set; }
|
||||||
|
public Grn? Grn { get; set; }
|
||||||
|
|
||||||
|
public int? PoLineId { get; set; }
|
||||||
|
public PoLine? PoLine { get; set; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
public int? BatchId { get; set; }
|
||||||
|
public Batch? Batch { get; set; }
|
||||||
|
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
|
||||||
|
public decimal UnitCost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of the PO line price at receipt; null for direct receipts.</summary>
|
||||||
|
public decimal? PoUnitPrice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Trade discount percentage (0–100), entered.</summary>
|
||||||
|
public decimal DiscountPct { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UnitCost × (1 − DiscountPct/100) — the inventory (FIFO layer) cost.</summary>
|
||||||
|
public decimal NetUnitCost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>VAT percentage (0–100), entered. Recoverable — does not affect stock value.</summary>
|
||||||
|
public decimal VatPct { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Qty × NetUnitCost × VatPct/100.</summary>
|
||||||
|
public decimal VatAmount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Qty × NetUnitCost (after discount, before VAT).</summary>
|
||||||
|
public decimal ReceivedValue { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Qty × NetUnitCost + VatAmount — payable to the vendor.</summary>
|
||||||
|
public decimal LineTotal { get; set; }
|
||||||
|
|
||||||
|
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Staff document catalog (FR-HR-DOC-01) — the user's "DocType": a category of
|
||||||
|
/// document (NIC, contract, certificate...), not the uploaded file itself (see
|
||||||
|
/// <see cref="EmployeeDocument"/>, the "Doc"). Deactivated, not deleted, when
|
||||||
|
/// referenced. Model: docs/12-BACKEND-HRM.md Part C.3.
|
||||||
|
/// </summary>
|
||||||
|
public class HrDocumentType
|
||||||
|
{
|
||||||
|
public int HrDocumentTypeId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public HrDocumentCategory Category { get; set; }
|
||||||
|
public bool RequiredAtOnboarding { get; set; }
|
||||||
|
public bool ExpiryTracked { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item master (FR-MD-01). Mutable aggregate: carries a <see cref="RowVersion"/>
|
||||||
|
/// concurrency token surfaced as an ETag (docs/10 Part C.10). SKU is unique.
|
||||||
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class Item
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Sku { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public int CategoryId { get; set; }
|
||||||
|
public Category? Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
|
||||||
|
public int? SubCategoryId { get; set; }
|
||||||
|
public SubCategory? SubCategory { get; set; }
|
||||||
|
|
||||||
|
public int? BrandId { get; set; }
|
||||||
|
public Brand? Brand { get; set; }
|
||||||
|
|
||||||
|
public int BaseUomId { get; set; }
|
||||||
|
public Uom? BaseUom { get; set; }
|
||||||
|
|
||||||
|
public int? DefaultVendorId { get; set; }
|
||||||
|
public Vendor? DefaultVendor { get; set; }
|
||||||
|
|
||||||
|
public StockNature StockNature { get; set; }
|
||||||
|
public TrackingMode TrackingMode { get; set; }
|
||||||
|
public string? TaxClass { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value"
|
||||||
|
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
|
||||||
|
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
|
||||||
|
/// </summary>
|
||||||
|
public decimal? SalePrice { get; set; }
|
||||||
|
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<ItemReorder> ReorderSettings { get; set; } = new List<ItemReorder>();
|
||||||
|
public ICollection<UomConversion> UomConversions { get; set; } = new List<UomConversion>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reorder policy for an item, optionally per warehouse (FR-MD-05). Reorder alerts
|
||||||
|
/// are computed from these versus available stock (FR-STK-10) — not stored.
|
||||||
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class ItemReorder
|
||||||
|
{
|
||||||
|
public int ReorderId { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public int WarehouseId { get; set; }
|
||||||
|
public Warehouse? Warehouse { get; set; }
|
||||||
|
|
||||||
|
public decimal ReorderPoint { get; set; }
|
||||||
|
public decimal ReorderQty { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or
|
||||||
|
/// Material.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
|
||||||
|
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
|
||||||
|
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
|
||||||
|
/// values (Red, S, M) are encoded by the client into the generated SKU
|
||||||
|
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
|
||||||
|
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
|
||||||
|
/// </para>
|
||||||
|
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
|
||||||
|
/// which is what the old <c>ItemType</c> enum became.
|
||||||
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class ItemType
|
||||||
|
{
|
||||||
|
public int ItemTypeId { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GL-ready journal entry emitted per stock movement (FR-STK-13) — data only, no
|
||||||
|
/// posting in Phase 1 (the Accounting module consumes these later). One row per
|
||||||
|
/// ledger entry, referencing the same source document polymorphically. Account
|
||||||
|
/// codes are Phase-1 placeholders until a chart of accounts exists.
|
||||||
|
/// Model: docs/10 Part C.7.
|
||||||
|
/// </summary>
|
||||||
|
public class JournalEntryStub
|
||||||
|
{
|
||||||
|
public int JournalId { get; set; }
|
||||||
|
public string SourceDocType { get; set; } = string.Empty;
|
||||||
|
public int SourceDocId { get; set; }
|
||||||
|
public string DebitAccount { get; set; } = string.Empty;
|
||||||
|
public string CreditAccount { get; set; } = string.Empty;
|
||||||
|
public decimal Amount { 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,22 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A top-level sidebar entry (mirrors the frontend's hardcoded nav list,
|
||||||
|
/// components/Layouts/AppSidebar.tsx). Seeded to match the current app routes;
|
||||||
|
/// per-role visibility is controlled via <see cref="Permission"/>/<see cref="RolePermission"/>,
|
||||||
|
/// not by editing these rows through the UI.
|
||||||
|
/// </summary>
|
||||||
|
public class NavItem
|
||||||
|
{
|
||||||
|
public int NavItemId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string? Icon { get; set; }
|
||||||
|
public string? Href { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public ICollection<SubNavItem> Children { get; set; } = new List<SubNavItem>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-document-type, per-year running counter behind human document numbers
|
||||||
|
/// (FR-X-03): <c>PR-2026-00001</c>, <c>PO-2026-00042</c>, … Numbers are issued
|
||||||
|
/// inside the document's transaction so they are unique and gap-controlled.
|
||||||
|
/// Model: docs/10 Part C.7.
|
||||||
|
/// </summary>
|
||||||
|
public class NumberSequence
|
||||||
|
{
|
||||||
|
public int SequenceId { get; set; }
|
||||||
|
public string DocType { get; set; } = string.Empty;
|
||||||
|
public int Year { get; set; }
|
||||||
|
public int LastNumber { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-employee payroll summary row (FR-HR-PAY-05). EpfEmployerAmount/EtfEmployerAmount
|
||||||
|
/// are informational/liability only, never subtracted from NetSalary
|
||||||
|
/// (docs/12-BACKEND-HRM.md B.4). Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||||
|
/// </summary>
|
||||||
|
public class PayrollLine
|
||||||
|
{
|
||||||
|
public int PayrollLineId { get; set; }
|
||||||
|
public int PayrollRunId { get; set; }
|
||||||
|
public PayrollRun? PayrollRun { get; set; }
|
||||||
|
public int EmployeeId { get; set; }
|
||||||
|
public Employee? Employee { get; set; }
|
||||||
|
|
||||||
|
public decimal BasicSalary { get; set; }
|
||||||
|
public decimal TotalAllowances { get; set; }
|
||||||
|
public decimal OvertimeAmount { get; set; }
|
||||||
|
public decimal GrossSalary { get; set; }
|
||||||
|
|
||||||
|
public decimal LateDeductionAmount { get; set; }
|
||||||
|
public decimal NoPayAmount { get; set; }
|
||||||
|
public decimal LoanDeductionAmount { get; set; }
|
||||||
|
public decimal EpfEmployeeAmount { get; set; }
|
||||||
|
public decimal EpfEmployerAmount { get; set; }
|
||||||
|
public decimal EtfEmployerAmount { get; set; }
|
||||||
|
public decimal TaxAmount { get; set; }
|
||||||
|
public decimal OtherDeductionsAmount { get; set; }
|
||||||
|
public decimal NetSalary { get; set; }
|
||||||
|
|
||||||
|
public int WorkingDays { get; set; }
|
||||||
|
public int PresentDays { get; set; }
|
||||||
|
public int AbsentDays { get; set; }
|
||||||
|
public int LeaveDays { get; set; }
|
||||||
|
public int OtMinutesTotal { get; set; }
|
||||||
|
public int LateMinutesTotal { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public List<PayrollLineComponent> Components { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>The detailed Basic/Transport/Meal/OT/Late/No-Pay/Loan/EPF/ETF/Tax breakdown. Model: docs/12-BACKEND-HRM.md Part C.6.</summary>
|
||||||
|
public class PayrollLineComponent
|
||||||
|
{
|
||||||
|
public int PayrollLineComponentId { get; set; }
|
||||||
|
public int PayrollLineId { get; set; }
|
||||||
|
public PayrollLine? PayrollLine { get; set; }
|
||||||
|
public PayrollLineComponentCategory ComponentCategory { get; set; }
|
||||||
|
/// <summary>Set only for structure-sourced Allowance/OtherDeduction lines; null for system-computed lines.</summary>
|
||||||
|
public int? SalaryComponentId { get; set; }
|
||||||
|
public SalaryComponent? SalaryComponent { get; set; }
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Payroll run (FR-HR-PAY-05/06) — the transactional document. Numbered via
|
||||||
|
/// <see cref="NumberSequence"/> (docType "PAY"). Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||||
|
/// </summary>
|
||||||
|
public class PayrollRun
|
||||||
|
{
|
||||||
|
public int PayrollRunId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
public int PeriodYear { get; set; }
|
||||||
|
public int PeriodMonth { get; set; }
|
||||||
|
/// <summary>Null = company-wide run.</summary>
|
||||||
|
public int? BranchId { get; set; }
|
||||||
|
public Branch? Branch { get; set; }
|
||||||
|
public PayrollRunStatus Status { get; set; } = PayrollRunStatus.Draft;
|
||||||
|
|
||||||
|
public int GeneratedBy { get; set; }
|
||||||
|
public DateTime GeneratedAt { get; set; }
|
||||||
|
public int? ApprovedBy { get; set; }
|
||||||
|
public DateTime? ApprovedAt { get; set; }
|
||||||
|
public int? LockedBy { get; set; }
|
||||||
|
public DateTime? LockedAt { get; set; }
|
||||||
|
public int? UnlockedBy { get; set; }
|
||||||
|
public DateTime? UnlockedAt { get; set; }
|
||||||
|
public string? UnlockReason { get; set; }
|
||||||
|
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public List<PayrollLine> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Effective-dated EPF/ETF rates (FR-HR-PAY-04) — Sri Lanka defaults (EPF 8%
|
||||||
|
/// employee / 12% employer, ETF 3% employer-only), configurable since government
|
||||||
|
/// rates can change. Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||||
|
/// </summary>
|
||||||
|
public class PayrollStatutorySetting
|
||||||
|
{
|
||||||
|
public int PayrollStatutorySettingId { get; set; }
|
||||||
|
public decimal EpfEmployeeRate { get; set; } = 0.08m;
|
||||||
|
public decimal EpfEmployerRate { get; set; } = 0.12m;
|
||||||
|
public decimal EtfEmployerRate { get; set; } = 0.03m;
|
||||||
|
public decimal OtMultiplierDefault { get; set; } = 1.5m;
|
||||||
|
public DateTime EffectiveFrom { get; set; }
|
||||||
|
public DateTime? EffectiveTo { get; set; }
|
||||||
|
|
||||||
|
public int CreatedBy { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin generation/release marker over a <see cref="PayrollLine"/> — ships as an
|
||||||
|
/// HTML print view in this phase, per the confirmed decision (no PDF dependency).
|
||||||
|
/// Model: docs/12-BACKEND-HRM.md Part C.6.
|
||||||
|
/// </summary>
|
||||||
|
public class Payslip
|
||||||
|
{
|
||||||
|
public int PayslipId { get; set; }
|
||||||
|
public int PayrollLineId { get; set; }
|
||||||
|
public PayrollLine? PayrollLine { get; set; }
|
||||||
|
public DateTime GeneratedAt { get; set; }
|
||||||
|
public DateTime? ReleasedAt { get; set; }
|
||||||
|
public int? ReleasedBy { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A grantable sidebar-visibility unit — exactly one of <see cref="NavItemId"/> /
|
||||||
|
/// <see cref="SubNavItemId"/> is set (enforced in <c>NavSeedService</c>/service layer,
|
||||||
|
/// not by a DB constraint). One row is seeded per <see cref="NavItem"/>/<see cref="SubNavItem"/>;
|
||||||
|
/// <see cref="RolePermission"/> grants it to a role.
|
||||||
|
/// </summary>
|
||||||
|
public class Permission
|
||||||
|
{
|
||||||
|
public int PermissionId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public int? NavItemId { get; set; }
|
||||||
|
public int? SubNavItemId { get; set; }
|
||||||
|
|
||||||
|
public NavItem? NavItem { get; set; }
|
||||||
|
public SubNavItem? SubNavItem { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purchase-order line (FR-PROC-03). <see cref="Tax"/> is the line tax rate
|
||||||
|
/// (e.g. 0.18); <see cref="QtyReceived"/> accrues as GRNs confirm (FR-PROC-07).
|
||||||
|
/// Model: docs/10 Part C.2.
|
||||||
|
/// </summary>
|
||||||
|
public class PoLine
|
||||||
|
{
|
||||||
|
public int PoLineId { get; set; }
|
||||||
|
|
||||||
|
public int PoId { get; set; }
|
||||||
|
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }//
|
||||||
|
public decimal Tax { get; set; }
|
||||||
|
public decimal QtyReceived { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
|
||||||
|
/// gating optional product master-data features.
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
|
||||||
|
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
|
||||||
|
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
|
||||||
|
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
|
||||||
|
/// so there is nothing on a write to reject; the frontend honours it by hiding the
|
||||||
|
/// builder's type section. Reads are never gated, so existing data stays visible after a
|
||||||
|
/// flag is switched off.
|
||||||
|
/// </para>
|
||||||
|
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||||
|
/// </summary>
|
||||||
|
public class ProductConfig
|
||||||
|
{
|
||||||
|
/// <summary>Always 1 — the singleton row's id.</summary>
|
||||||
|
public const int SingletonId = 1;
|
||||||
|
|
||||||
|
public int ConfigId { get; set; }
|
||||||
|
|
||||||
|
public bool SubcategoriesEnabled { get; set; } = true;
|
||||||
|
public bool BrandsEnabled { get; set; } = true;
|
||||||
|
public bool ItemTypesEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
public int? UpdatedBy { get; set; }
|
||||||
|
public User? UpdatedByUser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -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,36 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purchase order header (FR-PROC-03..06). Mutable aggregate with a
|
||||||
|
/// <see cref="RowVersion"/> ETag token; editable while open (FR-PROC-05).
|
||||||
|
/// Phase 1 auto-approves on creation; <see cref="ApprovalRequired"/> is retained
|
||||||
|
/// for the future approval workflow. Totals are computed server-side from lines
|
||||||
|
/// (not stored). Model: docs/10 Part C.2.
|
||||||
|
/// </summary>
|
||||||
|
public class PurchaseOrder
|
||||||
|
{
|
||||||
|
public int PoId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int VendorId { get; set; }
|
||||||
|
public Vendor? Vendor { get; set; }
|
||||||
|
|
||||||
|
public int? RequisitionId { get; set; }
|
||||||
|
public Requisition? Requisition { get; set; }
|
||||||
|
|
||||||
|
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
|
||||||
|
public bool ApprovalRequired { get; set; }
|
||||||
|
|
||||||
|
public int CreatedBy { get; set; }
|
||||||
|
public User? Creator { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
|
||||||
|
public ICollection<PoLine> Lines { get; set; } = new List<PoLine>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purchase return header (FR-PROC-08) — returns received goods to a vendor,
|
||||||
|
/// generating an outbound stock movement. Auto-posts with a mandatory reason code.
|
||||||
|
/// Model: docs/10 Part C.2.
|
||||||
|
/// </summary>
|
||||||
|
public class PurchaseReturn
|
||||||
|
{
|
||||||
|
public int ReturnId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int VendorId { get; set; }
|
||||||
|
public Vendor? Vendor { 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<PurchaseReturnLine> Lines { get; set; } = new List<PurchaseReturnLine>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purchase-return line (FR-PROC-08) referencing the original GRN line for
|
||||||
|
/// traceability. <see cref="Qty"/> is in base UOM. Model: docs/10 Part C.2.
|
||||||
|
/// </summary>
|
||||||
|
public class PurchaseReturnLine
|
||||||
|
{
|
||||||
|
public int ReturnLineId { get; set; }
|
||||||
|
|
||||||
|
public int ReturnId { get; set; }
|
||||||
|
public PurchaseReturn? Return { get; set; }
|
||||||
|
|
||||||
|
public int? GrnLineId { get; set; }
|
||||||
|
public GrnLine? GrnLine { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configurable reason code for adjustments, returns and count variances
|
||||||
|
/// (FR-X-04). Model: docs/10 Part C.7.
|
||||||
|
/// </summary>
|
||||||
|
public class ReasonCode
|
||||||
|
{
|
||||||
|
public int ReasonCodeId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public ReasonContext Context { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Purchase requisition header (FR-PROC-01). <see cref="RequestedBy"/> is the audit
|
||||||
|
/// actor from the token (never the body). Model: docs/10 Part C.2.
|
||||||
|
/// </summary>
|
||||||
|
public class Requisition
|
||||||
|
{
|
||||||
|
public int RequisitionId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int RequestedBy { get; set; }
|
||||||
|
public User? Requester { get; set; }
|
||||||
|
|
||||||
|
public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<RequisitionLine> Lines { get; set; } = new List<RequisitionLine>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>Requisition line (FR-PROC-01). Model: docs/10 Part C.2.</summary>
|
||||||
|
public class RequisitionLine
|
||||||
|
{
|
||||||
|
public int ReqLineId { get; set; }
|
||||||
|
|
||||||
|
public int RequisitionId { get; set; }
|
||||||
|
public Requisition? Requisition { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
public DateOnly? RequiredBy { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request for Quotation header (FR-PROC-02) raised from a requisition. Vendor
|
||||||
|
/// quotations attach for comparison. Model: docs/10 Part C.2.
|
||||||
|
/// </summary>
|
||||||
|
public class Rfq
|
||||||
|
{
|
||||||
|
public int RfqId { get; set; }
|
||||||
|
public string DocNo { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int RequisitionId { get; set; }
|
||||||
|
public Requisition? Requisition { get; set; }
|
||||||
|
|
||||||
|
public RfqStatus Status { get; set; } = RfqStatus.Open;
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public ICollection<RfqLine> Lines { get; set; } = new List<RfqLine>();
|
||||||
|
public ICollection<VendorQuotation> Quotations { get; set; } = new List<VendorQuotation>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>RFQ line — an item + quantity being quoted (FR-PROC-02). Model: docs/10 Part C.2.</summary>
|
||||||
|
public class RfqLine
|
||||||
|
{
|
||||||
|
public int RfqLineId { get; set; }
|
||||||
|
|
||||||
|
public int RfqId { get; set; }
|
||||||
|
public Rfq? Rfq { get; set; }
|
||||||
|
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public Item? Item { get; set; }
|
||||||
|
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using ERPCore.Domain.Enums;
|
||||||
|
|
||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
|
||||||
|
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
|
||||||
|
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
|
||||||
|
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
|
||||||
|
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
|
||||||
|
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
|
||||||
|
/// </summary>
|
||||||
|
public class Role
|
||||||
|
{
|
||||||
|
public int RoleId { get; set; }
|
||||||
|
public Guid AuthRoleId { get; set; }
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public bool IsSystemRole { get; set; }
|
||||||
|
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||||
|
public uint RowVersion { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ERPCore.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
|
||||||
|
public class RolePermission
|
||||||
|
{
|
||||||
|
public int RoleId { get; set; }
|
||||||
|
public int PermissionId { get; set; }
|
||||||
|
|
||||||
|
public Role? Role { get; set; }
|
||||||
|
public Permission? Permission { get; set; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user