Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47683ddd0f | |||
| 7c5faabc2d | |||
| 582782b0fe | |||
| 250cf89abb | |||
| c9a84e235b | |||
| 5d18d5d576 | |||
| ed2ee87c68 | |||
| 6c7f53350f | |||
| 0e4bcf174b | |||
| cb9fd7dfa8 | |||
| 4b2914cd5d | |||
| ae7627fcf2 | |||
| 67150425e4 | |||
| 0415794473 | |||
| 9e1aa57987 | |||
| 22f86451e3 | |||
| 7ac30bb454 | |||
| 4e84a15db7 | |||
| 783696fa97 | |||
| 0aa05f10f2 | |||
| 08a4c28868 | |||
| badb26a81f | |||
| 057dd5aedc |
@@ -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,252 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
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;
|
||||
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt)
|
||||
{
|
||||
_users = users;
|
||||
_recovery = recovery;
|
||||
_alt = alt;
|
||||
}
|
||||
|
||||
// ---- 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));
|
||||
|
||||
[HttpPost("logout")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Logout([FromBody] LogoutRequest request, CancellationToken ct)
|
||||
{
|
||||
await _users.LogoutUserAsync(request, ct);
|
||||
AuthCookieWriter.ClearSession(Response);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[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,31 @@
|
||||
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).</summary>
|
||||
[Route("api/v1/categories")]
|
||||
public sealed class CategoriesController : ApiControllerBase
|
||||
{
|
||||
private readonly ICategoryService _categories;
|
||||
|
||||
public CategoriesController(ICategoryService categories) => _categories = categories;
|
||||
|
||||
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<CategoryTreeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
|
||||
=> tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<ActionResult<CategoryDto>> Create([FromBody] CreateCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var dto = await _categories.CreateAsync(request, ct);
|
||||
return Created($"/api/v1/categories/{dto.CategoryId}", dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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;
|
||||
|
||||
[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,89 @@
|
||||
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] TrackingMode? trackingMode,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
|
||||
|
||||
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
|
||||
[HttpGet("{itemId:int}")]
|
||||
[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,74 @@
|
||||
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>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,26 @@
|
||||
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>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,44 @@
|
||||
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, CancellationToken ct)
|
||||
=> Ok(await _requisitions.ListAsync(query, 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,49 @@
|
||||
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;
|
||||
|
||||
[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,26 @@
|
||||
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>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,56 @@
|
||||
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));
|
||||
|
||||
[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] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, 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,49 @@
|
||||
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;
|
||||
|
||||
[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,49 @@
|
||||
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;
|
||||
|
||||
[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,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.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,17 @@
|
||||
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";
|
||||
}
|
||||
@@ -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,15 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Hierarchical item category (FR-MD-04). A null <see cref="ParentId"/> denotes a
|
||||
/// root category. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Category
|
||||
{
|
||||
public int CategoryId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int? ParentId { get; set; }
|
||||
public Category? Parent { get; set; }
|
||||
public ICollection<Category> Children { get; set; } = new List<Category>();
|
||||
}
|
||||
@@ -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,37 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
|
||||
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
|
||||
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
|
||||
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
|
||||
/// </summary>
|
||||
public class GrnLine
|
||||
{
|
||||
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; }
|
||||
public decimal UnitCost { get; set; }
|
||||
public decimal ReceivedValue { get; set; }
|
||||
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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; }
|
||||
|
||||
public int BaseUomId { get; set; }
|
||||
public Uom? BaseUom { get; set; }
|
||||
|
||||
public int? DefaultVendorId { get; set; }
|
||||
public Vendor? DefaultVendor { get; set; }
|
||||
|
||||
public ItemType ItemType { get; set; }
|
||||
public TrackingMode TrackingMode { get; set; }
|
||||
public string? TaxClass { 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,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,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,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,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,16 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Serial number for a serial-tracked item across its lifecycle (FR-WH-04).
|
||||
/// Model: docs/10 Part C.4.
|
||||
/// </summary>
|
||||
public class Serial
|
||||
{
|
||||
public int SerialId { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public string SerialNo { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = "InStock";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Stock adjustment header (FR-STK-07) — the highest-risk feature in the phase
|
||||
/// (02-SECURITY C.5). Auto-posts in Phase 1 with a mandatory reason code and user
|
||||
/// stamp. Mutable aggregate with an <see cref="RowVersion"/> token (docs/10 C.10).
|
||||
/// Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockAdjustment
|
||||
{
|
||||
public int AdjustmentId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public int ReasonCodeId { get; set; }
|
||||
public ReasonCode? ReasonCode { get; set; }
|
||||
|
||||
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<StockAdjustmentLine> Lines { get; set; } = new List<StockAdjustmentLine>();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Adjustment line (FR-STK-07). <see cref="QtyDelta"/> is a signed base-UOM
|
||||
/// quantity: negative consumes FIFO layers, positive creates a layer at last cost.
|
||||
/// Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockAdjustmentLine
|
||||
{
|
||||
public int AdjLineId { get; set; }
|
||||
|
||||
public int AdjustmentId { get; set; }
|
||||
public StockAdjustment? Adjustment { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int? BinId { get; set; }
|
||||
public int? BatchId { get; set; }
|
||||
public int? SerialId { get; set; }
|
||||
|
||||
public decimal QtyDelta { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Cycle/full physical count header (FR-STK-08). System quantities are snapshotted
|
||||
/// at creation and are immutable once opened (02-SECURITY C.7); posting emits a
|
||||
/// variance adjustment. Mutable aggregate with an <see cref="RowVersion"/> token.
|
||||
/// Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockCount
|
||||
{
|
||||
public int CountId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public CountType CountType { get; set; }
|
||||
public CountStatus Status { get; set; } = CountStatus.Draft;
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<StockCountLine> Lines { get; set; } = new List<StockCountLine>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Count line (FR-STK-08). <see cref="SystemQty"/> is the immutable snapshot;
|
||||
/// <see cref="Variance"/> = counted − system (in base UOM). Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockCountLine
|
||||
{
|
||||
public int CountLineId { get; set; }
|
||||
|
||||
public int CountId { get; set; }
|
||||
public StockCount? Count { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int? BinId { get; set; }
|
||||
|
||||
public decimal SystemQty { get; set; }
|
||||
public decimal? CountedQty { get; set; }
|
||||
public decimal? Variance { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// FIFO cost layer — a quantity received at a specific unit cost, consumed
|
||||
/// oldest-first (FR-STK-02). Keyed per item **per warehouse**; quantities and
|
||||
/// <see cref="UnitCost"/> are in the item's base UOM. Answers valuation
|
||||
/// ("what's on hand and at what cost"). Model: docs/10 Part C.5.
|
||||
/// </summary>
|
||||
public class StockLayer
|
||||
{
|
||||
public int LayerId { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int WarehouseId { get; set; }
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public int? BatchId { get; set; }
|
||||
public Batch? Batch { get; set; }
|
||||
|
||||
public int? SerialId { get; set; }
|
||||
public Serial? Serial { get; set; }
|
||||
|
||||
/// <summary>Originating GRN line — carries the inspection hold status for this stock.</summary>
|
||||
public int? GrnLineId { get; set; }
|
||||
public GrnLine? GrnLine { get; set; }
|
||||
|
||||
public decimal QtyReceived { get; set; }
|
||||
public decimal QtyRemaining { get; set; }
|
||||
public decimal UnitCost { get; set; }
|
||||
public DateTime ReceiptDate { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable, append-only stock ledger (FR-STK-01, FR-X-05). One row per costed
|
||||
/// movement; answers history ("what moved, when, by whom"). The originating
|
||||
/// document is referenced polymorphically via
|
||||
/// <see cref="SourceDocType"/>/<see cref="SourceDocId"/> (no hard FK per type) so
|
||||
/// new transaction types write here without a schema change. Model: docs/10 Part C.5.
|
||||
/// </summary>
|
||||
public class StockLedger
|
||||
{
|
||||
public int LedgerId { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public int WarehouseId { get; set; }
|
||||
public int? BinId { get; set; }
|
||||
public int? BatchId { get; set; }
|
||||
public int? SerialId { get; set; }
|
||||
public int UserId { get; set; }
|
||||
|
||||
public Direction Direction { get; set; }
|
||||
public decimal QtyBase { get; set; }
|
||||
public decimal UnitCost { get; set; }
|
||||
public decimal Value { get; set; }
|
||||
public decimal RunningBalance { get; set; }
|
||||
|
||||
public string SourceDocType { get; set; } = string.Empty;
|
||||
public int SourceDocId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Inter-warehouse stock transfer header (FR-STK-05/06). Dispatch consumes source
|
||||
/// FIFO layers into in-transit; receive creates the destination layer at the
|
||||
/// inherited cost (cost-preserving). Mutable aggregate with an
|
||||
/// <see cref="RowVersion"/> token (docs/10 C.10). Model: docs/10 Part C.6.
|
||||
/// </summary>
|
||||
public class StockTransfer
|
||||
{
|
||||
public int TransferId { get; set; }
|
||||
public string DocNo { get; set; } = string.Empty;
|
||||
|
||||
public int SrcWarehouseId { get; set; }
|
||||
public Warehouse? SrcWarehouse { get; set; }
|
||||
|
||||
public int DestWarehouseId { get; set; }
|
||||
public Warehouse? DestWarehouse { get; set; }
|
||||
|
||||
public TransferStatus Status { get; set; } = TransferStatus.Draft;
|
||||
|
||||
public int CreatedBy { get; set; }
|
||||
public User? Creator { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public uint RowVersion { get; set; }
|
||||
|
||||
public ICollection<StockTransferLine> Lines { get; set; } = new List<StockTransferLine>();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Transfer line (FR-STK-05/06). <see cref="Qty"/> is in base UOM.
|
||||
/// <para>
|
||||
/// Deviation note: <see cref="UnitCost"/> and <see cref="QtyReceived"/> extend
|
||||
/// docs/10 Part C.6's <c>STOCK_TRANSFER_LINE</c> to make the transfer
|
||||
/// cost-preserving: at dispatch the value-weighted cost of the consumed source
|
||||
/// layers is stored here, and receive recreates the destination layer at that cost
|
||||
/// (supports partial receive via <see cref="QtyReceived"/>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class StockTransferLine
|
||||
{
|
||||
public int TransferLineId { get; set; }
|
||||
|
||||
public int TransferId { get; set; }
|
||||
public StockTransfer? Transfer { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int? SrcBinId { get; set; }
|
||||
public int? DestBinId { get; set; }
|
||||
public int? BatchId { get; set; }
|
||||
public int? SerialId { get; set; }
|
||||
|
||||
public decimal Qty { get; set; }
|
||||
|
||||
/// <summary>Value-weighted unit cost of the consumed source layers (set at dispatch).</summary>
|
||||
public decimal? UnitCost { get; set; }
|
||||
|
||||
/// <summary>Quantity already received at the destination (partial-receive support).</summary>
|
||||
public decimal QtyReceived { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Unit of Measure (FR-MD-02). Referenced as an item's base UOM and as the
|
||||
/// endpoints of a <see cref="UomConversion"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Uom
|
||||
{
|
||||
public int UomId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-item conversion factor between two UOMs (FR-MD-02/03): quantity in
|
||||
/// <see cref="FromUomId"/> × <see cref="Factor"/> = quantity in <see cref="ToUomId"/>.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class UomConversion
|
||||
{
|
||||
public int ConversionId { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public int FromUomId { get; set; }
|
||||
public Uom? FromUom { get; set; }
|
||||
|
||||
public int ToUomId { get; set; }
|
||||
public Uom? ToUom { get; set; }
|
||||
|
||||
public decimal Factor { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Application user (FR-X-01) — a **local shadow/projection** of an AuthHex identity.
|
||||
/// The local <see cref="UserId"/> (int) is what every `createdBy`/`requestedBy`/
|
||||
/// audit/ledger FK references; <see cref="AuthUserId"/> maps it to the AuthHex
|
||||
/// <c>UserId</c> (GUID) and is JIT-provisioned on first authenticated request
|
||||
/// (docs/10 A.4/C.7). A seeded <c>system</c> user (id 1, null AuthUserId) is the
|
||||
/// fallback actor for unauthenticated/system operations. Model: docs/10 Part C.7.
|
||||
/// </summary>
|
||||
public class User
|
||||
{
|
||||
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
|
||||
public const int SystemUserId = 1;
|
||||
|
||||
public int UserId { get; set; }
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
/// <summary>AuthHex identity (token <c>UserId</c> GUID); null for the seeded system user.</summary>
|
||||
public Guid? AuthUserId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Vendor master (FR-MD-06). 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 Vendor
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Terms { get; set; }
|
||||
public string? TaxReg { get; set; }
|
||||
public string Currency { get; set; } = "LKR";
|
||||
public EntityStatus Status { get; set; } = EntityStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
|
||||
public uint RowVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A vendor's quotation against an RFQ (FR-PROC-02). Per-item pricing lives in
|
||||
/// <see cref="Lines"/>.
|
||||
/// <para>
|
||||
/// Deviation note: docs/10 Part C.2 models <c>VENDOR_QUOTATION</c> with scalar
|
||||
/// <c>unit_price</c>/<c>lead_days</c> and no item reference, which cannot represent
|
||||
/// the per-line pricing the API contract requires (docs/11 §3.2). This header +
|
||||
/// <see cref="VendorQuotationLine"/> split follows the authoritative API shape.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class VendorQuotation
|
||||
{
|
||||
public int QuotationId { get; set; }
|
||||
|
||||
public int RfqId { get; set; }
|
||||
public Rfq? Rfq { get; set; }
|
||||
|
||||
public int VendorId { get; set; }
|
||||
public Vendor? Vendor { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public ICollection<VendorQuotationLine> Lines { get; set; } = new List<VendorQuotationLine>();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>Per-item quoted price and lead time within a <see cref="VendorQuotation"/> (docs/11 §3.2).</summary>
|
||||
public class VendorQuotationLine
|
||||
{
|
||||
public int QuotationLineId { get; set; }
|
||||
|
||||
public int QuotationId { get; set; }
|
||||
public VendorQuotation? Quotation { get; set; }
|
||||
|
||||
public int ItemId { get; set; }
|
||||
public Item? Item { get; set; }
|
||||
|
||||
public decimal UnitPrice { get; set; }
|
||||
public int LeadDays { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Warehouse master (FR-MD-07, FR-WH-01). Owns a bin/location hierarchy.
|
||||
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
|
||||
/// </summary>
|
||||
public class Warehouse
|
||||
{
|
||||
public int WarehouseId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public ICollection<Bin> Bins { get; set; } = new List<Bin>();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Stock-adjustment lifecycle (docs/10 §B.8.1). Phase 1 auto-posts, so
|
||||
/// <see cref="PendingApproval"/> is reserved for the future threshold-approval
|
||||
/// workflow (FR-STK-07). Stored as a string.
|
||||
/// </summary>
|
||||
public enum AdjustmentStatus
|
||||
{
|
||||
Draft,
|
||||
PendingApproval,
|
||||
Posted
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Kind of mutation recorded in the audit trail (FR-X-02). Stored as a string.</summary>
|
||||
public enum AuditAction
|
||||
{
|
||||
Create,
|
||||
Update,
|
||||
Delete
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Stock-count lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
|
||||
public enum CountStatus
|
||||
{
|
||||
Draft,
|
||||
Counted,
|
||||
Posted
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Physical-count scope (docs/11 §8; FR-STK-08). Stored as a string.</summary>
|
||||
public enum CountType
|
||||
{
|
||||
Cycle,
|
||||
Full
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Stock-ledger movement direction (docs/11 §8). Stored as a string.</summary>
|
||||
public enum Direction
|
||||
{
|
||||
In,
|
||||
Out
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status for deactivatable master data (Item, Vendor). Masters are
|
||||
/// never hard-deleted while referenced — they are set <see cref="Inactive"/>
|
||||
/// instead (FR-MD-08). Stored as a string.
|
||||
/// </summary>
|
||||
public enum EntityStatus
|
||||
{
|
||||
Active,
|
||||
Inactive
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Goods-receipt-note lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
|
||||
public enum GrnStatus
|
||||
{
|
||||
Draft,
|
||||
Confirmed,
|
||||
Closed
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Inspection-hold state of received stock (docs/11 §8; FR-GRN-05). <see cref="OnHold"/>
|
||||
/// stock is on-hand but not issuable until released (FR-WH-07). Stored as a string.
|
||||
/// </summary>
|
||||
public enum HoldStatus
|
||||
{
|
||||
Available,
|
||||
OnHold,
|
||||
Rejected
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Item classification (FR-MD-01). Values match the <c>itemType</c> enum in
|
||||
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
|
||||
/// </summary>
|
||||
public enum ItemType
|
||||
{
|
||||
Stocked,
|
||||
NonStocked,
|
||||
Service
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase-order lifecycle (docs/11 §8; docs/10 §B.8.1). Phase 1 auto-approves on
|
||||
/// creation, so <see cref="PendingApproval"/> is reserved (not entered) until the
|
||||
/// approval workflow is enabled (FR-PROC-04). Stored as a string.
|
||||
/// </summary>
|
||||
public enum PurchaseOrderStatus
|
||||
{
|
||||
Draft,
|
||||
PendingApproval,
|
||||
Approved,
|
||||
PartiallyReceived,
|
||||
FullyReceived,
|
||||
Closed,
|
||||
Cancelled
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Where a reason code applies (FR-X-04; docs/10 §B.8.3). Stored as a string.</summary>
|
||||
public enum ReasonContext
|
||||
{
|
||||
Adjustment,
|
||||
Return,
|
||||
Count
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Purchase-requisition lifecycle (docs/11 §3.1; docs/10 §B.8.1). Stored as a string.</summary>
|
||||
public enum RequisitionStatus
|
||||
{
|
||||
Draft,
|
||||
Submitted
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Purchase-return lifecycle (docs/11 §3.4). Auto-posts in Phase 1. Stored as a string.</summary>
|
||||
public enum ReturnStatus
|
||||
{
|
||||
Draft,
|
||||
Posted
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>RFQ lifecycle (docs/11 §3.2). Stored as a string.</summary>
|
||||
public enum RfqStatus
|
||||
{
|
||||
Open,
|
||||
Closed
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// How on-hand units of an item are individually tracked (FR-MD-01). Values match
|
||||
/// the <c>trackingMode</c> enum in docs/11-BACKEND-PHASE1.md §8. Stored as a string.
|
||||
/// </summary>
|
||||
public enum TrackingMode
|
||||
{
|
||||
None,
|
||||
Batch,
|
||||
Serial
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ERPCore.Domain.Enums;
|
||||
|
||||
/// <summary>Stock-transfer lifecycle (docs/11 §8; docs/10 §B.8.1). Stored as a string.</summary>
|
||||
public enum TransferStatus
|
||||
{
|
||||
Draft,
|
||||
InTransit,
|
||||
Received,
|
||||
Closed
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Audit;
|
||||
|
||||
/// <summary>An audit-trail entry (FR-X-02). <c>ChangeSet</c> is the stored JSON, inlined.</summary>
|
||||
public sealed record AuditLogDto(
|
||||
int AuditId, int UserId, string EntityType, int EntityId, AuditAction Action, JsonElement ChangeSet, DateTime CreatedAt);
|
||||
|
||||
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
|
||||
public sealed record JournalEntryStubDto(
|
||||
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
public sealed class IsAvailableRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public string? Recovery { get; set; }
|
||||
}
|
||||
|
||||
public sealed class IsAvailableResponse
|
||||
{
|
||||
public bool? IsAvailable { get; set; }
|
||||
public string? Message { get; set; }
|
||||
/// <summary>Passed through as-is when `Recovery` matched existing users — shape isn't in the documented catalog.</summary>
|
||||
public JsonElement? ExistingUsers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SendOtpRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public int NumberOfDigits { get; set; } = 6;
|
||||
public bool NewUser { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SendOtpResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? RecoveryType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyAltOtpRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
public string? Identifier { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool NewUser { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
public sealed class ForgotPasswordRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
public bool UseResetLink { get; set; }
|
||||
public int NumberOfDigits { get; set; } = 6;
|
||||
public bool Welcome { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForgotPasswordResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? RecoveryType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyRecoveryOtpRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyRecoveryOtpResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
[Required] public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class ResetPasswordWithTokenRequest
|
||||
{
|
||||
[Required] public string ResetToken { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
[Required] public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ERPCore.Dtos.Auth;
|
||||
|
||||
/// <summary>Shared AuthHex user projection (API_REFERENCE.md §3). Field set is
|
||||
/// AuthHex's best-documented subset; unknown fields are ignored on deserialize.</summary>
|
||||
public sealed class UserSummaryDto
|
||||
{
|
||||
public Guid? UserId { get; set; }
|
||||
public Guid? RoleId { get; set; }
|
||||
public Guid? UserTypeId { get; set; }
|
||||
public string? Fullname { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public bool? EmailVerified { get; set; }
|
||||
public bool? MobileNumberVerified { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
public bool? IsLocked { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Body returned by every session-issuing endpoint. Tokens never appear
|
||||
/// here — they are delivered only as httpOnly cookies (docs/02-SECURITY.md §B.2).</summary>
|
||||
public sealed class AuthSessionResponse
|
||||
{
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterRequest
|
||||
{
|
||||
/// <summary>Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.</summary>
|
||||
public Guid? UserId { get; set; }
|
||||
[Required] public Guid RoleId { get; set; }
|
||||
[Required] public Guid UserTypeId { get; set; }
|
||||
public string? Fullname { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public bool? ChkUser { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
[Required] public string Identifier { get; set; } = string.Empty;
|
||||
[Required] public string Password { get; set; } = string.Empty;
|
||||
public Guid? UserTypeId { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyOtpForLoginRequest
|
||||
{
|
||||
[Required] public string ReferenceNumber { get; set; } = string.Empty;
|
||||
[Required] public string OtpCode { get; set; } = string.Empty;
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OtpLoginVerifiedResponse
|
||||
{
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool Verified { get; set; }
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RefreshTokenRequest
|
||||
{
|
||||
public string? DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GetUserDetailsResponse
|
||||
{
|
||||
public UserSummaryDto? User { get; set; }
|
||||
/// <summary>Passed through as-is — AuthHex's Role/UserType shapes aren't in the documented catalog.</summary>
|
||||
public JsonElement? Role { get; set; }
|
||||
public JsonElement? UserType { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SessionDto
|
||||
{
|
||||
public string? SessionId { get; set; }
|
||||
public string? DeviceName { get; set; }
|
||||
public string? Browser { get; set; }
|
||||
public string? OS { get; set; }
|
||||
public string? IPAddress { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
public bool? IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChangeUserStatusRequest
|
||||
{
|
||||
[Required] public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LockUserAccountRequest
|
||||
{
|
||||
[Required] public bool IsLocked { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChangeUserPasswordRequest
|
||||
{
|
||||
[Required] public string CurrentPassword { get; set; } = string.Empty;
|
||||
[Required, MinLength(8)] public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyPasswordRequest
|
||||
{
|
||||
[Required] public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class VerifyPasswordResponse
|
||||
{
|
||||
public bool Valid { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUserRequest
|
||||
{
|
||||
public string? FullName { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string? Nic { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? Optional1 { get; set; }
|
||||
public string? Optional2 { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? MobileNumber { get; set; }
|
||||
public string? NewPassword { get; set; }
|
||||
public string? CurrentPassword { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Passthrough — TOTP secret/QR payload shape is only loosely documented
|
||||
/// ("secret key, QR/otpauth URL ... from the third-party service").</summary>
|
||||
public sealed class TwoFaSetupResponse
|
||||
{
|
||||
public JsonElement Data { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CompleteTwoFaSetupRequest
|
||||
{
|
||||
[Required] public string SecretKey { get; set; } = string.Empty;
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CompleteTwoFaSetupResponse
|
||||
{
|
||||
public List<string> BackupCodes { get; set; } = new();
|
||||
public UserSummaryDto? User { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VerifyTwoFaRequest
|
||||
{
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class DisableTwoFaRequest
|
||||
{
|
||||
[Required] public string VerificationCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class TwoFaStatusResponse
|
||||
{
|
||||
public bool IsMfaEnabled { get; set; }
|
||||
public bool IsVerified { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
public bool HasBackupCodes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LogoutRequest
|
||||
{
|
||||
[Required] public Guid UserId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Categories;
|
||||
|
||||
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
|
||||
public sealed record CategoryDto(int CategoryId, string Name, int? ParentId);
|
||||
|
||||
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
|
||||
public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList<CategoryTreeDto> Children);
|
||||
|
||||
public sealed class CreateCategoryRequest
|
||||
{
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
public int? ParentId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ERPCore.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Shared paging/sorting query binding (docs/11-BACKEND-PHASE1.md §1.5). Page size
|
||||
/// is clamped to <see cref="MaxPageSize"/> to enforce pagination bounds
|
||||
/// (02-SECURITY B.6). Bind from the query string on list endpoints.
|
||||
/// </summary>
|
||||
public class PageQuery
|
||||
{
|
||||
public const int MaxPageSize = 200;
|
||||
public const int DefaultPageSize = 20;
|
||||
|
||||
private int _page = 1;
|
||||
private int _pageSize = DefaultPageSize;
|
||||
|
||||
/// <summary>1-based page number (default 1).</summary>
|
||||
public int Page
|
||||
{
|
||||
get => _page;
|
||||
set => _page = value < 1 ? 1 : value;
|
||||
}
|
||||
|
||||
/// <summary>Page size (default 20, clamped to 1..200).</summary>
|
||||
public int PageSize
|
||||
{
|
||||
get => _pageSize;
|
||||
set => _pageSize = value < 1 ? DefaultPageSize : Math.Min(value, MaxPageSize);
|
||||
}
|
||||
|
||||
/// <summary>Free-text search term (<c>q</c>).</summary>
|
||||
public string? Q { get; set; }
|
||||
|
||||
/// <summary>Sort spec, e.g. <c>name</c> or <c>-createdAt</c>.</summary>
|
||||
public string? Sort { get; set; }
|
||||
|
||||
public int Skip => (Page - 1) * PageSize;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ERPCore.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// List envelope matching docs/11-BACKEND-PHASE1.md §1.4:
|
||||
/// <c>{ "items": [...], "pagination": { page, pageSize, totalItems, totalPages } }</c>.
|
||||
/// </summary>
|
||||
public sealed record PagedResponse<T>(IReadOnlyList<T> Items, PaginationDto Pagination)
|
||||
{
|
||||
public static PagedResponse<T> Create(IReadOnlyList<T> items, int page, int pageSize, int totalItems)
|
||||
{
|
||||
var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(totalItems / (double)pageSize);
|
||||
return new PagedResponse<T>(items, new PaginationDto(page, pageSize, totalItems, totalPages));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pagination metadata block (docs/11 §1.4).</summary>
|
||||
public sealed record PaginationDto(int Page, int PageSize, int TotalItems, int TotalPages);
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Grn;
|
||||
|
||||
// Responses (docs/11 §4) --------------------------------------------------------
|
||||
|
||||
public sealed record GrnLineDto(
|
||||
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
|
||||
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
|
||||
|
||||
public sealed record GrnDto(
|
||||
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
|
||||
|
||||
public sealed record CreatedLayerDto(
|
||||
int LayerId, int ItemId, int WarehouseId, int? BatchId,
|
||||
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
|
||||
|
||||
public sealed record GrnConfirmResultDto(
|
||||
int GrnId, GrnStatus Status, DateTime PostedAt,
|
||||
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
|
||||
|
||||
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class BatchInput
|
||||
{
|
||||
[Required, StringLength(50)] public string BatchNo { get; set; } = string.Empty;
|
||||
public DateOnly? ExpiryDate { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateGrnLineInput
|
||||
{
|
||||
/// <summary>Set for a PO-based receipt; cost is then derived from the PO line (02-SECURITY C.3).</summary>
|
||||
public int? PoLineId { get; set; }
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
public int? BinId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
/// <summary>Used only for direct (no-PO) receipts; ignored when <see cref="PoLineId"/> is set.</summary>
|
||||
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
|
||||
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
|
||||
public BatchInput? Batch { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateGrnRequest
|
||||
{
|
||||
/// <summary>PO to receive against; null for a direct/emergency receipt (FR-GRN-02).</summary>
|
||||
public int? PoId { get; set; }
|
||||
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
|
||||
public int? VendorId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReleaseLineRequest
|
||||
{
|
||||
/// <summary><c>Release</c> makes the stock available; <c>Reject</c> removes it from on-hand.</summary>
|
||||
[Required, RegularExpression("Release|Reject")]
|
||||
public string Action { get; set; } = "Release";
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Items;
|
||||
|
||||
// Response DTOs (docs/11-BACKEND-PHASE1.md §2.1) --------------------------------
|
||||
|
||||
/// <summary>Row shape for <c>GET /items</c>.</summary>
|
||||
public sealed record ItemListItemDto(
|
||||
int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
|
||||
int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
|
||||
string? TaxClass, EntityStatus Status);
|
||||
|
||||
/// <summary>A single per-warehouse reorder policy row.</summary>
|
||||
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
|
||||
|
||||
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
|
||||
public sealed record ItemDetailDto(
|
||||
int ItemId, string Sku, string Name, string? Description, int CategoryId,
|
||||
int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
|
||||
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
|
||||
public sealed record UomConversionDto(int ConversionId, int FromUom, int ToUom, decimal Factor);
|
||||
|
||||
/// <summary>Response body for <c>PUT /items/{id}/uom-conversions</c>.</summary>
|
||||
public sealed record ItemUomConversionsDto(int ItemId, int BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
|
||||
|
||||
/// <summary>Response body for <c>PUT /items/{id}/reorder</c>.</summary>
|
||||
public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settings);
|
||||
|
||||
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
|
||||
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
|
||||
|
||||
public sealed class CreateItemRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(1000)] public string? Description { get; set; }
|
||||
[Required] public int CategoryId { get; set; }
|
||||
[Required] public int BaseUomId { get; set; }
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Sku { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(1000)] public string? Description { get; set; }
|
||||
[Required] public int CategoryId { get; set; }
|
||||
[Required] public int BaseUomId { get; set; }
|
||||
public int? DefaultVendorId { get; set; }
|
||||
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
|
||||
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
|
||||
[StringLength(20)] public string? TaxClass { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateItemStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReorderSettingInput
|
||||
{
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal ReorderPoint { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal ReorderQty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateReorderRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<ReorderSettingInput> Settings { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UomConversionInput
|
||||
{
|
||||
[Required] public int FromUom { get; set; }
|
||||
[Required] public int ToUom { get; set; }
|
||||
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUomConversionsRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<UomConversionInput> Conversions { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
// Responses (docs/11 §3.3) ------------------------------------------------------
|
||||
|
||||
public sealed record PoLineDto(
|
||||
int PoLineId, int ItemId, int UomId, int WarehouseId,
|
||||
decimal Qty, decimal UnitPrice, decimal Tax, decimal QtyReceived);
|
||||
|
||||
public sealed record PoTotalsDto(decimal SubTotal, decimal Tax, decimal GrandTotal, string Currency);
|
||||
|
||||
public sealed record PurchaseOrderDto(
|
||||
int PoId, string DocNo, int VendorId, int? RequisitionId, PurchaseOrderStatus Status,
|
||||
bool ApprovalRequired, int CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
|
||||
PoTotalsDto Totals, IReadOnlyList<PoLineDto> Lines);
|
||||
|
||||
public sealed record PurchaseOrderSummaryDto(
|
||||
int PoId, string DocNo, int VendorId, PurchaseOrderStatus Status,
|
||||
bool ApprovalRequired, DateTime CreatedAt, PoTotalsDto Totals);
|
||||
|
||||
// Requests — server sets docNo, status, createdBy, timestamps, qtyReceived, totals
|
||||
|
||||
public sealed class CreatePoLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Required] public int UomId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
||||
[Range(0, 1)] public decimal Tax { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreatePurchaseOrderRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
public int? RequisitionId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class UpdatePurchaseOrderRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
public int? RequisitionId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class CancelPurchaseOrderRequest
|
||||
{
|
||||
[StringLength(500)] public string? Reason { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
// Responses (docs/11 §3.4) ------------------------------------------------------
|
||||
|
||||
public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int ItemId, decimal Qty);
|
||||
|
||||
public sealed record PurchaseReturnDto(
|
||||
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
|
||||
int CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreatePurchaseReturnLineInput
|
||||
{
|
||||
/// <summary>Original GRN line, for traceability against the receipt.</summary>
|
||||
public int? GrnLineId { get; set; }
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreatePurchaseReturnRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error.</summary>
|
||||
public int? ReasonCodeId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreatePurchaseReturnLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
// Responses (docs/11 §3.1) ------------------------------------------------------
|
||||
|
||||
public sealed record RequisitionLineDto(int ReqLineId, int ItemId, decimal Qty, DateOnly? RequiredBy);
|
||||
|
||||
public sealed record RequisitionDto(
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy,
|
||||
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
|
||||
|
||||
public sealed record RequisitionSummaryDto(
|
||||
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
|
||||
|
||||
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
|
||||
|
||||
public sealed class CreateRequisitionLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
public DateOnly? RequiredBy { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateRequisitionRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<CreateRequisitionLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Procurement;
|
||||
|
||||
// Responses (docs/11 §3.2) ------------------------------------------------------
|
||||
|
||||
public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
|
||||
|
||||
public sealed record RfqDto(
|
||||
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
|
||||
|
||||
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
|
||||
|
||||
public sealed record VendorQuotationDto(
|
||||
int QuotationId, int RfqId, int VendorId, IReadOnlyList<QuotationLineDto> Lines);
|
||||
|
||||
/// <summary>Per-item, per-vendor price matrix for <c>GET /rfqs/{id}/comparison</c>.</summary>
|
||||
public sealed record RfqComparisonCellDto(int VendorId, int QuotationId, decimal UnitPrice, int LeadDays);
|
||||
public sealed record RfqComparisonRowDto(int ItemId, decimal Qty, IReadOnlyList<RfqComparisonCellDto> Quotes);
|
||||
public sealed record RfqComparisonDto(int RfqId, IReadOnlyList<int> VendorIds, IReadOnlyList<RfqComparisonRowDto> Rows);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateRfqLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateRfqRequest
|
||||
{
|
||||
[Required] public int RequisitionId { get; set; }
|
||||
/// <summary>Vendors the RFQ is issued to (validated for existence; quotations reference them).</summary>
|
||||
public List<int> VendorIds { get; set; } = new();
|
||||
[Required, MinLength(1)] public List<CreateRfqLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class CreateQuotationLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal UnitPrice { get; set; }
|
||||
[Range(0, int.MaxValue)] public int LeadDays { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateQuotationRequest
|
||||
{
|
||||
[Required] public int VendorId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateQuotationLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Reference;
|
||||
|
||||
/// <summary>Reason code (docs/11 §6).</summary>
|
||||
public sealed record ReasonCodeDto(int ReasonCodeId, string Code, string Description, ReasonContext Context);
|
||||
|
||||
public sealed class CreateReasonCodeRequest
|
||||
{
|
||||
[Required, StringLength(20)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Description { get; set; } = string.Empty;
|
||||
[Required, EnumDataType(typeof(ReasonContext))] public ReasonContext Context { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Responses (docs/11 §5.5) ------------------------------------------------------
|
||||
|
||||
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
|
||||
|
||||
public sealed record AdjustmentDto(
|
||||
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
|
||||
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateAdjustmentLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
public int? BinId { get; set; }
|
||||
public int? BatchId { get; set; }
|
||||
/// <summary>Signed base-UOM delta: negative consumes FIFO layers, positive adds stock.</summary>
|
||||
public decimal QtyDelta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateAdjustmentRequest
|
||||
{
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
/// <summary>Nullable so an omitted value is a distinct <c>REASON_CODE_REQUIRED</c> error, not 0.</summary>
|
||||
public int? ReasonCodeId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Responses (docs/11 §5.6) ------------------------------------------------------
|
||||
|
||||
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
|
||||
|
||||
public sealed record CountDto(
|
||||
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
|
||||
|
||||
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateCountRequest
|
||||
{
|
||||
[Required] public int WarehouseId { get; set; }
|
||||
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
|
||||
[Required, MinLength(1)] public List<int> ItemIds { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class EnterCountLineInput
|
||||
{
|
||||
[Required] public int CountLineId { get; set; }
|
||||
[Range(0, double.MaxValue)] public decimal CountedQty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EnterCountsRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<EnterCountLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
/// <summary>An item at/below its reorder point (docs/11 §5.7; FR-STK-10). Computed on read.</summary>
|
||||
public sealed record ReorderAlertDto(
|
||||
int ItemId, int WarehouseId, decimal Available,
|
||||
decimal ReorderPoint, decimal ReorderQty, decimal SuggestedRequisitionQty);
|
||||
@@ -0,0 +1,22 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand − onHold − reserved − inTransit(out).</summary>
|
||||
public sealed record StockOnHandDto(
|
||||
int ItemId, int WarehouseId, decimal OnHand, decimal Available,
|
||||
decimal OnHold, decimal InTransit, decimal Reserved, DateTime AsOf);
|
||||
|
||||
/// <summary>A stock-ledger row (docs/11 §5.2).</summary>
|
||||
public sealed record StockLedgerRowDto(
|
||||
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
|
||||
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
|
||||
string SourceDocType, int SourceDocId, int UserId, DateTime CreatedAt);
|
||||
|
||||
/// <summary>An open FIFO layer in a valuation (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationLayerDto(int LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
|
||||
|
||||
/// <summary>Valuation of on-hand stock from open FIFO layers (docs/11 §5.3).</summary>
|
||||
public sealed record StockValuationDto(
|
||||
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
|
||||
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Stock;
|
||||
|
||||
// Responses (docs/11 §5.4) ------------------------------------------------------
|
||||
|
||||
public sealed record TransferLineDto(
|
||||
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
|
||||
|
||||
public sealed record TransferDto(
|
||||
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
|
||||
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
|
||||
|
||||
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
|
||||
|
||||
public sealed record DispatchResultDto(
|
||||
int TransferId, TransferStatus Status, IReadOnlyList<ConsumedLayerDto> ConsumedLayers, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
public sealed record TransferCreatedLayerDto(int LayerId, int WarehouseId, decimal QtyReceived, decimal UnitCost);
|
||||
|
||||
public sealed record ReceiveResultDto(
|
||||
int TransferId, TransferStatus Status, IReadOnlyList<TransferCreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs);
|
||||
|
||||
// Requests ----------------------------------------------------------------------
|
||||
|
||||
public sealed class CreateTransferLineInput
|
||||
{
|
||||
[Required] public int ItemId { get; set; }
|
||||
public int? SrcBinId { get; set; }
|
||||
public int? DestBinId { get; set; }
|
||||
public int? BatchId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateTransferRequest
|
||||
{
|
||||
[Required] public int SrcWarehouseId { get; set; }
|
||||
[Required] public int DestWarehouseId { get; set; }
|
||||
[Required, MinLength(1)] public List<CreateTransferLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ReceiveTransferLineInput
|
||||
{
|
||||
[Required] public int TransferLineId { get; set; }
|
||||
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReceiveTransferRequest
|
||||
{
|
||||
[Required, MinLength(1)] public List<ReceiveTransferLineInput> Lines { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Uoms;
|
||||
|
||||
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
|
||||
public sealed record UomDto(int UomId, string Name);
|
||||
|
||||
public sealed class CreateUomRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ERPCore.Domain.Enums;
|
||||
|
||||
namespace ERPCore.Dtos.Vendors;
|
||||
|
||||
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
|
||||
public sealed record VendorDto(
|
||||
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
|
||||
string Currency, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
|
||||
|
||||
public sealed class CreateVendorRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(50)] public string? Terms { get; set; }
|
||||
[StringLength(50)] public string? TaxReg { get; set; }
|
||||
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
|
||||
}
|
||||
|
||||
public sealed class UpdateVendorRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(50)] public string? Terms { get; set; }
|
||||
[StringLength(50)] public string? TaxReg { get; set; }
|
||||
[Required, StringLength(3, MinimumLength = 3)] public string Currency { get; set; } = "LKR";
|
||||
}
|
||||
|
||||
public sealed class UpdateVendorStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ERPCore.Dtos.Warehouses;
|
||||
|
||||
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
||||
public sealed record WarehouseDto(int WarehouseId, string Code, string Name);
|
||||
|
||||
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
|
||||
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
|
||||
|
||||
public sealed class CreateWarehouseRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CreateBinRequest
|
||||
{
|
||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||
[StringLength(50)] public string? BinType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Writes/clears the httpOnly session cookies + non-httpOnly CSRF cookie
|
||||
/// AuthController issues on every session-establishing call (docs/02-SECURITY.md
|
||||
/// §B.2). SameSite=Strict assumes frontend and ERPCore share a registrable
|
||||
/// domain (e.g. both on `localhost`, different ports) — a cross-domain
|
||||
/// deployment would need SameSite=None (+ Secure, which is already set).
|
||||
/// </summary>
|
||||
public static class AuthCookieWriter
|
||||
{
|
||||
public static void WriteSession(HttpResponse response, string accessToken, string refreshToken, int expiresInSeconds)
|
||||
{
|
||||
response.Cookies.Append(JwtAuthExtensions.AccessTokenCookie, accessToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromSeconds(expiresInSeconds)
|
||||
});
|
||||
|
||||
response.Cookies.Append(JwtAuthExtensions.RefreshTokenCookie, refreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/api/v1/auth/refresh-token",
|
||||
MaxAge = TimeSpan.FromDays(30)
|
||||
});
|
||||
|
||||
response.Cookies.Append(JwtAuthExtensions.CsrfCookie, GenerateCsrfToken(), new CookieOptions
|
||||
{
|
||||
HttpOnly = false,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
Path = "/",
|
||||
MaxAge = TimeSpan.FromSeconds(expiresInSeconds)
|
||||
});
|
||||
}
|
||||
|
||||
public static void ClearSession(HttpResponse response)
|
||||
{
|
||||
response.Cookies.Delete(JwtAuthExtensions.AccessTokenCookie, new CookieOptions { Path = "/" });
|
||||
response.Cookies.Delete(JwtAuthExtensions.RefreshTokenCookie, new CookieOptions { Path = "/api/v1/auth/refresh-token" });
|
||||
response.Cookies.Delete(JwtAuthExtensions.CsrfCookie, new CookieOptions { Path = "/" });
|
||||
}
|
||||
|
||||
private static string GenerateCsrfToken() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ERPCore.Dtos.Auth;
|
||||
using ERPCore.System.Errors;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP implementation of <see cref="IAuthHexClient"/>. Registered as a typed
|
||||
/// client (`AddHttpClient<IAuthHexClient, AuthHexClient>`) with its
|
||||
/// `BaseAddress` bound from `AuthHex:BaseUrl`. Every call POSTs AuthHex's
|
||||
/// `{ functionName, payload, reference }` envelope to the matching manager
|
||||
/// route and unwraps the `{ statusCode, success, message, data }` response,
|
||||
/// translating upstream failures into <see cref="DomainException"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthHexClient : IAuthHexClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public AuthHexClient(HttpClient http) => _http = http;
|
||||
|
||||
// ---- UserManager --------------------------------------------------
|
||||
|
||||
public Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "registerUser", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "loginUser", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "VerifyOtpForLogin", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("user", "refreshToken", new { refreshToken, deviceName }, null, ct);
|
||||
|
||||
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct)
|
||||
=> CallAsync<GetUserDetailsResponse>("user", "getUserDetails", new { userId }, null, ct);
|
||||
|
||||
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<List<SessionDto>>("user", "getUserSessions", new { }, bearerToken, ct);
|
||||
|
||||
public Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "ChangeUserStatus", new { isActive }, bearerToken, ct);
|
||||
|
||||
public Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "LockUserAccount", new { isLocked }, bearerToken, ct);
|
||||
|
||||
public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "ChangeUserPassword", request, bearerToken, ct);
|
||||
|
||||
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<VerifyPasswordResponse>("user", "VerifyPassword", request, bearerToken, ct);
|
||||
|
||||
public Task LogoutUserAsync(Guid userId, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "LogoutUser", new { userId }, null, ct);
|
||||
|
||||
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<UserSummaryDto?>("user", "UpdateUser", request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<TwoFaSetupResponse>("user", "initiateTwoFASetup", new { }, bearerToken, ct);
|
||||
|
||||
public Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<CompleteTwoFaSetupResponse>("user", "completeTwoFASetup", request, bearerToken, ct);
|
||||
|
||||
public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "verifyTwoFA", request, bearerToken, ct);
|
||||
|
||||
public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct)
|
||||
=> CallVoidAsync("user", "disableTwoFA", request, bearerToken, ct);
|
||||
|
||||
public Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct)
|
||||
=> CallAsync<TwoFaStatusResponse>("user", "getTwoFAStatus", new { }, bearerToken, ct);
|
||||
|
||||
// ---- RecoveryManager ------------------------------------------------
|
||||
|
||||
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
|
||||
=> CallAsync<ForgotPasswordResponse>("recovery", "forgotPassword", request, null, ct);
|
||||
|
||||
public Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<VerifyRecoveryOtpResponse>("recovery", "verifyOTP", request, null, ct);
|
||||
|
||||
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct)
|
||||
=> CallVoidAsync("recovery", "resetPassword", request, null, ct);
|
||||
|
||||
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct)
|
||||
=> CallVoidAsync("recovery", "resetPasswordWithToken", request, null, ct);
|
||||
|
||||
// ---- AltOptionManager -------------------------------------------------
|
||||
|
||||
public Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct)
|
||||
=> CallAsync<IsAvailableResponse>("alt", "IsAvailable", request, null, ct);
|
||||
|
||||
public Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<SendOtpResponse>("alt", "sendOtp", request, null, ct);
|
||||
|
||||
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
|
||||
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
|
||||
|
||||
// ---- Transport --------------------------------------------------------
|
||||
|
||||
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||
=> await CallAsync<JsonElement?>(routeGroup, functionName, payload, bearerToken, ct);
|
||||
|
||||
private async Task<T> CallAsync<T>(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
|
||||
{
|
||||
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/{routeGroup}");
|
||||
if (!string.IsNullOrEmpty(bearerToken))
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||
|
||||
httpRequest.Content = JsonContent.Create(
|
||||
new AuthHexRequestBody { FunctionName = functionName, Payload = payload, Reference = string.Empty },
|
||||
options: JsonOptions);
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _http.SendAsync(httpRequest, ct);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
|
||||
}
|
||||
|
||||
AuthHexEnvelope<T>? envelope;
|
||||
try
|
||||
{
|
||||
envelope = await httpResponse.Content.ReadFromJsonAsync<AuthHexEnvelope<T>>(JsonOptions, ct);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an unreadable response.", 502);
|
||||
}
|
||||
|
||||
if (envelope is null)
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service returned an empty response.", 502);
|
||||
|
||||
// Trust the outer HTTP status over envelope.Success — AuthHex has been observed
|
||||
// returning HTTP 500 with `success:true, data:null` on business failures (e.g.
|
||||
// invalid credentials), which would otherwise slip through as a "success" and
|
||||
// hand a null payload to the caller.
|
||||
if (!httpResponse.IsSuccessStatusCode || !envelope.Success)
|
||||
{
|
||||
var statusCode = !httpResponse.IsSuccessStatusCode
|
||||
? (int)httpResponse.StatusCode
|
||||
: envelope.StatusCode is >= 400 and < 600 ? envelope.StatusCode : 400;
|
||||
throw new DomainException(ErrorCodes.AuthUpstreamError, envelope.Message ?? "Authentication request failed.", statusCode);
|
||||
}
|
||||
|
||||
return envelope.Data!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1).</summary>
|
||||
public sealed class AuthHexEnvelope<T>
|
||||
{
|
||||
public int StatusCode { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? Message { get; set; }
|
||||
public T? Data { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1).</summary>
|
||||
public sealed class AuthHexRequestBody
|
||||
{
|
||||
public string FunctionName { get; set; } = string.Empty;
|
||||
public object Payload { get; set; } = new { };
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape shared by every session-issuing AuthHex function (register, login,
|
||||
/// OTP login verify, refresh, alt OTP verify). Carries the raw tokens — kept
|
||||
/// internal so they never leak into a public Dtos/Auth response; AuthController
|
||||
/// extracts them into httpOnly cookies and returns only <see cref="AuthSessionResponse"/>.
|
||||
/// </summary>
|
||||
public sealed class AuthHexSessionResult
|
||||
{
|
||||
public string? AccessToken { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public int ExpiresIn { get; set; }
|
||||
public UserSummaryDto? User { get; set; }
|
||||
public string? ReferenceNumber { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public bool? Verified { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ERPCore.Dtos.Auth;
|
||||
|
||||
namespace ERPCore.Infra.Auth.AuthHex;
|
||||
|
||||
/// <summary>
|
||||
/// Typed client for the external AuthHex identity service (API_REFERENCE.md).
|
||||
/// Hides AuthHex's `functionName` dispatcher entirely — callers get one method
|
||||
/// per function. Internal: only Services/Auth consumes this; the controller
|
||||
/// boundary only ever sees Dtos/Auth types.
|
||||
/// </summary>
|
||||
public interface IAuthHexClient
|
||||
{
|
||||
// UserManager (POST /api/user)
|
||||
Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
|
||||
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct);
|
||||
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct);
|
||||
Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct);
|
||||
Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct);
|
||||
Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct);
|
||||
Task LogoutUserAsync(Guid userId, CancellationToken ct);
|
||||
Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct);
|
||||
Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct);
|
||||
Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct);
|
||||
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct);
|
||||
Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct);
|
||||
|
||||
// RecoveryManager (POST /api/recovery)
|
||||
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
|
||||
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
|
||||
Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct);
|
||||
Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct);
|
||||
|
||||
// AltOptionManager (POST /api/alt)
|
||||
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
|
||||
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
|
||||
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Claim type names emitted by the AuthHex IdP (see its <c>JwtTokenHelper</c>).
|
||||
/// AuthHex uses no standard <c>sub</c>/<c>nameid</c>; identity is the custom
|
||||
/// <see cref="UserId"/> (GUID). These are read verbatim (JWT bearer is configured
|
||||
/// with <c>MapInboundClaims = false</c>).
|
||||
/// </summary>
|
||||
public static class AuthHexClaims
|
||||
{
|
||||
public const string UserId = "UserId";
|
||||
public const string UserTypeId = "UserTypeId";
|
||||
public const string UserTypeCode = "UserTypeCode";
|
||||
public const string RoleId = "RoleId";
|
||||
public const string RoleCode = "RoleCode";
|
||||
public const string Nic = "NIC";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user