Compare commits

...

19 Commits

Author SHA1 Message Date
Sasanka 295ec5799f feat: enhance product management features
- Implement sorting and filtering for categories in the dashboard.
- Add status filtering and sorting capabilities for categories.
- Introduce warehouse selection and base UOM configuration in the new item creation page.
- Fetch and display brands and subcategories in the items page.
- Update product settings to manage subcategories and brands.
- Refactor sidebar navigation to include configuration options.
2026-07-20 17:11:13 +05:30
ImanThiyanga fe9e8a780f ui updated 2026-07-20 12:00:03 +05:30
Dhananjaya99 92c4b14a6c add rbac 2026-07-18 23:42:58 +05:30
ashan_rusiru 80b130dffb Merge pull request 'Feat/be inventory' (#10) from feat/be-inventory into Dev
Reviewed-on: #10
2026-07-18 08:38:47 +00:00
ImanThiyanga 62a5d857de Complete all for Items 2026-07-17 14:27:51 +05:30
ImanThiyanga f72b24fcaa completed invetory updates 2026-07-17 00:23:33 +05:30
ImanThiyanga 7c5faabc2d ath fix 2026-07-16 14:23:23 +05:30
ImanThiyanga 582782b0fe Merge pull request 'feat: add brands and variant categories management' (#8) from inventry-and-supply-chain-management into Dev
Reviewed-on: #8
2026-07-16 05:35:18 +00:00
ImanThiyanga 250cf89abb Merge branch 'Dev' into inventry-and-supply-chain-management 2026-07-16 05:35:12 +00:00
Sasanka c9a84e235b feat: add brands and variant categories management
- Implemented CRUD operations for brands and variant categories in the API.
- Created UI components for managing brands and variant categories, including listing, creating, editing, and deleting.
- Enhanced the sidebar navigation to include links for brands and variant categories.
- Updated the categories API to support pagination and filtering.
- Added validation for brand and variant category names.
- Integrated toast notifications for user feedback on actions.
2026-07-15 18:11:35 +05:30
ImanThiyanga 5d18d5d576 Merge branch 'production' into Dev 2026-07-15 10:08:02 +00:00
ImanThiyanga ed2ee87c68 Refactor code structure for int in alll ids instesd defult using long 2026-07-15 15:35:54 +05:30
ImanThiyanga 6c7f53350f Merge pull request 'Inventry and supply chain management' (#6) from inventry-and-supply-chain-management into Dev
Reviewed-on: #6
2026-07-15 08:04:34 +00:00
Sasanka 0e4bcf174b Refactor API client and remove unused code
- Removed the ApiError class and related functions from the api-client module.
- Updated vendor detail page to handle errors without the ApiError class.
- Refactored various API modules to use in-memory mock data instead of commented-out fetch implementations.
- Removed the auth-token module as it was no longer needed.
- Updated error handling to use a more generic error structure.
- Adjusted common types to include ApiResult interface for better type management.
2026-07-15 10:28:14 +05:30
Sasanka cb9fd7dfa8 Revert "Refactor API modules to remove mock implementations and integrate real endpoints"
This reverts commit
2026-07-15 10:27:38 +05:30
ImanThiyanga 4b2914cd5d Merge pull request 'Add migration to add auth_user_id column to users table' (#5) from fix/phase-01 into Dev
Reviewed-on: #5
2026-07-14 11:02:51 +00:00
Sasanka 0415794473 Refactor API modules to remove mock implementations and integrate real endpoints
- Updated stock-transfers.ts to replace mock data with API requests for stock transfers.
- Refactored stock.ts to utilize API calls for stock inquiries instead of mock data.
- Modified uoms.ts to implement real API requests for unit of measure operations.
- Transitioned vendors.ts to use actual API endpoints for vendor management, removing mock data handling.
- Updated warehouses.ts to replace mock implementations with real API calls for warehouse and bin management.
- Refactored wastage.ts to utilize stock adjustments and reason codes APIs, removing mock data dependencies.
- Adjusted procurement.ts to align with backend DTO shapes, ensuring consistency with planned backend structures.
2026-07-14 16:01:47 +05:30
Sasanka 7ac30bb454 feat: add warehouse and bin management API with mock data implementation
- Implemented warehouses API with methods for listing, creating, and managing bins.
- Added wastage API to handle stock write-offs and integrate with stock adjustments.
- Created auth token management for storing and retrieving access tokens.
- Developed error mapping for consistent user-facing error messages.
- Introduced client-side validations for GRN, master data, and procurement processes.
- Defined common types for pagination, problem details, and various master data entities.
- Established procurement and stock management types to support frontend functionality.
2026-07-13 17:11:40 +05:30
ImanThiyanga 0aa05f10f2 Merge pull request 'feat: Implement Category, Item, UOM, Vendor, and Warehouse services with CRUD operations' (#2) from feat/phase-implemtation into production
Reviewed-on: #2
2026-07-10 05:15:59 +00:00
308 changed files with 26105 additions and 7361 deletions
+7
View File
@@ -29,3 +29,10 @@ yarn-error.log*
.DS_Store
Thumbs.db
.idea/
# ── Migrations ─────────────────────────────────────────────────────────
# New EF Core migrations are not committed. Note the 4 migrations already in
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
# not apply to tracked files — so edits to those still get committed as normal.
# Untracking them too takes `git rm --cached`.
**/Migrations/
@@ -20,7 +20,7 @@ public sealed class AuditLogsController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AuditLogDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AuditLogDto>>> List(
[FromQuery] string? entityType, [FromQuery] long? entityId, [FromQuery] long? userId,
[FromQuery] string? entityType, [FromQuery] int? entityId, [FromQuery] int? userId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListLogsAsync(entityType, entityId, userId, from, to, query, ct));
}
@@ -0,0 +1,297 @@
using ERPCore.Dtos.Auth;
using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Fronts the external AuthHex identity service (API_REFERENCE.md) so the
/// frontend never calls AuthHex directly. Sessions are delivered as httpOnly
/// Secure cookies (docs/02-SECURITY.md §B.2) via <see cref="AuthCookieWriter"/>
/// — response bodies never carry raw tokens. Does not inherit
/// <see cref="ApiControllerBase"/>: most actions here are pre-session and need
/// <see cref="AllowAnonymousAttribute"/>, and the ETag/If-Match handling that
/// base provides doesn't apply to auth flows.
/// </summary>
[ApiController]
[Produces("application/json")]
[Route("api/v1/auth")]
[Authorize(JwtAuthExtensions.ErpAccessPolicy)]
public sealed class AuthController : ControllerBase
{
private readonly IAuthUserService _users;
private readonly IAuthRecoveryService _recovery;
private readonly IAuthAltService _alt;
private readonly IRoleService _roles;
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
{
_users = users;
_recovery = recovery;
_alt = alt;
_roles = roles;
}
/// <summary>
/// Authoritative current-session info for the frontend: role + the sidebar nav
/// codes it may see (docs/10 C.9 shadow-Role sync). Replaces the frontend's
/// previous reliance on a stale, untrusted `roleId` cached in localStorage.
/// </summary>
[HttpGet("me")]
[ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<MeResponseDto>> Me(CancellationToken ct)
{
var roleCode = User.FindFirst(AuthHexClaims.RoleCode)?.Value;
return Ok(await _roles.GetMeAsync(roleCode, ct));
}
// ---- Session-issuing (UserManager) ------------------------------------
[HttpPost("register")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<AuthSessionResponse>> Register([FromBody] RegisterRequest request, CancellationToken ct)
{
var result = await _users.RegisterAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
return Ok(result.Body);
}
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<AuthSessionResponse>> Login([FromBody] LoginRequest request, CancellationToken ct)
{
var result = await _users.LoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
return Ok(result.Body);
}
[HttpPost("login/otp/verify")]
[AllowAnonymous]
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyLoginOtp([FromBody] VerifyOtpForLoginRequest request, CancellationToken ct)
{
var result = await _users.VerifyOtpForLoginAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
return Ok(result.Body);
}
[HttpPost("refresh-token")]
[AllowAnonymous]
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<AuthSessionResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken ct)
{
if (!Request.Cookies.TryGetValue(JwtAuthExtensions.RefreshTokenCookie, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
throw new DomainException(ErrorCodes.RefreshTokenMissing, "No refresh session cookie present.", 401);
var result = await _users.RefreshTokenAsync(refreshToken, request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
return Ok(result.Body);
}
// ---- Profile / sessions (UserManager) ---------------------------------
[HttpGet("users/{userId:guid}")]
[AllowAnonymous]
[ProducesResponseType(typeof(GetUserDetailsResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<GetUserDetailsResponse>> GetUserDetails(Guid userId, CancellationToken ct)
=> Ok(await _users.GetUserDetailsAsync(userId, ct));
[HttpGet("sessions")]
[ProducesResponseType(typeof(List<SessionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<SessionDto>>> GetSessions(CancellationToken ct)
=> Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct));
[HttpPost("status")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct)
{
await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct);
return NoContent();
}
[HttpPost("lock")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Lock([FromBody] LockUserAccountRequest request, CancellationToken ct)
{
await _users.LockUserAccountAsync(request, RequireBearerToken(), ct);
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
[HttpPost("change-password")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ChangePassword([FromBody] ChangeUserPasswordRequest request, CancellationToken ct)
{
await _users.ChangeUserPasswordAsync(request, RequireBearerToken(), ct);
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
[HttpPost("verify-password")]
[ProducesResponseType(typeof(VerifyPasswordResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<VerifyPasswordResponse>> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
=> Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
/// <summary>
/// Ends the session: revokes it upstream where possible, and always clears our cookies.
/// <para>
/// <c>userId</c> is optional because callers usually cannot supply it — AuthHex returns
/// <c>user.userId: null</c> in its own login/register response, so a browser has no id
/// to send. It is resolved from the session token's <c>UserId</c> claim instead.
/// </para>
/// <para>
/// The cookies are cleared even if the upstream revoke fails or no user can be
/// resolved: a logout that leaves the caller holding a live session cookie is worse
/// than one that leaves a stale session server-side (which lapses on its own).
/// </para>
/// </summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout([FromBody] LogoutRequest? request, CancellationToken ct)
{
var userId = request?.UserId ?? ResolveTokenUserId();
if (userId is not null)
{
try
{
await _users.LogoutUserAsync(new LogoutRequest { UserId = userId.Value }, ct);
}
catch (DomainException)
{
// Upstream unreachable or already-revoked — fall through and clear anyway.
}
}
AuthCookieWriter.ClearSession(Response);
return NoContent();
}
/// <summary>AuthHex's identity claim, present when the request carried a valid session.</summary>
private Guid? ResolveTokenUserId()
=> Guid.TryParse(User.FindFirst(AuthHexClaims.UserId)?.Value, out var id) ? id : null;
[HttpPut("me")]
[ValidateCsrf]
[ProducesResponseType(typeof(UserSummaryDto), StatusCodes.Status200OK)]
public async Task<ActionResult<UserSummaryDto?>> UpdateMe([FromBody] UpdateUserRequest request, CancellationToken ct)
=> Ok(await _users.UpdateUserAsync(request, RequireBearerToken(), ct));
// ---- 2FA (UserManager) -------------------------------------------------
[HttpPost("2fa/initiate")]
[ValidateCsrf]
[ProducesResponseType(typeof(TwoFaSetupResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<TwoFaSetupResponse>> InitiateTwoFa(CancellationToken ct)
=> Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct));
[HttpPost("2fa/complete")]
[ValidateCsrf]
[ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<CompleteTwoFaSetupResponse>> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct)
=> Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct));
[HttpPost("2fa/verify")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct)
{
await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct);
return NoContent();
}
[HttpPost("2fa/disable")]
[ValidateCsrf]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> DisableTwoFa([FromBody] DisableTwoFaRequest request, CancellationToken ct)
{
await _users.DisableTwoFaAsync(request, RequireBearerToken(), ct);
return NoContent();
}
[HttpGet("2fa/status")]
[ProducesResponseType(typeof(TwoFaStatusResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<TwoFaStatusResponse>> GetTwoFaStatus(CancellationToken ct)
=> Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct));
// ---- Recovery -----------------------------------------------------------
[HttpPost("recovery/forgot-password")]
[AllowAnonymous]
[ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<ForgotPasswordResponse>> ForgotPassword([FromBody] ForgotPasswordRequest request, CancellationToken ct)
=> Ok(await _recovery.ForgotPasswordAsync(request, ct));
[HttpPost("recovery/verify-otp")]
[AllowAnonymous]
[ProducesResponseType(typeof(VerifyRecoveryOtpResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<VerifyRecoveryOtpResponse>> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct)
=> Ok(await _recovery.VerifyOtpAsync(request, ct));
[HttpPost("recovery/reset-password")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken ct)
{
await _recovery.ResetPasswordAsync(request, ct);
return NoContent();
}
[HttpPost("recovery/reset-password-token")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> ResetPasswordWithToken([FromBody] ResetPasswordWithTokenRequest request, CancellationToken ct)
{
await _recovery.ResetPasswordWithTokenAsync(request, ct);
return NoContent();
}
// ---- Availability / OTP (AltOptionManager) -----------------------------
[HttpPost("availability")]
[AllowAnonymous]
[ProducesResponseType(typeof(IsAvailableResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<IsAvailableResponse>> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct)
=> Ok(await _alt.IsAvailableAsync(request, ct));
[HttpPost("otp/send")]
[AllowAnonymous]
[ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<SendOtpResponse>> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
=> Ok(await _alt.SendOtpAsync(request, ct));
[HttpPost("otp/verify")]
[AllowAnonymous]
[ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
public async Task<ActionResult<OtpLoginVerifiedResponse>> VerifyAltOtp([FromBody] VerifyAltOtpRequest request, CancellationToken ct)
{
var result = await _alt.VerifyOtpAsync(request, ct);
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
return Ok(result.Body);
}
// ---- Helpers --------------------------------------------------------------
/// <summary>The token that authenticated this request — Bearer header if present, else the session cookie.</summary>
private string RequireBearerToken()
{
var header = Request.Headers.Authorization.ToString();
if (!string.IsNullOrEmpty(header) && header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..];
if (Request.Cookies.TryGetValue(JwtAuthExtensions.AccessTokenCookie, out var cookieToken) && !string.IsNullOrEmpty(cookieToken))
return cookieToken;
// [Authorize] already guaranteed one of the above was present to authenticate this request.
throw new DomainException(ErrorCodes.AuthUpstreamError, "No bearer token found on an authenticated request.", 500);
}
}
@@ -0,0 +1,67 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Brands;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).</summary>
[Route("api/v1/brands")]
public sealed class BrandsController : ApiControllerBase
{
private readonly IBrandService _brands;
public BrandsController(IBrandService brands) => _brands = brands;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<BrandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<BrandDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _brands.ListAsync(query, status, ct));
[HttpGet("{brandId:int}")]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BrandDto>> GetById(int brandId, CancellationToken ct)
{
var result = await _brands.GetAsync(brandId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BrandDto>> Create([FromBody] CreateBrandRequest request, CancellationToken ct)
{
var result = await _brands.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/brands/{result.Value.BrandId}", result.Value);
}
[HttpPut("{brandId:int}")]
[ProducesResponseType(typeof(BrandDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<BrandDto>> Update(int brandId, [FromBody] UpdateBrandRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _brands.UpdateAsync(brandId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{brandId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct)
{
await _brands.SetStatusAsync(brandId, request.Status, ct);
return NoContent();
}
}
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Services.Interfaces;
@@ -5,7 +6,11 @@ using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).</summary>
/// <summary>
/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3), including the subcategories
/// nested beneath each category. The hierarchy is exactly two levels deep — the old
/// <c>?tree=true</c> parameter is gone along with the self-nesting model.
/// </summary>
[Route("api/v1/categories")]
public sealed class CategoriesController : ApiControllerBase
{
@@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase
public CategoriesController(ICategoryService categories) => _categories = categories;
/// <summary>Flat paged list, or a nested tree when <c>tree=true</c>.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CategoryDto>), StatusCodes.Status200OK)]
[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));
public async Task<ActionResult<PagedResponse<CategoryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListAsync(query, status, ct));
[HttpGet("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CategoryDto>> GetById(int categoryId, CancellationToken ct)
{
var result = await _categories.GetAsync(categoryId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
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);
var result = await _categories.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/categories/{result.Value.CategoryId}", result.Value);
}
[HttpPut("{categoryId:int}")]
[ProducesResponseType(typeof(CategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<CategoryDto>> Update(
int categoryId, [FromBody] UpdateCategoryRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _categories.UpdateAsync(categoryId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{categoryId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int categoryId, [FromBody] UpdateCategoryStatusRequest request, CancellationToken ct)
{
await _categories.SetStatusAsync(categoryId, request.Status, ct);
return NoContent();
}
// Subcategories — nested under their parent category (docs/11 §2.3).
// Updates live on SubCategoriesController at /api/v1/subcategories/{id}.
[HttpGet("{categoryId:int}/subcategories")]
[ProducesResponseType(typeof(PagedResponse<SubCategoryDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PagedResponse<SubCategoryDto>>> ListSubCategories(
int categoryId, [FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _categories.ListSubCategoriesAsync(categoryId, query, status, ct));
[HttpPost("{categoryId:int}/subcategories")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<SubCategoryDto>> CreateSubCategory(
int categoryId, [FromBody] CreateSubCategoryRequest request, CancellationToken ct)
{
var result = await _categories.CreateSubCategoryAsync(categoryId, request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/subcategories/{result.Value.SubCategoryId}", result.Value);
}
}
+16 -6
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,10 +14,18 @@ public sealed class GrnsController : ApiControllerBase
public GrnsController(IGrnService grns) => _grns = grns;
[HttpGet("{grnId:long}")]
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<GrnDto>> GetById(long grnId, CancellationToken ct)
public async Task<ActionResult<GrnDto>> GetById(int grnId, CancellationToken ct)
{
var dto = await _grns.GetAsync(grnId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -33,20 +43,20 @@ public sealed class GrnsController : ApiControllerBase
}
/// <summary>Confirm: create FIFO layers + inbound ledger + update PO receipts (single UoW txn).</summary>
[HttpPost("{grnId:long}/confirm")]
[HttpPost("{grnId:int}/confirm")]
[ProducesResponseType(typeof(GrnConfirmResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<GrnConfirmResultDto>> Confirm(
long grnId, [FromHeader(Name = "Idempotency-Key")] string? idempotencyKey, CancellationToken ct)
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:long}/lines/{grnLineId:long}/release")]
[HttpPost("{grnId:int}/lines/{grnLineId:int}/release")]
[ProducesResponseType(typeof(ReleaseLineResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ReleaseLineResultDto>> Release(
long grnId, long grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
int grnId, int grnLineId, [FromBody] ReleaseLineRequest request, CancellationToken ct)
=> Ok(await _grns.ReleaseLineAsync(grnId, grnLineId, request.Action, ct));
}
@@ -0,0 +1,73 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.ItemTypes;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material
/// dimension names. <c>GET</c> is the reason this master exists: it populates the item
/// builder's dropdown. Items never reference an item type; the chosen values are encoded
/// into the client-generated SKU (docs/10 Part C.9).
/// </summary>
[Route("api/v1/item-types")]
public sealed class ItemTypesController : ApiControllerBase
{
private readonly IItemTypeService _itemTypes;
public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes;
/// <summary>Feeds the frontend item-builder dropdown; filter <c>status=Active</c> for selectable rows.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ItemTypeDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ItemTypeDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _itemTypes.ListAsync(query, status, ct));
[HttpGet("{itemTypeId:int}")]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemTypeDto>> GetById(int itemTypeId, CancellationToken ct)
{
var result = await _itemTypes.GetAsync(itemTypeId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ItemTypeDto>> Create([FromBody] CreateItemTypeRequest request, CancellationToken ct)
{
var result = await _itemTypes.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/item-types/{result.Value.ItemTypeId}", result.Value);
}
[HttpPut("{itemTypeId:int}")]
[ProducesResponseType(typeof(ItemTypeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ItemTypeDto>> Update(int itemTypeId, [FromBody] UpdateItemTypeRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _itemTypes.UpdateAsync(itemTypeId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{itemTypeId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct)
{
await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct);
return NoContent();
}
}
+14 -12
View File
@@ -20,16 +20,18 @@ public sealed class ItemsController : ApiControllerBase
public async Task<ActionResult<PagedResponse<ItemListItemDto>>> List(
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] long? categoryId,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, trackingMode, ct));
=> Ok(await _items.ListAsync(query, status, categoryId, subCategoryId, brandId, trackingMode, ct));
/// <summary>Get a single item; returns an <c>ETag</c> for optimistic concurrency.</summary>
[HttpGet("{itemId:long}")]
[HttpGet("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemDetailDto>> GetById(long itemId, CancellationToken ct)
public async Task<ActionResult<ItemDetailDto>> GetById(int itemId, CancellationToken ct)
{
var result = await _items.GetAsync(itemId, ct);
if (result is null) return NotFound();
@@ -51,11 +53,11 @@ public sealed class ItemsController : ApiControllerBase
}
/// <summary>Full update; requires <c>If-Match</c> (412 on stale ETag).</summary>
[HttpPut("{itemId:long}")]
[HttpPut("{itemId:int}")]
[ProducesResponseType(typeof(ItemDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ItemDetailDto>> Update(long itemId, [FromBody] UpdateItemRequest request, CancellationToken ct)
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);
@@ -64,26 +66,26 @@ public sealed class ItemsController : ApiControllerBase
}
/// <summary>Activate / deactivate the item (FR-MD-08 — deactivate, not delete).</summary>
[HttpPatch("{itemId:long}/status")]
[HttpPatch("{itemId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(long itemId, [FromBody] UpdateItemStatusRequest request, CancellationToken ct)
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:long}/reorder")]
[HttpPut("{itemId:int}/reorder")]
[ProducesResponseType(typeof(ItemReorderSettingsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemReorderSettingsDto>> UpdateReorder(long itemId, [FromBody] UpdateReorderRequest request, CancellationToken ct)
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:long}/uom-conversions")]
[HttpPut("{itemId:int}/uom-conversions")]
[ProducesResponseType(typeof(ItemUomConversionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(long itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
public async Task<ActionResult<ItemUomConversionsDto>> UpdateUomConversions(int itemId, [FromBody] UpdateUomConversionsRequest request, CancellationToken ct)
=> Ok(await _items.UpdateUomConversionsAsync(itemId, request, ct));
}
@@ -19,6 +19,6 @@ public sealed class JournalEntriesController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<JournalEntryStubDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<JournalEntryStubDto>>> List(
[FromQuery] string? sourceDocType, [FromQuery] long? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct)
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _audit.ListJournalAsync(sourceDocType, sourceDocId, query, ct));
}
@@ -0,0 +1,39 @@
using ERPCore.Domain.Entities;
using ERPCore.Dtos.Rbac;
using ERPCore.Repositories.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Controllers;
/// <summary>
/// Read-only sidebar nav tree, used by the Role permission-assignment checkbox
/// UI and by `GET /auth/me` (see AuthController) to resolve a role's visible codes.
/// NavItem/SubNavItem rows are seeded (NavItemConfiguration/SubNavItemConfiguration)
/// to match the frontend's hardcoded sidebar — not admin-editable in this phase.
/// </summary>
[Route("api/v1/nav")]
public sealed class NavController : ApiControllerBase
{
private readonly IRepository<NavItem> _navItems;
public NavController(IRepository<NavItem> navItems) => _navItems = navItems;
[HttpGet]
[ProducesResponseType(typeof(List<NavItemDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<NavItemDto>>> GetTree(CancellationToken ct)
{
var items = await _navItems.Query().AsNoTracking()
.Include(n => n.Children)
.OrderBy(n => n.SortOrder)
.ToListAsync(ct);
var dto = items.Select(n => new NavItemDto(
n.NavItemId, n.Code, n.Label, n.Icon, n.Href, n.SortOrder,
n.Children.OrderBy(c => c.SortOrder)
.Select(c => new SubNavItemDto(c.SubNavItemId, c.Code, c.Label, c.Icon, c.Href, c.SortOrder))
.ToList())).ToList();
return Ok(dto);
}
}
@@ -0,0 +1,46 @@
using ERPCore.Dtos.Config;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
/// feature gate for subcategories/brands/item-types.
/// <para>
/// <b>Authorization:</b> writes are admitted by the inherited ERP door policy only.
/// A dedicated <c>CONFIG_MANAGE</c> permission is reserved for when per-endpoint RBAC
/// lands (FR-X-01, currently deferred) — at that point this action gets the attribute
/// with no other change. Until then any ERP-admitted user can flip these flags; that is
/// the accepted Phase-1 posture, consistent with every other endpoint.
/// </para>
/// </summary>
[Route("api/v1/product-config")]
public sealed class ProductConfigController : ApiControllerBase
{
private readonly IProductConfigService _config;
public ProductConfigController(IProductConfigService config) => _config = config;
[HttpGet]
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProductConfigDto>> Get(CancellationToken ct)
{
var result = await _config.GetAsync(ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPut]
[ProducesResponseType(typeof(ProductConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<ProductConfigDto>> Update(
[FromBody] UpdateProductConfigRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _config.UpdateAsync(request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
}
@@ -17,13 +17,13 @@ public sealed class PurchaseOrdersController : ApiControllerBase
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseOrderSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseOrderSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] long? vendorId, CancellationToken ct)
[FromQuery] PageQuery query, [FromQuery] PurchaseOrderStatus? status, [FromQuery] int? vendorId, CancellationToken ct)
=> Ok(await _pos.ListAsync(query, status, vendorId, ct));
[HttpGet("{poId:long}")]
[HttpGet("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseOrderDto>> GetById(long poId, CancellationToken ct)
public async Task<ActionResult<PurchaseOrderDto>> GetById(int poId, CancellationToken ct)
{
var result = await _pos.GetAsync(poId, ct);
if (result is null) return NotFound();
@@ -44,12 +44,12 @@ public sealed class PurchaseOrdersController : ApiControllerBase
}
/// <summary>Edit while open (FR-PROC-05); requires <c>If-Match</c>. 409 PO_NOT_EDITABLE if closed.</summary>
[HttpPut("{poId:long}")]
[HttpPut("{poId:int}")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<PurchaseOrderDto>> Update(long poId, [FromBody] UpdatePurchaseOrderRequest request, CancellationToken ct)
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);
@@ -58,17 +58,17 @@ public sealed class PurchaseOrdersController : ApiControllerBase
}
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
[HttpPost("{poId:long}/approve")]
[HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseOrderDto>> Approve(long poId, CancellationToken ct)
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:long}/cancel")]
[HttpPost("{poId:int}/cancel")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Cancel(long poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct)
public async Task<ActionResult<PurchaseOrderDto>> Cancel(int poId, [FromBody] CancelPurchaseOrderRequest request, CancellationToken ct)
=> Ok(await _pos.CancelAsync(poId, request.Reason, ct));
}
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +13,23 @@ public sealed class PurchaseReturnsController : ApiControllerBase
public PurchaseReturnsController(IPurchaseReturnService returns) => _returns = returns;
/// <summary>List posted returns, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<PurchaseReturnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<PurchaseReturnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
/// <summary>Get one return with its lines and the ledger entries it posted.</summary>
[HttpGet("{returnId:int}")]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PurchaseReturnDto>> GetById(int returnId, CancellationToken ct)
{
var dto = await _returns.GetAsync(returnId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post a return (outbound movement). 409 if return exceeds available stock.</summary>
[HttpPost]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
@@ -1,3 +1,4 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
@@ -15,13 +16,14 @@ public sealed class RequisitionsController : ApiControllerBase
[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));
public async Task<ActionResult<PagedResponse<RequisitionSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
=> Ok(await _requisitions.ListAsync(query, status, ct));
[HttpGet("{requisitionId:long}")]
[HttpGet("{requisitionId:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RequisitionDto>> GetById(long requisitionId, CancellationToken ct)
public async Task<ActionResult<RequisitionDto>> GetById(int requisitionId, CancellationToken ct)
{
var dto = await _requisitions.GetAsync(requisitionId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -36,9 +38,9 @@ public sealed class RequisitionsController : ApiControllerBase
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
}
[HttpPost("{requisitionId:long}/submit")]
[HttpPost("{requisitionId:int}/submit")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RequisitionDto>> Submit(long requisitionId, CancellationToken ct)
public async Task<ActionResult<RequisitionDto>> Submit(int requisitionId, CancellationToken ct)
=> Ok(await _requisitions.SubmitAsync(requisitionId, ct));
}
+15 -6
View File
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Procurement;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,10 +14,17 @@ public sealed class RfqsController : ApiControllerBase
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
[HttpGet("{rfqId:long}")]
/// <summary>List RFQs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
=> Ok(await _rfqs.ListAsync(query, status, ct));
[HttpGet("{rfqId:int}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RfqDto>> GetById(long rfqId, CancellationToken ct)
public async Task<ActionResult<RfqDto>> GetById(int rfqId, CancellationToken ct)
{
var dto = await _rfqs.GetAsync(rfqId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -30,20 +39,20 @@ public sealed class RfqsController : ApiControllerBase
return Created($"/api/v1/rfqs/{dto.RfqId}", dto);
}
[HttpPost("{rfqId:long}/quotations")]
[HttpPost("{rfqId:int}/quotations")]
[ProducesResponseType(typeof(VendorQuotationDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<VendorQuotationDto>> AddQuotation(long rfqId, [FromBody] CreateQuotationRequest request, CancellationToken ct)
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:long}/comparison")]
[HttpGet("{rfqId:int}/comparison")]
[ProducesResponseType(typeof(RfqComparisonDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RfqComparisonDto>> Comparison(long rfqId, CancellationToken ct)
public async Task<ActionResult<RfqComparisonDto>> Comparison(int rfqId, CancellationToken ct)
=> Ok(await _rfqs.GetComparisonAsync(rfqId, ct));
}
@@ -0,0 +1,88 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Rbac;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).</summary>
[Route("api/v1/roles")]
public sealed class RolesController : ApiControllerBase
{
private readonly IRoleService _roles;
public RolesController(IRoleService roles) => _roles = roles;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RoleDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RoleDto>>> List(
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _roles.ListAsync(query, status, ct));
[HttpGet("{roleId:int}")]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RoleDto>> GetById(int roleId, CancellationToken ct)
{
var result = await _roles.GetAsync(roleId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPost]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<RoleDto>> Create([FromBody] CreateRoleRequest request, CancellationToken ct)
{
var result = await _roles.CreateAsync(request, ct);
SetETag(result.RowVersion);
return Created($"/api/v1/roles/{result.Value.RoleId}", result.Value);
}
[HttpPut("{roleId:int}")]
[ProducesResponseType(typeof(RoleDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<RoleDto>> Update(int roleId, [FromBody] UpdateRoleRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _roles.UpdateAsync(roleId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
[HttpPatch("{roleId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(int roleId, [FromBody] UpdateRoleStatusRequest request, CancellationToken ct)
{
await _roles.SetStatusAsync(roleId, request.Status, ct);
return NoContent();
}
[HttpDelete("{roleId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int roleId, CancellationToken ct)
{
await _roles.DeleteAsync(roleId, ct);
return NoContent();
}
[HttpGet("{roleId:int}/permissions")]
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RolePermissionsDto>> GetPermissions(int roleId, CancellationToken ct)
=> Ok(await _roles.GetPermissionsAsync(roleId, ct));
[HttpPut("{roleId:int}/permissions")]
[ProducesResponseType(typeof(RolePermissionsDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RolePermissionsDto>> AssignPermissions(
int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
=> Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
}
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,6 +13,23 @@ public sealed class StockAdjustmentsController : ApiControllerBase
public StockAdjustmentsController(IAdjustmentService adjustments) => _adjustments = adjustments;
/// <summary>List posted adjustments, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<AdjustmentSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<AdjustmentSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
=> Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
/// <summary>Get one adjustment with its lines and the ledger entries it posted.</summary>
[HttpGet("{adjustmentId:int}")]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AdjustmentDto>> GetById(int adjustmentId, CancellationToken ct)
{
var dto = await _adjustments.GetAsync(adjustmentId, ct);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).</summary>
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
+22 -8
View File
@@ -21,34 +21,48 @@ public sealed class StockController : ApiControllerBase
[HttpGet("on-hand")]
[ProducesResponseType(typeof(StockOnHandDto), StatusCodes.Status200OK)]
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] long itemId, [FromQuery] long warehouseId, CancellationToken ct)
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
/// this document post?" — the ledger's document reference is polymorphic, so there is
/// no FK to navigate instead (docs/10 C.9).
/// </summary>
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] long? itemId, [FromQuery] long? warehouseId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] string? sourceDocType, [FromQuery] int? sourceDocId,
[FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, sourceDocType, sourceDocId, query, ct));
[HttpGet("valuation")]
[ProducesResponseType(typeof(StockValuationDto), StatusCodes.Status200OK)]
public async Task<ActionResult<StockValuationDto>> Valuation([FromQuery] long itemId, [FromQuery] long warehouseId, CancellationToken ct)
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] long? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
[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:long}/requisition")]
[HttpPost("reorder-alerts/{itemId:int}/requisition")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<RequisitionDto>> SuggestRequisition(
long itemId, [FromQuery] long warehouseId, CancellationToken ct)
int itemId, [FromQuery] int warehouseId, CancellationToken ct)
{
var dto = await _reorder.CreateSuggestedRequisitionAsync(itemId, warehouseId, ct);
return Created($"/api/v1/requisitions/{dto.RequisitionId}", dto);
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,10 +14,17 @@ public sealed class StockCountsController : ApiControllerBase
public StockCountsController(ICountService counts) => _counts = counts;
[HttpGet("{countId:long}")]
/// <summary>List counts, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CountDto>> GetById(long countId, CancellationToken ct)
public async Task<ActionResult<CountDto>> GetById(int countId, CancellationToken ct)
{
var dto = await _counts.GetAsync(countId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -32,18 +41,18 @@ public sealed class StockCountsController : ApiControllerBase
}
/// <summary>Enter counted quantities; variance = counted system.</summary>
[HttpPut("{countId:long}/counts")]
[HttpPut("{countId:int}/counts")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CountDto>> EnterCounts(long countId, [FromBody] EnterCountsRequest request, CancellationToken ct)
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:long}/post")]
[HttpPost("{countId:int}/post")]
[ProducesResponseType(typeof(CountPostResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<CountPostResultDto>> Post(long countId, CancellationToken ct)
public async Task<ActionResult<CountPostResultDto>> Post(int countId, CancellationToken ct)
=> Ok(await _counts.PostAsync(countId, ct));
}
@@ -1,3 +1,5 @@
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
@@ -12,10 +14,18 @@ public sealed class StockTransfersController : ApiControllerBase
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
[HttpGet("{transferId:long}")]
/// <summary>List transfers, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<TransferDto>> GetById(long transferId, CancellationToken ct)
public async Task<ActionResult<TransferDto>> GetById(int transferId, CancellationToken ct)
{
var dto = await _transfers.GetAsync(transferId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -31,19 +41,19 @@ public sealed class StockTransfersController : ApiControllerBase
}
/// <summary>Dispatch: consume source FIFO layers into in-transit. 409 STOCK_NEGATIVE_BLOCKED if short.</summary>
[HttpPost("{transferId:long}/dispatch")]
[HttpPost("{transferId:int}/dispatch")]
[ProducesResponseType(typeof(DispatchResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<DispatchResultDto>> Dispatch(long transferId, CancellationToken ct)
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:long}/receive")]
[HttpPost("{transferId:int}/receive")]
[ProducesResponseType(typeof(ReceiveResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<ActionResult<ReceiveResultDto>> Receive(long transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct)
public async Task<ActionResult<ReceiveResultDto>> Receive(int transferId, [FromBody] ReceiveTransferRequest request, CancellationToken ct)
=> Ok(await _transfers.ReceiveAsync(transferId, request, ct));
}
@@ -0,0 +1,56 @@
using ERPCore.Dtos.Categories;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3).
/// Listing and creation live under the parent category on <see cref="CategoriesController"/>,
/// since a subcategory only exists in the context of one.
/// </summary>
[Route("api/v1/subcategories")]
public sealed class SubCategoriesController : ApiControllerBase
{
private readonly ICategoryService _categories;
public SubCategoriesController(ICategoryService categories) => _categories = categories;
[HttpGet("{subCategoryId:int}")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SubCategoryDto>> GetById(int subCategoryId, CancellationToken ct)
{
var result = await _categories.GetSubCategoryAsync(subCategoryId, ct);
if (result is null) return NotFound();
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Renames a subcategory. It cannot be moved to another category — see the request DTO.</summary>
[HttpPut("{subCategoryId:int}")]
[ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<SubCategoryDto>> Update(
int subCategoryId, [FromBody] UpdateSubCategoryRequest request, CancellationToken ct)
{
var expected = RequireIfMatch();
var result = await _categories.UpdateSubCategoryAsync(subCategoryId, request, expected, ct);
SetETag(result.RowVersion);
return Ok(result.Value);
}
/// <summary>Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).</summary>
[HttpPatch("{subCategoryId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(
int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct)
{
await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct);
return NoContent();
}
}
@@ -0,0 +1,53 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Users;
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// User management: local shadow `User` list/detail + role assignment, and
/// account creation orchestrated against AuthHex (see <see cref="IUserManagementService.CreateAsync"/>).
/// </summary>
[Route("api/v1/users")]
public sealed class UsersController : ApiControllerBase
{
private readonly IUserManagementService _users;
public UsersController(IUserManagementService users) => _users = users;
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _users.ListAsync(query, ct));
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
[HttpGet("user-types")]
[ProducesResponseType(typeof(List<UserTypeOptionDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<UserTypeOptionDto>>> ListUserTypes(CancellationToken ct)
=> Ok(await _users.ListUserTypesAsync(ct));
[HttpGet("{userId:int}")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> GetById(int userId, CancellationToken ct)
{
var result = await _users.GetAsync(userId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManagedUserDto>> Create([FromBody] CreateUserRequest request, CancellationToken ct)
{
var result = await _users.CreateAsync(request, ct);
return Created($"/api/v1/users/{result.UserId}", result);
}
[HttpPut("{userId:int}/role")]
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
}
@@ -20,10 +20,10 @@ public sealed class VendorsController : ApiControllerBase
[FromQuery] PageQuery query, [FromQuery] EntityStatus? status, CancellationToken ct)
=> Ok(await _vendors.ListAsync(query, status, ct));
[HttpGet("{vendorId:long}")]
[HttpGet("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VendorDto>> GetById(long vendorId, CancellationToken ct)
public async Task<ActionResult<VendorDto>> GetById(int vendorId, CancellationToken ct)
{
var result = await _vendors.GetAsync(vendorId, ct);
if (result is null) return NotFound();
@@ -42,11 +42,11 @@ public sealed class VendorsController : ApiControllerBase
return Created($"/api/v1/vendors/{result.Value.VendorId}", result.Value);
}
[HttpPut("{vendorId:long}")]
[HttpPut("{vendorId:int}")]
[ProducesResponseType(typeof(VendorDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<ActionResult<VendorDto>> Update(long vendorId, [FromBody] UpdateVendorRequest request, CancellationToken ct)
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);
@@ -54,10 +54,10 @@ public sealed class VendorsController : ApiControllerBase
return Ok(result.Value);
}
[HttpPatch("{vendorId:long}/status")]
[HttpPatch("{vendorId:int}/status")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetStatus(long vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct)
public async Task<IActionResult> SetStatus(int vendorId, [FromBody] UpdateVendorStatusRequest request, CancellationToken ct)
{
await _vendors.SetStatusAsync(vendorId, request.Status, ct);
return NoContent();
@@ -18,10 +18,10 @@ public sealed class WarehousesController : ApiControllerBase
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _warehouses.ListAsync(query, ct));
[HttpGet("{warehouseId:long}")]
[HttpGet("{warehouseId:int}")]
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WarehouseDto>> GetById(long warehouseId, CancellationToken ct)
public async Task<ActionResult<WarehouseDto>> GetById(int warehouseId, CancellationToken ct)
{
var dto = await _warehouses.GetAsync(warehouseId, ct);
return dto is null ? NotFound() : Ok(dto);
@@ -36,17 +36,17 @@ public sealed class WarehousesController : ApiControllerBase
return Created($"/api/v1/warehouses/{dto.WarehouseId}", dto);
}
[HttpGet("{warehouseId:long}/bins")]
[HttpGet("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(IReadOnlyList<BinDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<BinDto>>> ListBins(long warehouseId, CancellationToken ct)
public async Task<ActionResult<IReadOnlyList<BinDto>>> ListBins(int warehouseId, CancellationToken ct)
=> Ok(await _warehouses.ListBinsAsync(warehouseId, ct));
[HttpPost("{warehouseId:long}/bins")]
[HttpPost("{warehouseId:int}/bins")]
[ProducesResponseType(typeof(BinDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<BinDto>> CreateBin(long warehouseId, [FromBody] CreateBinRequest request, CancellationToken ct)
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);
+3 -3
View File
@@ -12,10 +12,10 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class AuditLog
{
public long AuditId { get; set; }
public long UserId { get; set; }
public int AuditId { get; set; }
public int UserId { get; set; }
public string EntityType { get; set; } = string.Empty;
public long EntityId { get; set; }
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; } = "{}";
+2 -2
View File
@@ -6,9 +6,9 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Batch
{
public long BatchId { get; set; }
public int BatchId { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string BatchNo { get; set; } = string.Empty;
+2 -2
View File
@@ -6,9 +6,9 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Bin
{
public long BinId { get; set; }
public int BinId { get; set; }
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public string Code { get; set; } = string.Empty;
+21
View File
@@ -0,0 +1,21 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Brand master (FR-MD-09). Referenced optionally by <see cref="Item.BrandId"/>.
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Brand
{
public int BrandId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+16 -6
View File
@@ -1,15 +1,25 @@
using ERPCore.Domain.Enums;
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.
/// Item category (FR-MD-04) — the top level of a two-level hierarchy. The optional level
/// below is <see cref="SubCategory"/>; categories no longer self-nest (the former
/// <c>parent_id</c> tree was replaced in migration #2).
/// Mutable aggregate with a <see cref="RowVersion"/> ETag token. Deactivated, not
/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class Category
{
public long CategoryId { get; set; }
public int CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public long? ParentId { get; set; }
public Category? Parent { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>();
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
public ICollection<SubCategory> SubCategories { get; set; } = new List<SubCategory>();
}
+5 -5
View File
@@ -10,21 +10,21 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Grn
{
public long GrnId { get; set; }
public int GrnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long? PoId { get; set; }
public int? PoId { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
public long VendorId { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public GrnStatus Status { get; set; } = GrnStatus.Draft;
public long CreatedBy { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
+7 -7
View File
@@ -10,24 +10,24 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class GrnLine
{
public long GrnLineId { get; set; }
public int GrnLineId { get; set; }
public long GrnId { get; set; }
public int GrnId { get; set; }
public Grn? Grn { get; set; }
public long? PoLineId { get; set; }
public int? PoLineId { get; set; }
public PoLine? PoLine { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public long UomId { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public long? BinId { get; set; }
public int? BinId { get; set; }
public Bin? Bin { get; set; }
public long? BatchId { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public decimal Qty { get; set; }
+12 -5
View File
@@ -9,21 +9,28 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Item
{
public long ItemId { get; set; }
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 long CategoryId { get; set; }
public int CategoryId { get; set; }
public Category? Category { get; set; }
public long BaseUomId { get; set; }
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
public int? SubCategoryId { get; set; }
public SubCategory? SubCategory { get; set; }
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
public long? DefaultVendorId { get; set; }
public int? DefaultVendorId { get; set; }
public Vendor? DefaultVendor { get; set; }
public ItemType ItemType { get; set; }
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
@@ -7,12 +7,12 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class ItemReorder
{
public long ReorderId { get; set; }
public int ReorderId { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal ReorderPoint { get; set; }
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> enum became.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ItemType
{
public int ItemTypeId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -9,9 +9,9 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class JournalEntryStub
{
public long JournalId { get; set; }
public int JournalId { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public long SourceDocId { get; set; }
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,22 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A top-level sidebar entry (mirrors the frontend's hardcoded nav list,
/// components/Layouts/AppSidebar.tsx). Seeded to match the current app routes;
/// per-role visibility is controlled via <see cref="Permission"/>/<see cref="RolePermission"/>,
/// not by editing these rows through the UI.
/// </summary>
public class NavItem
{
public int NavItemId { get; set; }
public string Code { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? Icon { get; set; }
public string? Href { get; set; }
public int SortOrder { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public ICollection<SubNavItem> Children { get; set; } = new List<SubNavItem>();
}
@@ -8,8 +8,8 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class NumberSequence
{
public long SequenceId { get; set; }
public int SequenceId { get; set; }
public string DocType { get; set; } = string.Empty;
public int Year { get; set; }
public long LastNumber { get; set; }
public int LastNumber { get; set; }
}
@@ -0,0 +1,18 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// A grantable sidebar-visibility unit — exactly one of <see cref="NavItemId"/> /
/// <see cref="SubNavItemId"/> is set (enforced in <c>NavSeedService</c>/service layer,
/// not by a DB constraint). One row is seeded per <see cref="NavItem"/>/<see cref="SubNavItem"/>;
/// <see cref="RolePermission"/> grants it to a role.
/// </summary>
public class Permission
{
public int PermissionId { get; set; }
public string Code { get; set; } = string.Empty;
public int? NavItemId { get; set; }
public int? SubNavItemId { get; set; }
public NavItem? NavItem { get; set; }
public SubNavItem? SubNavItem { get; set; }
}
+5 -5
View File
@@ -7,18 +7,18 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class PoLine
{
public long PoLineId { get; set; }
public int PoLineId { get; set; }
public long PoId { get; set; }
public int PoId { get; set; }
public PurchaseOrder? PurchaseOrder { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public long UomId { get; set; }
public int UomId { get; set; }
public Uom? Uom { get; set; }
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
@@ -0,0 +1,35 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
/// <para>
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
/// so there is nothing on a write to reject; the frontend honours it by hiding the
/// builder's type section. Reads are never gated, so existing data stays visible after a
/// flag is switched off.
/// </para>
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ProductConfig
{
/// <summary>Always 1 — the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -11,19 +11,19 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class PurchaseOrder
{
public long PoId { get; set; }
public int PoId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long VendorId { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public long? RequisitionId { 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 long CreatedBy { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
@@ -9,21 +9,21 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class PurchaseReturn
{
public long ReturnId { get; set; }
public int ReturnId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long VendorId { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long ReasonCodeId { get; set; }
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public ReturnStatus Status { get; set; } = ReturnStatus.Posted;
public long CreatedBy { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
@@ -6,15 +6,15 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class PurchaseReturnLine
{
public long ReturnLineId { get; set; }
public int ReturnLineId { get; set; }
public long ReturnId { get; set; }
public int ReturnId { get; set; }
public PurchaseReturn? Return { get; set; }
public long? GrnLineId { get; set; }
public int? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
@@ -8,7 +8,7 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class ReasonCode
{
public long ReasonCodeId { get; set; }
public int ReasonCodeId { get; set; }
public string Code { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public ReasonContext Context { get; set; }
@@ -8,10 +8,10 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Requisition
{
public long RequisitionId { get; set; }
public int RequisitionId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long RequestedBy { get; set; }
public int RequestedBy { get; set; }
public User? Requester { get; set; }
public RequisitionStatus Status { get; set; } = RequisitionStatus.Draft;
@@ -3,12 +3,12 @@ namespace ERPCore.Domain.Entities;
/// <summary>Requisition line (FR-PROC-01). Model: docs/10 Part C.2.</summary>
public class RequisitionLine
{
public long ReqLineId { get; set; }
public int ReqLineId { get; set; }
public long RequisitionId { get; set; }
public int RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
+2 -2
View File
@@ -8,10 +8,10 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Rfq
{
public long RfqId { get; set; }
public int RfqId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long RequisitionId { get; set; }
public int RequisitionId { get; set; }
public Requisition? Requisition { get; set; }
public RfqStatus Status { get; set; } = RfqStatus.Open;
+3 -3
View File
@@ -3,12 +3,12 @@ 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 long RfqLineId { get; set; }
public int RfqLineId { get; set; }
public long RfqId { get; set; }
public int RfqId { get; set; }
public Rfq? Rfq { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal Qty { get; set; }
+27
View File
@@ -0,0 +1,27 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Local shadow/projection of an AuthHex <c>Role</c> row, mirroring the same
/// pattern <see cref="User"/> uses for AuthHex identities: <see cref="AuthRoleId"/>
/// maps to AuthHex's Guid <c>RoleId</c>, while the local <see cref="RoleId"/> (int)
/// is what <see cref="Permission"/>/<see cref="RolePermission"/>/<see cref="User.RoleId"/>
/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
/// first (<c>IAuthHexClient</c>) and mirrored here on success.
/// </summary>
public class Role
{
public int RoleId { get; set; }
public Guid AuthRoleId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public bool IsSystemRole { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,11 @@
namespace ERPCore.Domain.Entities;
/// <summary>Join row granting a <see cref="Role"/> visibility of a <see cref="Permission"/> (nav node).</summary>
public class RolePermission
{
public int RoleId { get; set; }
public int PermissionId { get; set; }
public Role? Role { get; set; }
public Permission? Permission { get; set; }
}
+2 -2
View File
@@ -6,9 +6,9 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Serial
{
public long SerialId { get; set; }
public int SerialId { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public string SerialNo { get; set; } = string.Empty;
@@ -10,18 +10,18 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class StockAdjustment
{
public long AdjustmentId { get; set; }
public int AdjustmentId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long ReasonCodeId { get; set; }
public int ReasonCodeId { get; set; }
public ReasonCode? ReasonCode { get; set; }
public AdjustmentStatus Status { get; set; } = AdjustmentStatus.Posted;
public long CreatedBy { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
@@ -7,17 +7,17 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class StockAdjustmentLine
{
public long AdjLineId { get; set; }
public int AdjLineId { get; set; }
public long AdjustmentId { get; set; }
public int AdjustmentId { get; set; }
public StockAdjustment? Adjustment { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public long? BinId { get; set; }
public long? BatchId { get; set; }
public long? SerialId { get; set; }
public int? BinId { get; set; }
public int? BatchId { get; set; }
public int? SerialId { get; set; }
public decimal QtyDelta { get; set; }
}
@@ -10,16 +10,16 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class StockCount
{
public long CountId { get; set; }
public int CountId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public CountType CountType { get; set; }
public CountStatus Status { get; set; } = CountStatus.Draft;
public long CreatedBy { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
@@ -6,15 +6,15 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class StockCountLine
{
public long CountLineId { get; set; }
public int CountLineId { get; set; }
public long CountId { get; set; }
public int CountId { get; set; }
public StockCount? Count { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public long? BinId { get; set; }
public int? BinId { get; set; }
public decimal SystemQty { get; set; }
public decimal? CountedQty { get; set; }
@@ -8,22 +8,22 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class StockLayer
{
public long LayerId { get; set; }
public int LayerId { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public Warehouse? Warehouse { get; set; }
public long? BatchId { get; set; }
public int? BatchId { get; set; }
public Batch? Batch { get; set; }
public long? SerialId { 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 long? GrnLineId { get; set; }
public int? GrnLineId { get; set; }
public GrnLine? GrnLine { get; set; }
public decimal QtyReceived { get; set; }
@@ -11,14 +11,14 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class StockLedger
{
public long LedgerId { get; set; }
public int LedgerId { get; set; }
public long ItemId { get; set; }
public long WarehouseId { get; set; }
public long? BinId { get; set; }
public long? BatchId { get; set; }
public long? SerialId { get; set; }
public long UserId { 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; }
@@ -27,6 +27,6 @@ public class StockLedger
public decimal RunningBalance { get; set; }
public string SourceDocType { get; set; } = string.Empty;
public long SourceDocId { get; set; }
public int SourceDocId { get; set; }
public DateTime CreatedAt { get; set; }
}
@@ -10,18 +10,18 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class StockTransfer
{
public long TransferId { get; set; }
public int TransferId { get; set; }
public string DocNo { get; set; } = string.Empty;
public long SrcWarehouseId { get; set; }
public int SrcWarehouseId { get; set; }
public Warehouse? SrcWarehouse { get; set; }
public long DestWarehouseId { get; set; }
public int DestWarehouseId { get; set; }
public Warehouse? DestWarehouse { get; set; }
public TransferStatus Status { get; set; } = TransferStatus.Draft;
public long CreatedBy { get; set; }
public int CreatedBy { get; set; }
public User? Creator { get; set; }
public DateTime CreatedAt { get; set; }
@@ -12,18 +12,18 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class StockTransferLine
{
public long TransferLineId { get; set; }
public int TransferLineId { get; set; }
public long TransferId { get; set; }
public int TransferId { get; set; }
public StockTransfer? Transfer { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public long? SrcBinId { get; set; }
public long? DestBinId { get; set; }
public long? BatchId { get; set; }
public long? SerialId { 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; }
@@ -0,0 +1,26 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Subcategory — the single optional level below <see cref="Category"/> (FR-MD-04).
/// Replaces the former self-referencing CATEGORY.parent_id tree: the hierarchy is
/// exactly two levels deep and cannot nest further. Referenced optionally by
/// <see cref="Item.SubCategoryId"/>. Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class SubCategory
{
public int SubCategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public int CategoryId { get; set; }
public Category? Category { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,18 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>A child sidebar entry under a <see cref="NavItem"/> (e.g. Products' children).</summary>
public class SubNavItem
{
public int SubNavItemId { get; set; }
public int NavItemId { get; set; }
public string Code { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? Icon { get; set; }
public string? Href { get; set; }
public int SortOrder { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public NavItem? NavItem { get; set; }
}
+1 -1
View File
@@ -6,6 +6,6 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Uom
{
public long UomId { get; set; }
public int UomId { get; set; }
public string Name { get; set; } = string.Empty;
}
@@ -7,15 +7,15 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class UomConversion
{
public long ConversionId { get; set; }
public int ConversionId { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public long FromUomId { get; set; }
public int FromUomId { get; set; }
public Uom? FromUom { get; set; }
public long ToUomId { get; set; }
public int ToUomId { get; set; }
public Uom? ToUom { get; set; }
public decimal Factor { get; set; }
+7 -3
View File
@@ -4,7 +4,7 @@ namespace ERPCore.Domain.Entities;
/// <summary>
/// Application user (FR-X-01) — a **local shadow/projection** of an AuthHex identity.
/// The local <see cref="UserId"/> (long) is what every `createdBy`/`requestedBy`/
/// 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
@@ -13,13 +13,17 @@ namespace ERPCore.Domain.Entities;
public class User
{
/// <summary>Seeded fallback actor for unauthenticated/system operations.</summary>
public const long SystemUserId = 1;
public const int SystemUserId = 1;
public long UserId { get; set; }
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; }
/// <summary>Local shadow <see cref="Role"/> assignment; null until an admin assigns one.</summary>
public int? RoleId { get; set; }
public Role? Role { get; set; }
}
+1 -1
View File
@@ -9,7 +9,7 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Vendor
{
public long VendorId { get; set; }
public int VendorId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? Terms { get; set; }
@@ -12,12 +12,12 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class VendorQuotation
{
public long QuotationId { get; set; }
public int QuotationId { get; set; }
public long RfqId { get; set; }
public int RfqId { get; set; }
public Rfq? Rfq { get; set; }
public long VendorId { get; set; }
public int VendorId { get; set; }
public Vendor? Vendor { get; set; }
public DateTime CreatedAt { get; set; }
@@ -3,12 +3,12 @@ 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 long QuotationLineId { get; set; }
public int QuotationLineId { get; set; }
public long QuotationId { get; set; }
public int QuotationId { get; set; }
public VendorQuotation? Quotation { get; set; }
public long ItemId { get; set; }
public int ItemId { get; set; }
public Item? Item { get; set; }
public decimal UnitPrice { get; set; }
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ERPCore.Domain.Entities;
/// </summary>
public class Warehouse
{
public long WarehouseId { get; set; }
public int WarehouseId { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
-12
View File
@@ -1,12 +0,0 @@
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,14 @@
namespace ERPCore.Domain.Enums;
/// <summary>
/// Whether an item holds stock (FR-MD-01). Values match the <c>stockNature</c> enum in
/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
/// Renamed from <c>ItemType</c> so that name could be taken by the ItemType master
/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9).
/// </summary>
public enum StockNature
{
Stocked,
NonStocked,
Service
}
+2 -2
View File
@@ -5,8 +5,8 @@ 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(
long AuditId, long UserId, string EntityType, long EntityId, AuditAction Action, JsonElement ChangeSet, DateTime CreatedAt);
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(
long JournalId, string SourceDocType, long SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
+42
View File
@@ -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;
}
+28
View File
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Auth;
/// <summary>AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).</summary>
public sealed class AuthHexRoleDto
{
public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
public DateTime CreatedAt { get; set; }
}
public sealed class CreateAuthHexRoleRequest
{
[Required] public string Code { get; set; } = string.Empty;
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
public sealed class UpdateAuthHexRoleRequest
{
[Required] public Guid RoleId { get; set; }
public string? Code { get; set; }
public string? Name { get; set; }
public bool? IsSystemRole { get; set; }
}
+191
View File
@@ -0,0 +1,191 @@
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; }
}
/// <summary>AuthHex's UserType lookup (ERP_Auth_Service/API_DOCUMENTATION.md, listUserTypes).</summary>
public sealed class UserTypeDto
{
public Guid UserTypeId { get; set; }
public string? Code { get; set; }
public string? Description { get; set; }
}
public sealed class SessionDto
{
public string? SessionId { get; set; }
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
{
/// <summary>
/// Optional: AuthHex returns no <c>userId</c> on login, so browsers cannot supply one.
/// When omitted, the controller resolves it from the session token's UserId claim.
/// </summary>
public Guid? UserId { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Brands;
/// <summary>Brand resource (docs/11-BACKEND-PHASE1.md §2.6).</summary>
public sealed record BrandDto(
int BrandId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
public sealed class CreateBrandRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateBrandRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateBrandStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
@@ -1,15 +1,56 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Categories;
/// <summary>Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).</summary>
public sealed record CategoryDto(long CategoryId, string Name, long? ParentId);
// Category (docs/11-BACKEND-PHASE1.md §2.3) ------------------------------------
// The hierarchy is exactly two levels: Category → SubCategory. The former
// self-nesting tree (parentId / ?tree=true / CategoryTreeDto) was removed in
// migration #2 — see docs/10 Part C.1.
/// <summary>Nested category node for <c>GET /categories?tree=true</c>.</summary>
public sealed record CategoryTreeDto(long CategoryId, string Name, long? ParentId, IReadOnlyList<CategoryTreeDto> Children);
/// <summary>Category resource — the top level.</summary>
public sealed record CategoryDto(
int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>Subcategory resource — the single optional level below a category.</summary>
public sealed record SubCategoryDto(
int SubCategoryId, int CategoryId, string Name, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
public sealed class CreateCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
public long? ParentId { get; set; }
}
public sealed class UpdateCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateCategoryStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>Body for <c>POST /categories/{categoryId}/subcategories</c>; the parent comes from the route.</summary>
public sealed class CreateSubCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
/// <summary>
/// Body for <c>PUT /subcategories/{id}</c>. Name only — a subcategory cannot be reparented,
/// since moving one would silently invalidate the category of every item referencing it.
/// </summary>
public sealed class UpdateSubCategoryRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateSubCategoryStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Config;
/// <summary>
/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
/// <see cref="ItemTypesEnabled"/> is advisory (frontend-honoured) — see the entity docs.
/// </summary>
public sealed record ProductConfigDto(
bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
DateTime? UpdatedAt, int? UpdatedBy);
/// <summary>
/// Full replacement of the flags. <c>UpdatedBy</c> is derived from the token, never posted.
/// <para>
/// The flags are <see cref="bool"/>? deliberately: <c>[Required]</c> on a non-nullable bool
/// is a no-op (it always has a value), so a body of <c>{}</c> would bind every flag to
/// <c>false</c> and silently switch all three features off. Nullable makes the requirement
/// actually bind — an omitted flag is a 400, not an accidental disable.
/// </para>
/// </summary>
public sealed class UpdateProductConfigRequest
{
[Required] public bool? SubcategoriesEnabled { get; set; }
[Required] public bool? BrandsEnabled { get; set; }
[Required] public bool? ItemTypesEnabled { get; set; }
}
+20 -15
View File
@@ -6,22 +6,27 @@ namespace ERPCore.Dtos.Grn;
// Responses (docs/11 §4) --------------------------------------------------------
public sealed record GrnLineDto(
long GrnLineId, long? PoLineId, long ItemId, long UomId, long? BinId,
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, long? BatchId);
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
public sealed record GrnDto(
long GrnId, string DocNo, long? PoId, long VendorId, long WarehouseId, GrnStatus Status,
long CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
/// <summary>Row shape for <c>GET /grns</c> — line count instead of the lines themselves.</summary>
public sealed record GrnSummaryDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, int LineCount);
public sealed record CreatedLayerDto(
long LayerId, long ItemId, long WarehouseId, long? BatchId,
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
public sealed record GrnConfirmResultDto(
long GrnId, GrnStatus Status, DateTime PostedAt,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<long> LedgerRefs, PurchaseOrderStatus? PoStatus);
int GrnId, GrnStatus Status, DateTime PostedAt,
IReadOnlyList<CreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs, PurchaseOrderStatus? PoStatus);
public sealed record ReleaseLineResultDto(long GrnLineId, HoldStatus HoldStatus);
public sealed record ReleaseLineResultDto(int GrnLineId, HoldStatus HoldStatus);
// Requests ----------------------------------------------------------------------
@@ -34,10 +39,10 @@ public sealed class BatchInput
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 long? PoLineId { get; set; }
[Required] public long ItemId { get; set; }
[Required] public long UomId { get; set; }
public long? BinId { get; set; }
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; }
@@ -48,10 +53,10 @@ public sealed class CreateGrnLineInput
public sealed class CreateGrnRequest
{
/// <summary>PO to receive against; null for a direct/emergency receipt (FR-GRN-02).</summary>
public long? PoId { get; set; }
public int? PoId { get; set; }
/// <summary>Required for a direct receipt (no PO); ignored otherwise (vendor derives from the PO).</summary>
public long? VendorId { get; set; }
[Required] public long WarehouseId { get; set; }
public int? VendorId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required, MinLength(1)] public List<CreateGrnLineInput> Lines { get; set; } = new();
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.ItemTypes;
/// <summary>
/// Item type resource (docs/11-BACKEND-PHASE1.md §2.7) — a dimension name such as Color
/// or Size. Carries no values and no item linkage: <c>GET /item-types</c> exists to
/// populate the frontend builder's dropdown, and the chosen values are encoded into the
/// client-generated SKU rather than stored (docs/10 Part C.9).
/// </summary>
public sealed record ItemTypeDto(
int ItemTypeId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
public sealed class CreateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateItemTypeRequest
{
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateItemTypeStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
+41 -19
View File
@@ -7,25 +7,35 @@ namespace ERPCore.Dtos.Items;
/// <summary>Row shape for <c>GET /items</c>.</summary>
public sealed record ItemListItemDto(
long ItemId, string Sku, string Name, long CategoryId, long BaseUomId,
long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(long WarehouseId, decimal ReorderPoint, decimal ReorderQty);
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>
/// <summary>
/// Full item resource for <c>GET /items/{id}</c> and create/update responses.
/// <para>
/// <see cref="Conversions"/> is embedded because they are otherwise unreadable: they can
/// only be written via <c>PUT /items/{id}/uom-conversions</c>, which returns them, but no
/// endpoint reads them back — so a detail screen could never show current state before
/// editing. Mirrors how <see cref="Reorder"/> is already inlined.
/// </para>
/// </summary>
public sealed record ItemDetailDto(
long ItemId, string Sku, string Name, string? Description, long CategoryId,
long BaseUomId, long? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
public sealed record UomConversionDto(long ConversionId, long FromUom, long ToUom, decimal Factor);
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(long ItemId, long BaseUomId, IReadOnlyList<UomConversionDto> Conversions);
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);
@@ -33,15 +43,23 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList<ItemReorderDto> Settin
// Request DTOs — narrow: server-controlled fields (status, ids, timestamps)
// are intentionally excluded to prevent over-posting (02-SECURITY B.6 / C.1). ----
// Note: the SKU is generated client-side (it encodes the chosen item-type values, e.g.
// "BL-100-0003"); the server only enforces uniqueness. There is no item-type field here
// by design — items carry no item-type reference (docs/10 Part C.9).
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 long CategoryId { get; set; }
[Required] public long BaseUomId { get; set; }
public long? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[Required] public int CategoryId { get; set; }
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
public int? SubCategoryId { get; set; }
/// <summary>Optional. Rejected when brands are disabled.</summary>
public int? BrandId { get; set; }
[Required] public int BaseUomId { get; set; }
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
}
@@ -51,10 +69,14 @@ 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 long CategoryId { get; set; }
[Required] public long BaseUomId { get; set; }
public long? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(ItemType))] public ItemType ItemType { get; set; }
[Required] public int CategoryId { get; set; }
/// <summary>Optional; must belong to <see cref="CategoryId"/>. Rejected when subcategories are disabled.</summary>
public int? SubCategoryId { get; set; }
/// <summary>Optional. Rejected when brands are disabled.</summary>
public int? BrandId { get; set; }
[Required] public int BaseUomId { get; set; }
public int? DefaultVendorId { get; set; }
[Required, EnumDataType(typeof(StockNature))] public StockNature StockNature { get; set; }
[EnumDataType(typeof(TrackingMode))] public TrackingMode TrackingMode { get; set; } = TrackingMode.None;
[StringLength(20)] public string? TaxClass { get; set; }
}
@@ -66,7 +88,7 @@ public sealed class UpdateItemStatusRequest
public sealed class ReorderSettingInput
{
[Required] public long WarehouseId { get; set; }
[Required] public int WarehouseId { get; set; }
[Range(0, double.MaxValue)] public decimal ReorderPoint { get; set; }
[Range(0, double.MaxValue)] public decimal ReorderQty { get; set; }
}
@@ -78,8 +100,8 @@ public sealed class UpdateReorderRequest
public sealed class UomConversionInput
{
[Required] public long FromUom { get; set; }
[Required] public long ToUom { get; set; }
[Required] public int FromUom { get; set; }
[Required] public int ToUom { get; set; }
[Range(0.000001, double.MaxValue)] public decimal Factor { get; set; }
}
@@ -6,27 +6,27 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.3) ------------------------------------------------------
public sealed record PoLineDto(
long PoLineId, long ItemId, long UomId, long WarehouseId,
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(
long PoId, string DocNo, long VendorId, long? RequisitionId, PurchaseOrderStatus Status,
bool ApprovalRequired, long CreatedBy, DateTime CreatedAt, DateTime? UpdatedAt,
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(
long PoId, string DocNo, long VendorId, PurchaseOrderStatus Status,
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 long ItemId { get; set; }
[Required] public long UomId { get; set; }
[Required] public long WarehouseId { get; set; }
[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; }
@@ -34,15 +34,15 @@ public sealed class CreatePoLineInput
public sealed class CreatePurchaseOrderRequest
{
[Required] public long VendorId { get; set; }
public long? RequisitionId { get; set; }
[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 long VendorId { get; set; }
public long? RequisitionId { get; set; }
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
}
@@ -5,27 +5,32 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.4) ------------------------------------------------------
public sealed record PurchaseReturnLineDto(long ReturnLineId, long? GrnLineId, long ItemId, decimal Qty);
public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int ItemId, decimal Qty);
public sealed record PurchaseReturnDto(
long ReturnId, string DocNo, long VendorId, long WarehouseId, long ReasonCodeId, ReturnStatus Status,
long CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<long> LedgerRefs);
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /purchase-returns</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record PurchaseReturnSummaryDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
public sealed class CreatePurchaseReturnLineInput
{
/// <summary>Original GRN line, for traceability against the receipt.</summary>
public long? GrnLineId { get; set; }
[Required] public long ItemId { get; set; }
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 long VendorId { get; set; }
[Required] public long WarehouseId { get; set; }
[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 long? ReasonCodeId { get; set; }
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreatePurchaseReturnLineInput> Lines { get; set; } = new();
}
@@ -5,20 +5,20 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.1) ------------------------------------------------------
public sealed record RequisitionLineDto(long ReqLineId, long ItemId, decimal Qty, DateOnly? RequiredBy);
public sealed record RequisitionLineDto(int ReqLineId, int ItemId, decimal Qty, DateOnly? RequiredBy);
public sealed record RequisitionDto(
long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy,
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy,
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
public sealed record RequisitionSummaryDto(
long RequisitionId, string DocNo, RequisitionStatus Status, long RequestedBy, DateTime CreatedAt);
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
public sealed class CreateRequisitionLineInput
{
[Required] public long ItemId { get; set; }
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
public DateOnly? RequiredBy { get; set; }
}
+16 -12
View File
@@ -5,46 +5,50 @@ namespace ERPCore.Dtos.Procurement;
// Responses (docs/11 §3.2) ------------------------------------------------------
public sealed record RfqLineDto(long RfqLineId, long ItemId, decimal Qty);
public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
public sealed record RfqDto(
long RfqId, string DocNo, long RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
public sealed record QuotationLineDto(long ItemId, decimal UnitPrice, int LeadDays);
/// <summary>Row shape for <c>GET /rfqs</c> — line/quotation counts instead of the lines themselves.</summary>
public sealed record RfqSummaryDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, int LineCount, int QuotationCount);
public sealed record QuotationLineDto(int ItemId, decimal UnitPrice, int LeadDays);
public sealed record VendorQuotationDto(
long QuotationId, long RfqId, long VendorId, IReadOnlyList<QuotationLineDto> Lines);
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(long VendorId, long QuotationId, decimal UnitPrice, int LeadDays);
public sealed record RfqComparisonRowDto(long ItemId, decimal Qty, IReadOnlyList<RfqComparisonCellDto> Quotes);
public sealed record RfqComparisonDto(long RfqId, IReadOnlyList<long> VendorIds, IReadOnlyList<RfqComparisonRowDto> Rows);
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 long ItemId { get; set; }
[Required] public int ItemId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
public sealed class CreateRfqRequest
{
[Required] public long RequisitionId { get; set; }
[Required] public int RequisitionId { get; set; }
/// <summary>Vendors the RFQ is issued to (validated for existence; quotations reference them).</summary>
public List<long> VendorIds { get; set; } = new();
public List<int> VendorIds { get; set; } = new();
[Required, MinLength(1)] public List<CreateRfqLineInput> Lines { get; set; } = new();
}
public sealed class CreateQuotationLineInput
{
[Required] public long ItemId { get; set; }
[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 long VendorId { get; set; }
[Required] public int VendorId { get; set; }
[Required, MinLength(1)] public List<CreateQuotationLineInput> Lines { get; set; } = new();
}
+6
View File
@@ -0,0 +1,6 @@
namespace ERPCore.Dtos.Rbac;
/// <summary>Response for `GET /api/v1/auth/me` — the frontend's authoritative source
/// for the current user's role and permitted sidebar nav codes (replaces trusting
/// the stale, client-only `roleId` cached in localStorage).</summary>
public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList<string> NavCodes);
+7
View File
@@ -0,0 +1,7 @@
namespace ERPCore.Dtos.Rbac;
public sealed record SubNavItemDto(int SubNavItemId, string Code, string Label, string? Icon, string? Href, int SortOrder);
public sealed record NavItemDto(
int NavItemId, string Code, string Label, string? Icon, string? Href, int SortOrder,
IReadOnlyList<SubNavItemDto> Children);
+34
View File
@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Rbac;
public sealed record RoleDto(
int RoleId, string Code, string Name, bool IsSystemRole, EntityStatus Status,
DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateRoleRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateRoleRequest
{
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
}
public sealed class UpdateRoleStatusRequest
{
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
}
/// <summary>Replaces a role's full permission set (checkbox-tree save from the frontend).</summary>
public sealed class AssignRolePermissionsRequest
{
public List<int> NavItemIds { get; set; } = new();
public List<int> SubNavItemIds { get; set; } = new();
}
public sealed record RolePermissionsDto(int RoleId, List<int> NavItemIds, List<int> SubNavItemIds);
@@ -4,7 +4,7 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Reference;
/// <summary>Reason code (docs/11 §6).</summary>
public sealed record ReasonCodeDto(long ReasonCodeId, string Code, string Description, ReasonContext Context);
public sealed record ReasonCodeDto(int ReasonCodeId, string Code, string Description, ReasonContext Context);
public sealed class CreateReasonCodeRequest
{
+13 -8
View File
@@ -5,27 +5,32 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.5) ------------------------------------------------------
public sealed record AdjustmentLineDto(long AdjLineId, long ItemId, long? BinId, long? BatchId, decimal QtyDelta);
public sealed record AdjustmentLineDto(int AdjLineId, int ItemId, int? BinId, int? BatchId, decimal QtyDelta);
public sealed record AdjustmentDto(
long AdjustmentId, string DocNo, long WarehouseId, long ReasonCodeId, AdjustmentStatus Status,
long CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<long> LedgerRefs);
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<AdjustmentLineDto> Lines, IReadOnlyList<int> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-adjustments</c> — no lines/ledgerRefs (those need a per-row query).</summary>
public sealed record AdjustmentSummaryDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
public sealed class CreateAdjustmentLineInput
{
[Required] public long ItemId { get; set; }
public long? BinId { get; set; }
public long? BatchId { get; set; }
[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 long WarehouseId { get; set; }
[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 long? ReasonCodeId { get; set; }
public int? ReasonCodeId { get; set; }
[Required, MinLength(1)] public List<CreateAdjustmentLineInput> Lines { get; set; } = new();
}
+12 -6
View File
@@ -5,25 +5,31 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.6) ------------------------------------------------------
public sealed record CountLineDto(long CountLineId, long ItemId, long? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
long CountId, string DocNo, long WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
public sealed record CountPostResultDto(long CountId, CountStatus Status, long? AdjustmentId, IReadOnlyList<long> LedgerRefs);
/// <summary>Row shape for <c>GET /stock-counts</c> — line count instead of the lines themselves.</summary>
public sealed record CountSummaryDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record CountPostResultDto(int CountId, CountStatus Status, int? AdjustmentId, IReadOnlyList<int> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateCountRequest
{
[Required] public long WarehouseId { get; set; }
[Required] public int WarehouseId { get; set; }
[Required, EnumDataType(typeof(CountType))] public CountType CountType { get; set; }
[Required, MinLength(1)] public List<long> ItemIds { get; set; } = new();
[Required, MinLength(1)] public List<int> ItemIds { get; set; } = new();
}
public sealed class EnterCountLineInput
{
[Required] public long CountLineId { get; set; }
[Required] public int CountLineId { get; set; }
[Range(0, double.MaxValue)] public decimal CountedQty { get; set; }
}
+1 -1
View File
@@ -2,5 +2,5 @@ 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(
long ItemId, long WarehouseId, decimal Available,
int ItemId, int WarehouseId, decimal Available,
decimal ReorderPoint, decimal ReorderQty, decimal SuggestedRequisitionQty);
+5 -5
View File
@@ -4,19 +4,19 @@ namespace ERPCore.Dtos.Stock;
/// <summary>Stock enquiry (docs/11 §5.1). available = onHand onHold reserved inTransit(out).</summary>
public sealed record StockOnHandDto(
long ItemId, long WarehouseId, decimal OnHand, decimal Available,
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(
long LedgerId, long ItemId, long WarehouseId, long? BinId, long? BatchId, long? SerialId,
int LedgerId, int ItemId, int WarehouseId, int? BinId, int? BatchId, int? SerialId,
Direction Direction, decimal QtyBase, decimal UnitCost, decimal Value, decimal RunningBalance,
string SourceDocType, long SourceDocId, long UserId, DateTime CreatedAt);
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(long LayerId, decimal QtyRemaining, decimal UnitCost, decimal Value, DateTime ReceiptDate);
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(
long ItemId, long WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
int ItemId, int WarehouseId, IReadOnlyList<StockValuationLayerDto> Layers,
decimal TotalQty, decimal TotalValue, string Currency, string CostingMethod);
+19 -14
View File
@@ -6,43 +6,48 @@ namespace ERPCore.Dtos.Stock;
// Responses (docs/11 §5.4) ------------------------------------------------------
public sealed record TransferLineDto(
long TransferLineId, long ItemId, long? SrcBinId, long? DestBinId, long? BatchId, decimal Qty, decimal QtyReceived);
int TransferLineId, int ItemId, int? SrcBinId, int? DestBinId, int? BatchId, decimal Qty, decimal QtyReceived);
public sealed record TransferDto(
long TransferId, string DocNo, long SrcWarehouseId, long DestWarehouseId,
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
public sealed record ConsumedLayerDto(long LayerId, decimal QtyConsumed, decimal UnitCost);
/// <summary>Row shape for <c>GET /stock-transfers</c> — line count instead of the lines themselves.</summary>
public sealed record TransferSummaryDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
TransferStatus Status, int CreatedBy, DateTime CreatedAt, int LineCount);
public sealed record ConsumedLayerDto(int LayerId, decimal QtyConsumed, decimal UnitCost);
public sealed record DispatchResultDto(
long TransferId, TransferStatus Status, IReadOnlyList<ConsumedLayerDto> ConsumedLayers, IReadOnlyList<long> LedgerRefs);
int TransferId, TransferStatus Status, IReadOnlyList<ConsumedLayerDto> ConsumedLayers, IReadOnlyList<int> LedgerRefs);
public sealed record TransferCreatedLayerDto(long LayerId, long WarehouseId, decimal QtyReceived, decimal UnitCost);
public sealed record TransferCreatedLayerDto(int LayerId, int WarehouseId, decimal QtyReceived, decimal UnitCost);
public sealed record ReceiveResultDto(
long TransferId, TransferStatus Status, IReadOnlyList<TransferCreatedLayerDto> CreatedLayers, IReadOnlyList<long> LedgerRefs);
int TransferId, TransferStatus Status, IReadOnlyList<TransferCreatedLayerDto> CreatedLayers, IReadOnlyList<int> LedgerRefs);
// Requests ----------------------------------------------------------------------
public sealed class CreateTransferLineInput
{
[Required] public long ItemId { get; set; }
public long? SrcBinId { get; set; }
public long? DestBinId { get; set; }
public long? BatchId { get; set; }
[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 long SrcWarehouseId { get; set; }
[Required] public long DestWarehouseId { get; set; }
[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 long TransferLineId { get; set; }
[Required] public int TransferLineId { get; set; }
[Range(0.0001, double.MaxValue)] public decimal Qty { get; set; }
}
+1 -1
View File
@@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Uoms;
/// <summary>UOM resource (docs/11-BACKEND-PHASE1.md §2.2).</summary>
public sealed record UomDto(long UomId, string Name);
public sealed record UomDto(int UomId, string Name);
public sealed class CreateUomRequest
{
+35
View File
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Users;
public sealed record ManagedUserDto(
int UserId, string Username, string DisplayName, EntityStatus Status,
int? RoleId, string? RoleCode, string? RoleName);
/// <summary>
/// Creates a user in both backends: forwards to AuthHex's `registerUser` (source
/// of truth for credentials), then mirrors the account into ERPCore's local
/// shadow `User` row immediately (rather than waiting for next-login JIT
/// provisioning). AuthHex emails the generated/supplied password to `Email`.
/// </summary>
public sealed class CreateUserRequest
{
[Required, StringLength(100)] public string Username { get; set; } = string.Empty;
[Required, StringLength(200)] public string FullName { get; set; } = string.Empty;
[Required] public int RoleId { get; set; }
[Required] public Guid UserTypeId { get; set; }
[Required, EmailAddress] public string Email { get; set; } = string.Empty;
public string? Nic { get; set; }
public string? MobileNumber { get; set; }
/// <summary>Left empty to auto-generate (AuthHex emails it to <see cref="Email"/>).</summary>
public string? Password { get; set; }
}
public sealed class UpdateUserRoleRequest
{
[Required] public int RoleId { get; set; }
}
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ERPCore.Dtos.Vendors;
/// <summary>Vendor resource (docs/11-BACKEND-PHASE1.md §2.4).</summary>
public sealed record VendorDto(
long VendorId, string Code, string Name, string? Terms, string? TaxReg,
int VendorId, string Code, string Name, string? Terms, string? TaxReg,
string Currency, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
public sealed class CreateVendorRequest
@@ -3,10 +3,10 @@ using System.ComponentModel.DataAnnotations;
namespace ERPCore.Dtos.Warehouses;
/// <summary>Warehouse resource (docs/11-BACKEND-PHASE1.md §2.5).</summary>
public sealed record WarehouseDto(long WarehouseId, string Code, string Name);
public sealed record WarehouseDto(int WarehouseId, string Code, string Name);
/// <summary>Bin/location resource (docs/11 §2.5).</summary>
public sealed record BinDto(long BinId, long WarehouseId, string Code, string? BinType);
public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType);
public sealed class CreateWarehouseRequest
{
@@ -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,188 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
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&lt;IAuthHexClient, AuthHexClient&gt;`) 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
{
// WhenWritingNull: AuthHex's dispatcher reads payload fields as raw JsonElements and some
// (e.g. RoleManager's isSystemRole) call type-specific getters like GetBoolean() that throw
// on an explicit JSON null rather than treating it as "absent" — omit null properties instead
// of serializing them, so unset nullable request fields behave as ContainsKey == false upstream.
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
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<UserTypeDto>> ListUserTypesAsync(CancellationToken ct)
=> CallAsync<List<UserTypeDto>>("user", "listUserTypes", new { }, 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);
// ---- RoleManager --------------------------------------------------
public Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "createRole", request, null, ct);
public Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct)
=> CallAsync<List<AuthHexRoleDto>>("role", "listRoles", new { }, null, ct);
public Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "getRole", new { roleId }, null, ct);
public Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
=> CallAsync<AuthHexRoleDto>("role", "updateRole", request, null, ct);
public Task DeleteRoleAsync(Guid roleId, CancellationToken ct)
=> CallVoidAsync("role", "deleteRole", new { roleId }, 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!;
}
}

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