Compare commits

...

19 Commits

Author SHA1 Message Date
ImanThiyanga a1b3985469 feat: Implement fixed sale price functionality for items
- Added a toggle for fixed sale price vs stock value in the item creation form.
- Introduced validation to ensure all variants have a price greater than 0 when fixed price mode is selected.
- Updated the item model to include a nullable salePrice field, which is used for sales only and does not affect GRN/FIFO/ledger.
- Enhanced the GRN page to allow off-PO items and included a refresh button to update the item list without reloading the page.
- Updated documentation to reflect changes in item pricing and GRN handling.
2026-07-23 12:12:32 +05:30
ImanThiyanga 5cf9588728 role code error fixed 2026-07-22 16:14:49 +05:30
ImanThiyanga 9260b4de9b Merge branch 'production' into Dev 2026-07-22 06:12:28 +00:00
ImanThiyanga 46e971ef25 Merge pull request 'Feat/po grn' (#12) from feat/po-GRN into Dev
Reviewed-on: #12
2026-07-21 11:04:58 +00:00
ImanThiyanga f8b9ee8f6c Merge branch 'feat/po-GRN' of https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core into feat/po-GRN 2026-07-21 16:27:05 +05:30
ImanThiyanga 4108062416 ui fixes 2026-07-21 16:26:32 +05:30
ImanThiyanga 03bc85b788 feat(procurement): enhance purchase order and GRN functionalities
- Updated purchase order descriptions for clarity on draft and submission processes.
- Implemented submit and delete functionalities for draft purchase orders, allowing users to manage their orders more effectively.
- Added discount and VAT fields to GRN lines, enabling better cost tracking and reporting.
- Enhanced validation for GRN lines to ensure discount and VAT percentages are within acceptable ranges.
- Updated API to support new functionalities, including submitting and deleting purchase orders.
- Improved UI components for better user experience in managing purchase orders and GRNs.
- Documented changes in security and backend phase documentation to reflect new processes and requirements.
2026-07-21 16:26:32 +05:30
ImanThiyanga 9158cd8c82 ui fixes 2026-07-21 11:55:40 +05:30
ImanThiyanga 951961b798 Merge pull request 'feat: enhance product management features' (#11) from products-fixers into Dev
Reviewed-on: #11
2026-07-21 04:40:18 +00:00
ImanThiyanga f02c89b3cb feat(procurement): enhance purchase order and GRN functionalities
- Updated purchase order descriptions for clarity on draft and submission processes.
- Implemented submit and delete functionalities for draft purchase orders, allowing users to manage their orders more effectively.
- Added discount and VAT fields to GRN lines, enabling better cost tracking and reporting.
- Enhanced validation for GRN lines to ensure discount and VAT percentages are within acceptable ranges.
- Updated API to support new functionalities, including submitting and deleting purchase orders.
- Improved UI components for better user experience in managing purchase orders and GRNs.
- Documented changes in security and backend phase documentation to reflect new processes and requirements.
2026-07-21 10:08:32 +05:30
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 baaf51ba99 Merge pull request 'Dev' (#7) from Dev into production
Reviewed-on: #7
2026-07-15 10:08:07 +00:00
215 changed files with 18695 additions and 4093 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/
@@ -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);
}
}
@@ -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,6 +14,14 @@ public sealed class GrnsController : ApiControllerBase
public GrnsController(IGrnService grns) => _grns = grns;
/// <summary>List GRNs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<GrnSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<GrnSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] GrnStatus? status, [FromQuery] int? poId,
[FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _grns.ListAsync(query, status, poId, vendorId, warehouseId, ct));
[HttpGet("{grnId:int}")]
[ProducesResponseType(typeof(GrnDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -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();
}
}
@@ -21,9 +21,11 @@ public sealed class ItemsController : ApiControllerBase
[FromQuery] PageQuery query,
[FromQuery] EntityStatus? status,
[FromQuery] int? categoryId,
[FromQuery] int? subCategoryId,
[FromQuery] int? brandId,
[FromQuery] TrackingMode? trackingMode,
CancellationToken ct)
=> Ok(await _items.ListAsync(query, status, categoryId, 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:int}")]
@@ -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);
}
}
@@ -57,6 +57,25 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
/// <summary>Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.</summary>
[HttpPost("{poId:int}/submit")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PurchaseOrderDto>> Submit(int poId, CancellationToken ct)
=> Ok(await _pos.SubmitAsync(poId, ct));
/// <summary>Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).</summary>
[HttpDelete("{poId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Delete(int poId, CancellationToken ct)
{
await _pos.DeleteAsync(poId, ct);
return NoContent();
}
/// <summary>Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.</summary>
[HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
@@ -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,8 +16,9 @@ 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:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
@@ -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,6 +14,13 @@ public sealed class RfqsController : ApiControllerBase
public RfqsController(IRfqService rfqs) => _rfqs = rfqs;
/// <summary>List RFQs, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<RfqSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<RfqSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] RfqStatus? status, CancellationToken ct)
=> Ok(await _rfqs.ListAsync(query, status, ct));
[HttpGet("{rfqId:int}")]
[ProducesResponseType(typeof(RfqDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -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)]
+16 -2
View File
@@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase
public async Task<ActionResult<StockOnHandDto>> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
/// <summary>On-hand across every stocked (item, warehouse) pair; both filters optional.</summary>
[HttpGet("on-hand/list")]
[ProducesResponseType(typeof(PagedResponse<StockOnHandDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockOnHandDto>>> OnHandList(
[FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
/// <summary>
/// Immutable movement history. <c>sourceDocType</c>/<c>sourceDocId</c> answer "what did
/// this document post?" — the ledger's document reference is polymorphic, so there is
/// no FK to navigate instead (docs/10 C.9).
/// </summary>
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse<StockLedgerRowDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<StockLedgerRowDto>>> Ledger(
[FromQuery] int? itemId, [FromQuery] int? warehouseId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to, [FromQuery] PageQuery query, CancellationToken ct)
=> Ok(await _stock.GetLedgerAsync(itemId, warehouseId, from, to, query, ct));
[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)]
@@ -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,6 +14,13 @@ public sealed class StockCountsController : ApiControllerBase
public StockCountsController(ICountService counts) => _counts = counts;
/// <summary>List counts, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<CountSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<CountSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] CountStatus? status, [FromQuery] int? warehouseId, CancellationToken ct)
=> Ok(await _counts.ListAsync(query, status, warehouseId, ct));
[HttpGet("{countId:int}")]
[ProducesResponseType(typeof(CountDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -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,6 +14,14 @@ public sealed class StockTransfersController : ApiControllerBase
public StockTransfersController(ITransferService transfers) => _transfers = transfers;
/// <summary>List transfers, newest first.</summary>
[HttpGet]
[ProducesResponseType(typeof(PagedResponse<TransferSummaryDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResponse<TransferSummaryDto>>> List(
[FromQuery] PageQuery query, [FromQuery] TransferStatus? status,
[FromQuery] int? srcWarehouseId, [FromQuery] int? destWarehouseId, CancellationToken ct)
=> Ok(await _transfers.ListAsync(query, status, srcWarehouseId, destWarehouseId, ct));
[HttpGet("{transferId:int}")]
[ProducesResponseType(typeof(TransferDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -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));
}
+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; }
}
+15 -5
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 int CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public int? 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>();
}
+30 -3
View File
@@ -3,9 +3,13 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the PO-derived cost for
/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
/// for direct receipts. <see cref="ReceivedValue"/> = qty × unitCost.
/// GRN line (FR-GRN-04..08). <see cref="UnitCost"/> is the gross cost received at:
/// entered on the line, defaulting to the PO price when omitted (a per-receipt price
/// override is now permitted — see docs/02-SECURITY C.3, revised). <see cref="PoUnitPrice"/>
/// snapshots the PO price at receipt so the variance survives later PO edits.
/// <see cref="NetUnitCost"/> = unitCost after trade discount — this is what the FIFO layer
/// costs at (VAT never enters stock value; it is recoverable input tax).
/// <see cref="ReceivedValue"/> = qty × netUnitCost (after discount, before VAT).
/// <see cref="HoldStatus"/> gates issuability. Model: docs/10 Part C.3.
/// </summary>
public class GrnLine
@@ -31,7 +35,30 @@ public class GrnLine
public Batch? Batch { get; set; }
public decimal Qty { get; set; }
/// <summary>Gross unit cost received at (entered, or PO price when omitted).</summary>
public decimal UnitCost { get; set; }
/// <summary>Snapshot of the PO line price at receipt; null for direct receipts.</summary>
public decimal? PoUnitPrice { get; set; }
/// <summary>Trade discount percentage (0100), entered.</summary>
public decimal DiscountPct { get; set; }
/// <summary>UnitCost × (1 DiscountPct/100) — the inventory (FIFO layer) cost.</summary>
public decimal NetUnitCost { get; set; }
/// <summary>VAT percentage (0100), entered. Recoverable — does not affect stock value.</summary>
public decimal VatPct { get; set; }
/// <summary>Qty × NetUnitCost × VatPct/100.</summary>
public decimal VatAmount { get; set; }
/// <summary>Qty × NetUnitCost (after discount, before VAT).</summary>
public decimal ReceivedValue { get; set; }
/// <summary>Qty × NetUnitCost + VatAmount — payable to the vendor.</summary>
public decimal LineTotal { get; set; }
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
}
+16 -1
View File
@@ -17,15 +17,30 @@ public class Item
public int CategoryId { get; set; }
public Category? Category { get; set; }
/// <summary>Optional second level below <see cref="CategoryId"/>; must belong to it.</summary>
public int? SubCategoryId { get; set; }
public SubCategory? SubCategory { get; set; }
public int? BrandId { get; set; }
public Brand? Brand { get; set; }
public int BaseUomId { get; set; }
public Uom? BaseUom { get; set; }
public int? DefaultVendorId { get; set; }
public Vendor? DefaultVendor { get; set; }
public ItemType ItemType { get; set; }
public StockNature StockNature { get; set; }
public TrackingMode TrackingMode { get; set; }
public string? TaxClass { get; set; }
/// <summary>
/// Optional fixed selling price used by Sales only. <c>null</c> means "use stock value"
/// (the item is sold at its FIFO stock cost at sale time); a value is the fixed sale price.
/// Never enters costing/GRN/FIFO (docs/10 Part C.1, C.9).
/// </summary>
public decimal? SalePrice { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
@@ -0,0 +1,31 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or
/// Material.
/// <para>
/// <b>Deliberately unlinked.</b> Nothing references this entity and it references
/// nothing: there is no value table and no join to <see cref="Item"/>. Its only job is
/// to feed the frontend's item-builder dropdown via <c>GET /item-types</c>. The chosen
/// values (Red, S, M) are encoded by the client into the generated SKU
/// (e.g. <c>BL-100-0003</c>) and are never stored or parsed server-side — the item list
/// is the record of what was built. See the accepted trade-off in docs/10 Part C.9.
/// </para>
/// Not to be confused with <see cref="Enums.StockNature"/> (Stocked/NonStocked/Service),
/// which is what the old <c>ItemType</c> enum became.
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ItemType
{
public int ItemTypeId { get; set; }
public string Name { get; set; } = string.Empty;
public EntityStatus Status { get; set; } = EntityStatus.Active;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
@@ -0,0 +1,22 @@
using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
/// <summary>
/// A top-level sidebar entry (mirrors the frontend's hardcoded nav list,
/// components/Layouts/AppSidebar.tsx). Seeded to match the current app routes;
/// per-role visibility is controlled via <see cref="Permission"/>/<see cref="RolePermission"/>,
/// not by editing these rows through the UI.
/// </summary>
public class NavItem
{
public int NavItemId { get; set; }
public string Code { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string? Icon { get; set; }
public string? Href { get; set; }
public int SortOrder { get; set; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
public ICollection<SubNavItem> Children { get; set; } = new List<SubNavItem>();
}
@@ -0,0 +1,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; }
}
+1 -1
View File
@@ -22,7 +22,7 @@ public class PoLine
public Warehouse? Warehouse { get; set; }
public decimal Qty { get; set; }
public decimal UnitPrice { get; set; }
public decimal UnitPrice { get; set; }//
public decimal Tax { get; set; }
public decimal QtyReceived { get; set; }
}
@@ -0,0 +1,35 @@
namespace ERPCore.Domain.Entities;
/// <summary>
/// Product configuration (FR-MD-11) — a <b>singleton row</b> (single-tenant, docs/00-CORE §1)
/// gating optional product master-data features.
/// <para>
/// <see cref="SubcategoriesEnabled"/> and <see cref="BrandsEnabled"/> are enforced
/// server-side: an Item write carrying a subcategory/brand while the flag is off is
/// rejected with <c>CONFIG_DISABLED</c>. <see cref="ItemTypesEnabled"/> is
/// <b>advisory only</b> — items carry no item-type reference (see <see cref="ItemType"/>),
/// so there is nothing on a write to reject; the frontend honours it by hiding the
/// builder's type section. Reads are never gated, so existing data stays visible after a
/// flag is switched off.
/// </para>
/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
/// </summary>
public class ProductConfig
{
/// <summary>Always 1 — the singleton row's id.</summary>
public const int SingletonId = 1;
public int ConfigId { get; set; }
public bool SubcategoriesEnabled { get; set; } = true;
public bool BrandsEnabled { get; set; } = true;
public bool ItemTypesEnabled { get; set; } = true;
public DateTime? UpdatedAt { get; set; }
public int? UpdatedBy { get; set; }
public User? UpdatedByUser { get; set; }
/// <summary>PostgreSQL xmin-backed optimistic concurrency token (ETag source).</summary>
public uint RowVersion { get; set; }
}
+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; }
}
@@ -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; }
}
+4
View File
@@ -22,4 +22,8 @@ public class User
/// <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; }
}
-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
}
+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(int CategoryId, string Name, int? 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(int CategoryId, string Name, int? 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 int? 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; }
}
+18 -2
View File
@@ -7,12 +7,20 @@ namespace ERPCore.Dtos.Grn;
public sealed record GrnLineDto(
int GrnLineId, int? PoLineId, int ItemId, int UomId, int? BinId,
decimal Qty, decimal UnitCost, decimal ReceivedValue, HoldStatus HoldStatus, int? BatchId);
decimal Qty, decimal UnitCost, decimal? PoUnitPrice,
decimal DiscountPct, decimal NetUnitCost, decimal VatPct, decimal VatAmount,
decimal ReceivedValue, decimal LineTotal, decimal PriceVariance,
HoldStatus HoldStatus, int? BatchId);
public sealed record GrnDto(
int GrnId, string DocNo, int? PoId, int VendorId, int WarehouseId, GrnStatus Status,
int CreatedBy, DateTime CreatedAt, DateTime? PostedAt, IReadOnlyList<GrnLineDto> Lines);
/// <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(
int LayerId, int ItemId, int WarehouseId, int? BatchId,
decimal QtyReceived, decimal QtyRemaining, decimal UnitCost, DateTime ReceiptDate);
@@ -39,8 +47,16 @@ public sealed class CreateGrnLineInput
[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>
/// <summary>
/// Gross unit cost. Required for direct (no-PO) receipts. For a PO line it is an optional
/// per-receipt price override — when 0/omitted the PO line price is used; when supplied it
/// wins and a variance is recorded against the PO snapshot (docs/02-SECURITY C.3, revised).
/// </summary>
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
/// <summary>Trade discount percentage (0100). Reduces the inventory cost.</summary>
[Range(0, 100)] public decimal DiscountPct { get; set; }
/// <summary>VAT percentage (0100). Recoverable — does not affect stock value.</summary>
[Range(0, 100)] public decimal VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
}
@@ -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; }
}
+34 -8
View File
@@ -7,18 +7,28 @@ namespace ERPCore.Dtos.Items;
/// <summary>Row shape for <c>GET /items</c>.</summary>
public sealed record ItemListItemDto(
int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status);
int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, decimal? SalePrice, EntityStatus Status);
/// <summary>A single per-warehouse reorder policy row.</summary>
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
/// <summary>Full item resource for <c>GET /items/{id}</c> and create/update responses.</summary>
/// <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(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, decimal? SalePrice, EntityStatus Status, IReadOnlyList<ItemReorderDto> Reorder,
IReadOnlyList<UomConversionDto> Conversions,
DateTime CreatedAt, DateTime? UpdatedAt);
/// <summary>UOM conversion row (docs/11 §2.2).</summary>
@@ -33,17 +43,27 @@ 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 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(ItemType))] public ItemType ItemType { 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; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
}
public sealed class UpdateItemRequest
@@ -52,11 +72,17 @@ public sealed class UpdateItemRequest
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
[StringLength(1000)] public string? Description { 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(ItemType))] public ItemType ItemType { 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; }
/// <summary>Optional fixed sale price (Sales only). Null ⇒ sell at stock/FIFO value.</summary>
[Range(0, double.MaxValue)] public decimal? SalePrice { get; set; }
}
public sealed class UpdateItemStatusRequest
@@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List<CreatePoLineInput> Lines { get; set; } = new();
/// <summary>
/// When true the PO is created in <c>Draft</c> (editable/deletable, not yet issued).
/// When false (default) it auto-approves on creation, preserving the Requisition→PO
/// and RFQ→PO flows unchanged (docs/11 §3.3).
/// </summary>
public bool SaveAsDraft { get; set; }
}
public sealed class UpdatePurchaseOrderRequest
@@ -9,7 +9,12 @@ public sealed record PurchaseReturnLineDto(int ReturnLineId, int? GrnLineId, int
public sealed record PurchaseReturnDto(
int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
int CreatedBy, IReadOnlyList<PurchaseReturnLineDto> Lines, IReadOnlyList<int> LedgerRefs);
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 ----------------------------------------------------------------------
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
DateTime CreatedAt, IReadOnlyList<RequisitionLineDto> Lines);
public sealed record RequisitionSummaryDto(
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt);
int RequisitionId, string DocNo, RequisitionStatus Status, int RequestedBy, DateTime CreatedAt, int LineCount);
// Requests — server sets docNo, status, requestedBy (audit actor), timestamps ----
@@ -10,6 +10,10 @@ public sealed record RfqLineDto(int RfqLineId, int ItemId, decimal Qty);
public sealed record RfqDto(
int RfqId, string DocNo, int RequisitionId, RfqStatus Status, IReadOnlyList<RfqLineDto> Lines);
/// <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(
+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);
@@ -11,6 +11,11 @@ public sealed record AdjustmentDto(
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
+7 -1
View File
@@ -8,7 +8,13 @@ namespace ERPCore.Dtos.Stock;
public sealed record CountLineDto(int CountLineId, int ItemId, int? BinId, decimal SystemQty, decimal? CountedQty, decimal? Variance);
public sealed record CountDto(
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status, IReadOnlyList<CountLineDto> Lines);
int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList<CountLineDto> Lines);
/// <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);
+6 -1
View File
@@ -10,7 +10,12 @@ public sealed record TransferLineDto(
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
TransferStatus Status, IReadOnlyList<TransferLineDto> Lines);
TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList<TransferLineDto> Lines);
/// <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);
+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);
@@ -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!;
}
}
@@ -0,0 +1,37 @@
using ERPCore.Dtos.Auth;
namespace ERPCore.Infra.Auth.AuthHex;
/// <summary>Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1).</summary>
public sealed class AuthHexEnvelope<T>
{
public int StatusCode { get; set; }
public bool Success { get; set; }
public string? Message { get; set; }
public T? Data { get; set; }
}
/// <summary>Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1).</summary>
public sealed class AuthHexRequestBody
{
public string FunctionName { get; set; } = string.Empty;
public object Payload { get; set; } = new { };
public string Reference { get; set; } = string.Empty;
}
/// <summary>
/// Wire shape shared by every session-issuing AuthHex function (register, login,
/// OTP login verify, refresh, alt OTP verify). Carries the raw tokens — kept
/// internal so they never leak into a public Dtos/Auth response; AuthController
/// extracts them into httpOnly cookies and returns only <see cref="AuthSessionResponse"/>.
/// </summary>
public sealed class AuthHexSessionResult
{
public string? AccessToken { get; set; }
public string? RefreshToken { get; set; }
public int ExpiresIn { get; set; }
public UserSummaryDto? User { get; set; }
public string? ReferenceNumber { get; set; }
public Guid? UserId { get; set; }
public bool? Verified { get; set; }
}
@@ -0,0 +1,51 @@
using ERPCore.Dtos.Auth;
namespace ERPCore.Infra.Auth.AuthHex;
/// <summary>
/// Typed client for the external AuthHex identity service (API_REFERENCE.md).
/// Hides AuthHex's `functionName` dispatcher entirely — callers get one method
/// per function. Internal: only Services/Auth consumes this; the controller
/// boundary only ever sees Dtos/Auth types.
/// </summary>
public interface IAuthHexClient
{
// UserManager (POST /api/user)
Task<AuthHexSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct);
Task<AuthHexSessionResult> LoginAsync(LoginRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
Task<AuthHexSessionResult> RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct);
Task<List<UserTypeDto>> ListUserTypesAsync(CancellationToken ct);
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct);
Task ChangeUserStatusAsync(bool isActive, string bearerToken, CancellationToken ct);
Task LockUserAccountAsync(bool isLocked, string bearerToken, CancellationToken ct);
Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct);
Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct);
Task LogoutUserAsync(Guid userId, CancellationToken ct);
Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct);
Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct);
Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct);
Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct);
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct);
Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct);
// RecoveryManager (POST /api/recovery)
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
Task<VerifyRecoveryOtpResponse> VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct);
Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct);
// AltOptionManager (POST /api/alt)
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
// RoleManager (POST /api/role) — AuthHex is the source of truth for Role;
// ERPCore mirrors the result into a local shadow Role row (see RoleService).
Task<AuthHexRoleDto> CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
Task<List<AuthHexRoleDto>> ListRolesAsync(CancellationToken ct);
Task<AuthHexRoleDto> GetRoleAsync(Guid roleId, CancellationToken ct);
Task<AuthHexRoleDto> UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
}
@@ -18,6 +18,18 @@ public static class JwtAuthExtensions
/// <summary>Authorization policy applied to every v1 controller (via ApiControllerBase).</summary>
public const string ErpAccessPolicy = "ErpAccess";
/// <summary>httpOnly cookie AuthController writes the AuthHex access token into (docs/02-SECURITY.md §B.2).</summary>
public const string AccessTokenCookie = "erp_at";
/// <summary>httpOnly cookie AuthController writes the AuthHex refresh token into, scoped to the refresh route.</summary>
public const string RefreshTokenCookie = "erp_rt";
/// <summary>Non-httpOnly CSRF cookie for the double-submit check on cookie-authenticated mutations.</summary>
public const string CsrfCookie = "XSRF-TOKEN";
/// <summary>Header the frontend echoes the CSRF cookie value back through.</summary>
public const string CsrfHeader = "X-XSRF-TOKEN";
public static IServiceCollection AddErpJwtAuth(this IServiceCollection services, IConfiguration config)
{
var issuer = config["Auth:Issuer"];
@@ -49,6 +61,23 @@ public static class JwtAuthExtensions
ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 },
ClockSkew = TimeSpan.FromSeconds(30)
};
// BFF cookie fallback: browsers hitting ERPCore through AuthController's
// httpOnly cookie session carry no Authorization header. Only used when
// that header is absent, so Bearer callers (Swagger, service-to-service,
// AuthHexClient forwarding) are unaffected.
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (string.IsNullOrEmpty(context.Token) &&
context.Request.Cookies.TryGetValue(AccessTokenCookie, out var cookieToken) &&
!string.IsNullOrEmpty(cookieToken))
{
context.Token = cookieToken;
}
return Task.CompletedTask;
}
};
});
services.AddAuthorization(options =>
@@ -0,0 +1,35 @@
using ERPCore.System.Errors;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ERPCore.Infra.Auth;
/// <summary>
/// Double-submit-cookie CSRF check for cookie-authenticated, state-changing
/// AuthController actions (docs/02-SECURITY.md §B.2). Bearer-token callers
/// (Swagger, service-to-service) are exempt — CSRF only threatens requests a
/// browser sends automatically via cookies. Requires the <c>X-XSRF-TOKEN</c>
/// header to match the non-httpOnly <c>XSRF-TOKEN</c> cookie AuthController
/// issues alongside the session cookies.
/// </summary>
public sealed class ValidateCsrfAttribute : Attribute, IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var request = context.HttpContext.Request;
if (!string.IsNullOrEmpty(request.Headers.Authorization.ToString()))
{
await next();
return;
}
if (!request.Cookies.TryGetValue(JwtAuthExtensions.CsrfCookie, out var cookieToken) || string.IsNullOrEmpty(cookieToken))
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "Missing CSRF cookie.", 403);
var headerToken = request.Headers[JwtAuthExtensions.CsrfHeader].ToString();
if (string.IsNullOrEmpty(headerToken) || !string.Equals(headerToken, cookieToken, StringComparison.Ordinal))
throw new DomainException(ErrorCodes.CsrfTokenMismatch, "CSRF token mismatch.", 403);
await next();
}
}
@@ -0,0 +1,29 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class BrandConfiguration : IEntityTypeConfiguration<Brand>
{
public void Configure(EntityTypeBuilder<Brand> builder)
{
builder.ToTable("brands");
builder.HasKey(b => b.BrandId);
builder.Property(b => b.Name).IsRequired().HasMaxLength(200);
builder.HasIndex(b => b.Name).IsUnique();
builder.Property(b => b.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(b => b.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(b => b.RowVersion).IsRowVersion();
builder.HasIndex(b => b.Status);
}
}
@@ -1,4 +1,5 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -12,12 +13,17 @@ public sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
builder.HasKey(c => c.CategoryId);
builder.Property(c => c.Name).IsRequired().HasMaxLength(200);
builder.HasIndex(c => c.Name).IsUnique();
builder.HasOne(c => c.Parent)
.WithMany(c => c.Children)
.HasForeignKey(c => c.ParentId)
.OnDelete(DeleteBehavior.Restrict);
builder.Property(c => c.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasIndex(c => c.ParentId);
builder.Property(c => c.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasIndex(c => c.Status);
}
}
@@ -37,7 +37,13 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration<GrnLine>
builder.Property(l => l.Qty).HasPrecision(18, 4);
builder.Property(l => l.UnitCost).HasPrecision(18, 6);
builder.Property(l => l.PoUnitPrice).HasPrecision(18, 6);
builder.Property(l => l.DiscountPct).HasPrecision(9, 4);
builder.Property(l => l.NetUnitCost).HasPrecision(18, 6);
builder.Property(l => l.VatPct).HasPrecision(9, 4);
builder.Property(l => l.VatAmount).HasPrecision(18, 4);
builder.Property(l => l.ReceivedValue).HasPrecision(18, 4);
builder.Property(l => l.LineTotal).HasPrecision(18, 4);
builder.Property(l => l.HoldStatus).HasConversion<string>().HasMaxLength(20).IsRequired();
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
@@ -19,7 +19,10 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.Property(i => i.Description).HasMaxLength(1000);
builder.Property(i => i.TaxClass).HasMaxLength(20);
builder.Property(i => i.ItemType)
// Sales-only fixed selling price; nullable (null ⇒ sell at stock/FIFO value).
builder.Property(i => i.SalePrice).HasPrecision(18, 4);
builder.Property(i => i.StockNature)
.HasConversion<string>().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
.HasConversion<string>().HasMaxLength(20).IsRequired();
@@ -37,6 +40,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
.HasForeignKey(i => i.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.SubCategory)
.WithMany()
.HasForeignKey(i => i.SubCategoryId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.Brand)
.WithMany()
.HasForeignKey(i => i.BrandId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(i => i.BaseUom)
.WithMany()
.HasForeignKey(i => i.BaseUomId)
@@ -49,5 +62,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration<Item>
builder.HasIndex(i => i.Status);
builder.HasIndex(i => i.CategoryId);
builder.HasIndex(i => i.BrandId);
}
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the ItemType master (Color/Size/Material). Note there are deliberately no
/// relationships here — nothing references this table (docs/10 Part C.9).
/// </summary>
public sealed class ItemTypeConfiguration : IEntityTypeConfiguration<ItemType>
{
public void Configure(EntityTypeBuilder<ItemType> builder)
{
builder.ToTable("item_types");
builder.HasKey(t => t.ItemTypeId);
builder.Property(t => t.Name).IsRequired().HasMaxLength(200);
builder.HasIndex(t => t.Name).IsUnique();
builder.Property(t => t.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(t => t.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(t => t.RowVersion).IsRowVersion();
builder.HasIndex(t => t.Status);
}
}
@@ -0,0 +1,42 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Seeded to mirror the frontend's hardcoded sidebar
/// (ERP-core/Frontend/erp-system/components/Layouts/AppSidebar.tsx). Codes here
/// must match the <c>code</c> given to each frontend nav entry.
/// </summary>
public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
{
public void Configure(EntityTypeBuilder<NavItem> builder)
{
builder.ToTable("nav_items");
builder.HasKey(n => n.NavItemId);
builder.Property(n => n.Code).IsRequired().HasMaxLength(50);
builder.HasIndex(n => n.Code).IsUnique();
builder.Property(n => n.Label).IsRequired().HasMaxLength(100);
builder.Property(n => n.Icon).HasMaxLength(50);
builder.Property(n => n.Href).HasMaxLength(200);
builder.Property(n => n.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasData(
new NavItem { NavItemId = 1, Code = "dashboard", Label = "Dashboard", Href = "/dashboard", SortOrder = 1 },
new NavItem { NavItemId = 2, Code = "products", Label = "Products", Href = "/dashboard/products", SortOrder = 2 },
new NavItem { NavItemId = 3, Code = "vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 3 },
new NavItem { NavItemId = 4, Code = "procurement", Label = "Procurement", Href = "/dashboard/procurement", SortOrder = 4 },
new NavItem { NavItemId = 5, Code = "receiving", Label = "Receiving", Href = "/dashboard/receiving/grn", SortOrder = 5 },
new NavItem { NavItemId = 6, Code = "stock", Label = "Stock", Href = "/dashboard/stock", SortOrder = 6 },
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
);
}
}
@@ -0,0 +1,51 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// One row per <see cref="NavItem"/>/<see cref="SubNavItem"/>, seeded in lockstep
/// with <see cref="NavItemConfiguration"/>/<see cref="SubNavItemConfiguration"/>.
/// </summary>
public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permission>
{
public void Configure(EntityTypeBuilder<Permission> builder)
{
builder.ToTable("permissions");
builder.HasKey(p => p.PermissionId);
builder.Property(p => p.Code).IsRequired().HasMaxLength(80);
builder.HasIndex(p => p.Code).IsUnique();
builder.HasOne(p => p.NavItem).WithMany()
.HasForeignKey(p => p.NavItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(p => p.SubNavItem).WithMany()
.HasForeignKey(p => p.SubNavItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasData(
new Permission { PermissionId = 1, Code = "NAV:dashboard", NavItemId = 1 },
new Permission { PermissionId = 2, Code = "NAV:products", NavItemId = 2 },
new Permission { PermissionId = 3, Code = "NAV:vendors", NavItemId = 3 },
new Permission { PermissionId = 4, Code = "NAV:procurement", NavItemId = 4 },
new Permission { PermissionId = 5, Code = "NAV:receiving", NavItemId = 5 },
new Permission { PermissionId = 6, Code = "NAV:stock", NavItemId = 6 },
new Permission { PermissionId = 7, Code = "NAV:warehouses", NavItemId = 7 },
new Permission { PermissionId = 8, Code = "NAV:orders", NavItemId = 8 },
new Permission { PermissionId = 9, Code = "NAV:settings", NavItemId = 9 },
new Permission { PermissionId = 10, Code = "NAV:help", NavItemId = 10 },
new Permission { PermissionId = 11, Code = "NAV:products.item", SubNavItemId = 1 },
new Permission { PermissionId = 12, Code = "NAV:products.category", SubNavItemId = 2 },
new Permission { PermissionId = 13, Code = "NAV:products.brand", SubNavItemId = 3 },
new Permission { PermissionId = 14, Code = "NAV:products.item-type", SubNavItemId = 4 },
new Permission { PermissionId = 15, Code = "NAV:products.uom", SubNavItemId = 5 },
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
);
}
}
@@ -0,0 +1,37 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>
/// Configures the singleton product-configuration row (FR-MD-11). The check constraint
/// is what makes "singleton" a database guarantee rather than a convention.
/// </summary>
public sealed class ProductConfigConfiguration : IEntityTypeConfiguration<ProductConfig>
{
public void Configure(EntityTypeBuilder<ProductConfig> builder)
{
// The column is created as quoted PascalCase ("ConfigId"), so the constraint must
// quote it too — an unquoted config_id would fold to a column that does not exist.
builder.ToTable("product_config", t =>
t.HasCheckConstraint("ck_product_config_singleton", $"\"ConfigId\" = {ProductConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
// The id is fixed, never generated — there is exactly one row, seeded by DataSeeder.
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.SubcategoriesEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.BrandsEnabled).IsRequired().HasDefaultValue(true);
builder.Property(c => c.ItemTypesEnabled).IsRequired().HasDefaultValue(true);
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,33 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class RoleConfiguration : IEntityTypeConfiguration<Role>
{
public void Configure(EntityTypeBuilder<Role> builder)
{
builder.ToTable("roles");
builder.HasKey(r => r.RoleId);
builder.Property(r => r.AuthRoleId).HasColumnName("auth_role_id").IsRequired();
builder.HasIndex(r => r.AuthRoleId).IsUnique();
builder.Property(r => r.Code).IsRequired().HasMaxLength(50);
builder.HasIndex(r => r.Code).IsUnique();
builder.Property(r => r.Name).IsRequired().HasMaxLength(200);
builder.Property(r => r.IsSystemRole).IsRequired().HasDefaultValue(false);
builder.Property(r => r.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(r => r.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(r => r.RowVersion).IsRowVersion();
}
}
@@ -0,0 +1,19 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class RolePermissionConfiguration : IEntityTypeConfiguration<RolePermission>
{
public void Configure(EntityTypeBuilder<RolePermission> builder)
{
builder.ToTable("role_permissions");
builder.HasKey(rp => new { rp.RoleId, rp.PermissionId });
builder.HasOne(rp => rp.Role).WithMany()
.HasForeignKey(rp => rp.RoleId).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(rp => rp.Permission).WithMany()
.HasForeignKey(rp => rp.PermissionId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,35 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SubCategoryConfiguration : IEntityTypeConfiguration<SubCategory>
{
public void Configure(EntityTypeBuilder<SubCategory> builder)
{
builder.ToTable("subcategories");
builder.HasKey(s => s.SubCategoryId);
builder.Property(s => s.Name).IsRequired().HasMaxLength(200);
builder.Property(s => s.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.Property(s => s.CreatedAt).IsRequired();
// PostgreSQL xmin system column as the optimistic concurrency token (ETag).
builder.Property(s => s.RowVersion).IsRowVersion();
builder.HasOne(s => s.Category)
.WithMany(c => c.SubCategories)
.HasForeignKey(s => s.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
// Names need only be unique within their parent category.
builder.HasIndex(s => new { s.CategoryId, s.Name }).IsUnique();
builder.HasIndex(s => s.Status);
}
}
@@ -0,0 +1,43 @@
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavItem>
{
public void Configure(EntityTypeBuilder<SubNavItem> builder)
{
builder.ToTable("sub_nav_items");
builder.HasKey(n => n.SubNavItemId);
builder.Property(n => n.Code).IsRequired().HasMaxLength(50);
builder.HasIndex(n => n.Code).IsUnique();
builder.Property(n => n.Label).IsRequired().HasMaxLength(100);
builder.Property(n => n.Icon).HasMaxLength(50);
builder.Property(n => n.Href).HasMaxLength(200);
builder.Property(n => n.Status)
.HasConversion<string>().HasMaxLength(20).IsRequired()
.HasDefaultValue(EntityStatus.Active);
builder.HasOne(n => n.NavItem).WithMany(n => n.Children)
.HasForeignKey(n => n.NavItemId).OnDelete(DeleteBehavior.Cascade);
builder.HasData(
new SubNavItem { SubNavItemId = 1, NavItemId = 2, Code = "products.item", Label = "Item", Href = "/dashboard/products", SortOrder = 1 },
new SubNavItem { SubNavItemId = 2, NavItemId = 2, Code = "products.category", Label = "Category", Href = "/dashboard/products/categories", SortOrder = 2 },
new SubNavItem { SubNavItemId = 3, NavItemId = 2, Code = "products.brand", Label = "Brand", Href = "/dashboard/products/brands", SortOrder = 3 },
new SubNavItem { SubNavItemId = 4, NavItemId = 2, Code = "products.item-type", Label = "Item Type", Href = "/dashboard/products/item-types", SortOrder = 4 },
new SubNavItem { SubNavItemId = 5, NavItemId = 2, Code = "products.uom", Label = "UOM", Href = "/dashboard/products/uoms", SortOrder = 5 },
new SubNavItem { SubNavItemId = 6, NavItemId = 2, Code = "products.configuration", Label = "Configuration", Href = "/dashboard/products/settings", SortOrder = 6 },
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
// Procurement (NavItemId 4) children — mirror the hub page order.
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
);
}
}
@@ -23,6 +23,10 @@ public sealed class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(u => u.AuthUserId).HasColumnName("auth_user_id");
builder.HasIndex(u => u.AuthUserId).IsUnique();
// Local shadow Role assignment (nullable — unset until an admin assigns one).
builder.HasOne(u => u.Role).WithMany()
.HasForeignKey(u => u.RoleId).OnDelete(DeleteBehavior.Restrict);
// Seeded fallback audit actor while auth is deferred (§6).
builder.HasData(new User
{
@@ -12,6 +12,13 @@ namespace ERPCore.Infra.Persistence;
/// </summary>
public static class DataSeeder
{
/// <summary>
/// Item type names the frontend builder has always assumed exist (they were hardcoded
/// while it ran on mock data). Seeded so the dropdown is not empty on a fresh database;
/// users add their own (e.g. Material) from the admin screen.
/// </summary>
private static readonly string[] StandardItemTypes = ["Color", "Size"];
private static readonly (string Code, string Description, ReasonContext Context)[] StandardReasonCodes =
[
("DMG", "Damage", ReasonContext.Adjustment),
@@ -26,6 +33,15 @@ public static class DataSeeder
];
public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default)
{
var dirty = await SeedReasonCodesAsync(db, ct);
dirty |= await SeedItemTypesAsync(db, ct);
dirty |= await SeedProductConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
}
private static async Task<bool> SeedReasonCodesAsync(ErpDbContext db, CancellationToken ct)
{
var existing = await db.ReasonCodes
.Select(r => new { r.Context, r.Code })
@@ -37,9 +53,44 @@ public static class DataSeeder
.Select(r => new ReasonCode { Code = r.Code, Description = r.Description, Context = r.Context })
.ToList();
if (toAdd.Count == 0) return;
if (toAdd.Count == 0) return false;
db.ReasonCodes.AddRange(toAdd);
await db.SaveChangesAsync(ct);
return true;
}
private static async Task<bool> SeedItemTypesAsync(ErpDbContext db, CancellationToken ct)
{
var have = await db.ItemTypes.Select(t => t.Name).ToListAsync(ct);
var toAdd = StandardItemTypes
.Where(name => !have.Contains(name, StringComparer.OrdinalIgnoreCase))
.Select(name => new ItemType { Name = name, Status = EntityStatus.Active, CreatedAt = DateTime.UtcNow })
.ToList();
if (toAdd.Count == 0) return false;
db.ItemTypes.AddRange(toAdd);
return true;
}
/// <summary>
/// Ensures the singleton product-config row exists (FR-MD-11). Migration #2 inserts it,
/// so this only fires for a database built some other way — but without it every Item
/// write would 404 on the missing config, so it is worth the one query at startup.
/// New deployments start with all features on.
/// </summary>
private static async Task<bool> SeedProductConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.ProductConfig.AnyAsync(c => c.ConfigId == ProductConfig.SingletonId, ct)) return false;
db.ProductConfig.Add(new ProductConfig
{
ConfigId = ProductConfig.SingletonId,
SubcategoriesEnabled = true,
BrandsEnabled = true,
ItemTypesEnabled = true
});
return true;
}
}
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
/// <summary>
/// EF Core context for the ERP database. The 38 Phase 1 entities and their
/// EF Core context for the ERP database. The 42 Phase 1 entities and their
/// <see cref="IEntityTypeConfiguration{TEntity}"/> configurations are added under
/// Domain/Entities and Infra/Persistence/Configurations as they are implemented.
/// The authoritative schema lives in docs/10-BACKEND-PHASE1.md — do not invent it here.
@@ -23,6 +23,10 @@ public class ErpDbContext : DbContext
// --- Master Data (docs/10 Part C.1) ---
public DbSet<Category> Categories => Set<Category>();
public DbSet<SubCategory> SubCategories => Set<SubCategory>();
public DbSet<Brand> Brands => Set<Brand>();
/// <summary>Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).</summary>
public DbSet<ItemType> ItemTypes => Set<ItemType>();
public DbSet<Uom> Uoms => Set<Uom>();
public DbSet<UomConversion> UomConversions => Set<UomConversion>();
public DbSet<Item> Items => Set<Item>();
@@ -30,11 +34,20 @@ public class ErpDbContext : DbContext
public DbSet<Vendor> Vendors => Set<Vendor>();
public DbSet<Warehouse> Warehouses => Set<Warehouse>();
public DbSet<Bin> Bins => Set<Bin>();
/// <summary>Singleton row (FR-MD-11).</summary>
public DbSet<ProductConfig> ProductConfig => Set<ProductConfig>();
// --- Cross-cutting (docs/10 Part C.7) ---
public DbSet<User> Users => Set<User>();
public DbSet<NumberSequence> NumberSequences => Set<NumberSequence>();
// --- RBAC / sidebar (docs/10 Part C.8) ---
public DbSet<Role> Roles => Set<Role>();
public DbSet<NavItem> NavItems => Set<NavItem>();
public DbSet<SubNavItem> SubNavItems => Set<SubNavItem>();
public DbSet<Permission> Permissions => Set<Permission>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
// --- Procurement (docs/10 Part C.2) ---
public DbSet<Requisition> Requisitions => Set<Requisition>();
public DbSet<RequisitionLine> RequisitionLines => Set<RequisitionLine>();
@@ -0,0 +1,442 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <summary>
/// Adds the Brand / SubCategory / ItemType masters and the singleton product config,
/// and converts CATEGORY from a self-nesting tree into a fixed two-level
/// Category → SubCategory hierarchy (docs/10 Part C.1).
/// <para>
/// <b>This migration carries data, not just DDL.</b> The scaffolded version dropped
/// <c>categories.ParentId</c> outright, which would have silently flattened every
/// child category into a root and left items pointing at what is now a top-level
/// category — losing the parent entirely. The hand-written steps below (marked
/// "data migration") move child categories into <c>subcategories</c> and repoint items
/// onto the correct (category, subcategory) pair before the column goes away.
/// </para>
/// </summary>
public partial class AddBrandsSubcategoriesItemTypesAndProductConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// NOTE: the ParentId drop is deliberately deferred to the bottom of this method —
// the data migration reads it. Order here is load-bearing.
migrationBuilder.RenameColumn(
name: "ItemType",
table: "items",
newName: "StockNature");
migrationBuilder.AddColumn<int>(
name: "BrandId",
table: "items",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SubCategoryId",
table: "items",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "categories",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<string>(
name: "Status",
table: "categories",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Active");
migrationBuilder.AddColumn<DateTime>(
name: "UpdatedAt",
table: "categories",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<uint>(
name: "xmin",
table: "categories",
type: "xid",
rowVersion: true,
nullable: false,
defaultValue: 0u);
migrationBuilder.CreateTable(
name: "brands",
columns: table => new
{
BrandId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_brands", x => x.BrandId);
});
migrationBuilder.CreateTable(
name: "item_types",
columns: table => new
{
ItemTypeId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_item_types", x => x.ItemTypeId);
});
migrationBuilder.CreateTable(
name: "product_config",
columns: table => new
{
ConfigId = table.Column<int>(type: "integer", nullable: false),
SubcategoriesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
BrandsEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
ItemTypesEnabled = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
UpdatedBy = table.Column<int>(type: "integer", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_product_config", x => x.ConfigId);
table.CheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
table.ForeignKey(
name: "FK_product_config_users_UpdatedBy",
column: x => x.UpdatedBy,
principalTable: "users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "subcategories",
columns: table => new
{
SubCategoryId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CategoryId = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subcategories", x => x.SubCategoryId);
table.ForeignKey(
name: "FK_subcategories_categories_CategoryId",
column: x => x.CategoryId,
principalTable: "categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Restrict);
});
// ---------------------------------------------------------------------------
// DATA MIGRATION — must run before ParentId is dropped.
// ---------------------------------------------------------------------------
// Existing categories predate CreatedAt; the added column defaulted them to
// 0001-01-01. Stamp them with the migration time instead of a sentinel date.
migrationBuilder.Sql(@"
UPDATE categories SET ""CreatedAt"" = NOW() AT TIME ZONE 'utc';
");
// Carry the old category id alongside each new subcategory so items can be
// repointed by join below. Dropped again once the repoint is done.
migrationBuilder.Sql(@"
ALTER TABLE subcategories ADD COLUMN legacy_category_id integer;
");
// Walk the old tree to its roots. The previous model allowed unlimited nesting,
// but the new one is exactly two levels — so a category at any depth below the
// root collapses into a subcategory of its ROOT ancestor (a grandchild cannot
// become a subcategory of its immediate parent, since that parent is itself
// ceasing to be a category).
migrationBuilder.Sql(@"
WITH RECURSIVE tree AS (
SELECT ""CategoryId"", ""ParentId"", ""Name"", ""CategoryId"" AS root_id
FROM categories
WHERE ""ParentId"" IS NULL
UNION ALL
SELECT c.""CategoryId"", c.""ParentId"", c.""Name"", t.root_id
FROM categories c
JOIN tree t ON c.""ParentId"" = t.""CategoryId""
)
INSERT INTO subcategories (""Name"", ""CategoryId"", ""Status"", ""CreatedAt"", legacy_category_id)
SELECT t.""Name"", t.root_id, 'Active', NOW() AT TIME ZONE 'utc', t.""CategoryId""
FROM tree t
WHERE t.""ParentId"" IS NOT NULL;
");
// Repoint items: an item that pointed at a child category now carries the root
// category plus the subcategory it actually meant.
migrationBuilder.Sql(@"
UPDATE items i
SET ""SubCategoryId"" = s.""SubCategoryId"",
""CategoryId"" = s.""CategoryId""
FROM subcategories s
WHERE s.legacy_category_id = i.""CategoryId"";
");
// The self-FK must go before the delete, or RESTRICT rejects removing a parent
// whose own child row is still present.
migrationBuilder.DropForeignKey(
name: "FK_categories_categories_ParentId",
table: "categories");
// Every non-root category now lives in `subcategories`, and no item references
// one any more (repointed above), so the rows can go.
migrationBuilder.Sql(@"
DELETE FROM categories WHERE ""ParentId"" IS NOT NULL;
ALTER TABLE subcategories DROP COLUMN legacy_category_id;
");
migrationBuilder.DropIndex(
name: "IX_categories_ParentId",
table: "categories");
migrationBuilder.DropColumn(
name: "ParentId",
table: "categories");
// Seed the singleton config (FR-MD-11) — all features on. Item writes read this
// row, so it must exist before the app serves a single request.
migrationBuilder.Sql(@"
INSERT INTO product_config (""ConfigId"", ""SubcategoriesEnabled"", ""BrandsEnabled"", ""ItemTypesEnabled"")
VALUES (1, TRUE, TRUE, TRUE)
ON CONFLICT (""ConfigId"") DO NOTHING;
");
// ---------------------------------------------------------------------------
migrationBuilder.CreateIndex(
name: "IX_items_BrandId",
table: "items",
column: "BrandId");
migrationBuilder.CreateIndex(
name: "IX_items_SubCategoryId",
table: "items",
column: "SubCategoryId");
migrationBuilder.CreateIndex(
name: "IX_categories_Name",
table: "categories",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_categories_Status",
table: "categories",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_brands_Name",
table: "brands",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_brands_Status",
table: "brands",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_item_types_Name",
table: "item_types",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_item_types_Status",
table: "item_types",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_product_config_UpdatedBy",
table: "product_config",
column: "UpdatedBy");
migrationBuilder.CreateIndex(
name: "IX_subcategories_CategoryId_Name",
table: "subcategories",
columns: new[] { "CategoryId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_subcategories_Status",
table: "subcategories",
column: "Status");
migrationBuilder.AddForeignKey(
name: "FK_items_brands_BrandId",
table: "items",
column: "BrandId",
principalTable: "brands",
principalColumn: "BrandId",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_items_subcategories_SubCategoryId",
table: "items",
column: "SubCategoryId",
principalTable: "subcategories",
principalColumn: "SubCategoryId",
onDelete: ReferentialAction.Restrict);
}
/// <summary>
/// Reverses the schema change and puts the subcategory data back where it came from.
/// <para>
/// The scaffolded version simply dropped <c>subcategories</c>, which would have
/// discarded exactly what <see cref="Up"/> preserved. Instead each subcategory is
/// restored as a child category and its items are repointed back onto it. This is
/// not perfectly lossless: the old tree's depth is gone (a former grandchild comes
/// back as a direct child of its root), and Brand data cannot survive a schema that
/// has nowhere to put it.
/// </para>
/// </summary>
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_items_brands_BrandId",
table: "items");
migrationBuilder.DropForeignKey(
name: "FK_items_subcategories_SubCategoryId",
table: "items");
// Restore the parent column + self-FK first so subcategories have somewhere to
// land, then move them back before the table is dropped.
migrationBuilder.AddColumn<int>(
name: "ParentId",
table: "categories",
type: "integer",
nullable: true);
// ---------------------------------------------------------------------------
// DATA MIGRATION (reverse) — must run before `subcategories` is dropped.
// ---------------------------------------------------------------------------
migrationBuilder.Sql(@"
ALTER TABLE categories ADD COLUMN legacy_subcategory_id integer;
");
// Each subcategory becomes a child category again under the same parent.
migrationBuilder.Sql(@"
INSERT INTO categories (""Name"", ""ParentId"", ""CreatedAt"", ""Status"", legacy_subcategory_id)
SELECT s.""Name"", s.""CategoryId"", s.""CreatedAt"", s.""Status"", s.""SubCategoryId""
FROM subcategories s;
");
// Items that carried a subcategory point back at the restored child category.
migrationBuilder.Sql(@"
UPDATE items i
SET ""CategoryId"" = c.""CategoryId""
FROM categories c
WHERE c.legacy_subcategory_id = i.""SubCategoryId"";
");
migrationBuilder.Sql(@"
ALTER TABLE categories DROP COLUMN legacy_subcategory_id;
");
// ---------------------------------------------------------------------------
migrationBuilder.DropTable(
name: "brands");
migrationBuilder.DropTable(
name: "item_types");
migrationBuilder.DropTable(
name: "product_config");
migrationBuilder.DropTable(
name: "subcategories");
migrationBuilder.DropIndex(
name: "IX_items_BrandId",
table: "items");
migrationBuilder.DropIndex(
name: "IX_items_SubCategoryId",
table: "items");
migrationBuilder.DropIndex(
name: "IX_categories_Name",
table: "categories");
migrationBuilder.DropIndex(
name: "IX_categories_Status",
table: "categories");
migrationBuilder.DropColumn(
name: "BrandId",
table: "items");
migrationBuilder.DropColumn(
name: "SubCategoryId",
table: "items");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "categories");
migrationBuilder.DropColumn(
name: "Status",
table: "categories");
migrationBuilder.DropColumn(
name: "UpdatedAt",
table: "categories");
migrationBuilder.DropColumn(
name: "xmin",
table: "categories");
migrationBuilder.RenameColumn(
name: "StockNature",
table: "items",
newName: "ItemType");
// ParentId itself was re-added at the top of this method, ahead of the reverse
// data migration that populates it.
migrationBuilder.CreateIndex(
name: "IX_categories_ParentId",
table: "categories",
column: "ParentId");
migrationBuilder.AddForeignKey(
name: "FK_categories_categories_ParentId",
table: "categories",
column: "ParentId",
principalTable: "categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Restrict);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class ini2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddRolesNavPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "RoleId",
table: "users",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "nav_items",
columns: table => new
{
NavItemId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
},
constraints: table =>
{
table.PrimaryKey("PK_nav_items", x => x.NavItemId);
});
migrationBuilder.CreateTable(
name: "roles",
columns: table => new
{
RoleId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
auth_role_id = table.Column<Guid>(type: "uuid", nullable: false),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
IsSystemRole = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_roles", x => x.RoleId);
});
migrationBuilder.CreateTable(
name: "sub_nav_items",
columns: table => new
{
SubNavItemId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
NavItemId = table.Column<int>(type: "integer", nullable: false),
Code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
Label = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Icon = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Href = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active")
},
constraints: table =>
{
table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId);
table.ForeignKey(
name: "FK_sub_nav_items_nav_items_NavItemId",
column: x => x.NavItemId,
principalTable: "nav_items",
principalColumn: "NavItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "permissions",
columns: table => new
{
PermissionId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
NavItemId = table.Column<int>(type: "integer", nullable: true),
SubNavItemId = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_permissions", x => x.PermissionId);
table.ForeignKey(
name: "FK_permissions_nav_items_NavItemId",
column: x => x.NavItemId,
principalTable: "nav_items",
principalColumn: "NavItemId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_permissions_sub_nav_items_SubNavItemId",
column: x => x.SubNavItemId,
principalTable: "sub_nav_items",
principalColumn: "SubNavItemId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "role_permissions",
columns: table => new
{
RoleId = table.Column<int>(type: "integer", nullable: false),
PermissionId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId });
table.ForeignKey(
name: "FK_role_permissions_permissions_PermissionId",
column: x => x.PermissionId,
principalTable: "permissions",
principalColumn: "PermissionId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_role_permissions_roles_RoleId",
column: x => x.RoleId,
principalTable: "roles",
principalColumn: "RoleId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[,]
{
{ 1, "dashboard", "/dashboard", null, "Dashboard", 1 },
{ 2, "products", "/dashboard/products", null, "Products", 2 },
{ 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 },
{ 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 },
{ 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 },
{ 6, "stock", "/dashboard/stock", null, "Stock", 6 },
{ 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 },
{ 8, "orders", "/dashboard/orders", null, "Orders", 8 },
{ 9, "settings", "/dashboard/settings", null, "Settings", 9 },
{ 10, "help", "/dashboard/help", null, "Help", 10 }
});
migrationBuilder.UpdateData(
table: "users",
keyColumn: "UserId",
keyValue: 1,
column: "RoleId",
value: null);
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 1, "NAV:dashboard", 1, null },
{ 2, "NAV:products", 2, null },
{ 3, "NAV:vendors", 3, null },
{ 4, "NAV:procurement", 4, null },
{ 5, "NAV:receiving", 5, null },
{ 6, "NAV:stock", 6, null },
{ 7, "NAV:warehouses", 7, null },
{ 8, "NAV:orders", 8, null },
{ 9, "NAV:settings", 9, null },
{ 10, "NAV:help", 10, null }
});
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 1, "products.item", "/dashboard/products", null, "Item", 2, 1 },
{ 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 },
{ 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 },
{ 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 },
{ 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 },
{ 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 },
{ 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 },
{ 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 11, "NAV:products.item", null, 1 },
{ 12, "NAV:products.category", null, 2 },
{ 13, "NAV:products.brand", null, 3 },
{ 14, "NAV:products.item-type", null, 4 },
{ 15, "NAV:products.uom", null, 5 },
{ 16, "NAV:products.configuration", null, 6 },
{ 17, "NAV:settings.roles", null, 7 },
{ 18, "NAV:settings.users", null, 8 }
});
migrationBuilder.CreateIndex(
name: "IX_users_RoleId",
table: "users",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_nav_items_Code",
table: "nav_items",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_permissions_Code",
table: "permissions",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_permissions_NavItemId",
table: "permissions",
column: "NavItemId");
migrationBuilder.CreateIndex(
name: "IX_permissions_SubNavItemId",
table: "permissions",
column: "SubNavItemId");
migrationBuilder.CreateIndex(
name: "IX_role_permissions_PermissionId",
table: "role_permissions",
column: "PermissionId");
migrationBuilder.CreateIndex(
name: "IX_roles_auth_role_id",
table: "roles",
column: "auth_role_id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_roles_Code",
table: "roles",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sub_nav_items_Code",
table: "sub_nav_items",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sub_nav_items_NavItemId",
table: "sub_nav_items",
column: "NavItemId");
migrationBuilder.AddForeignKey(
name: "FK_users_roles_RoleId",
table: "users",
column: "RoleId",
principalTable: "roles",
principalColumn: "RoleId",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_users_roles_RoleId",
table: "users");
migrationBuilder.DropTable(
name: "role_permissions");
migrationBuilder.DropTable(
name: "permissions");
migrationBuilder.DropTable(
name: "roles");
migrationBuilder.DropTable(
name: "sub_nav_items");
migrationBuilder.DropTable(
name: "nav_items");
migrationBuilder.DropIndex(
name: "IX_users_RoleId",
table: "users");
migrationBuilder.DropColumn(
name: "RoleId",
table: "users");
}
}
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -1,10 +1,12 @@
using System.Text.Json.Serialization;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.UoW;
using ERPCore.Repositories;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services;
using ERPCore.Services.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.Services.Stock;
using ERPCore.System.Errors;
@@ -35,6 +37,17 @@ builder.Services.AddExceptionHandler<DomainExceptionHandler>();
// Auth: validate external AuthHex RS256 tokens + ERP door policy (docs/10 A.4)
builder.Services.AddErpJwtAuth(builder.Configuration);
// AuthController proxy → AuthHex (docs/11 §2.0)
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(c =>
{
var baseUrl = builder.Configuration["AuthHex:BaseUrl"]
?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
c.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<IAuthUserService, AuthUserService>();
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
builder.Services.AddHttpContextAccessor();
@@ -49,9 +62,16 @@ builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IItemService, ItemService>();
builder.Services.AddScoped<IUomService, UomService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IBrandService, BrandService>();
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
builder.Services.AddScoped<IVendorService, VendorService>();
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
// RBAC / sidebar: Role (shadow of AuthHex) + Permission assignment + user management
builder.Services.AddScoped<IRoleService, RoleService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
// Cross-cutting + procurement services (docs/11 §3)
builder.Services.AddScoped<INumberSequenceService, NumberSequenceService>();
builder.Services.AddScoped<IRequisitionService, RequisitionService>();
+54 -8
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -23,6 +24,7 @@ public sealed class AdjustmentService : IAdjustmentService
private readonly IRepository<Warehouse> _warehouses;
private readonly IRepository<Item> _items;
private readonly IRepository<ReasonCode> _reasonCodes;
private readonly IRepository<StockLedger> _ledger;
private readonly IStockMutator _mutator;
private readonly INumberSequenceService _numbers;
private readonly ICurrentUser _currentUser;
@@ -30,19 +32,62 @@ public sealed class AdjustmentService : IAdjustmentService
public AdjustmentService(
IRepository<StockAdjustment> adjustments, IRepository<Warehouse> warehouses, IRepository<Item> items,
IRepository<ReasonCode> reasonCodes, IStockMutator mutator, INumberSequenceService numbers,
ICurrentUser currentUser, IUnitOfWork uow)
IRepository<ReasonCode> reasonCodes, IRepository<StockLedger> ledger, IStockMutator mutator,
INumberSequenceService numbers, ICurrentUser currentUser, IUnitOfWork uow)
{
_adjustments = adjustments;
_warehouses = warehouses;
_items = items;
_reasonCodes = reasonCodes;
_ledger = ledger;
_mutator = mutator;
_numbers = numbers;
_currentUser = currentUser;
_uow = uow;
}
public async Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default)
{
var q = _adjustments.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(a => EF.Functions.ILike(a.DocNo, $"%{term}%"));
}
if (warehouseId is not null) q = q.Where(a => a.WarehouseId == warehouseId);
if (reasonCodeId is not null) q = q.Where(a => a.ReasonCodeId == reasonCodeId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(a => a.AdjustmentId)
.Skip(query.Skip).Take(query.PageSize)
.Select(a => new AdjustmentSummaryDto(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status,
a.CreatedBy, a.CreatedAt, a.Lines.Count))
.ToListAsync(ct);
return PagedResponse<AdjustmentSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default)
{
var adjustment = await _adjustments.Query().AsNoTracking()
.Include(a => a.Lines)
.FirstOrDefaultAsync(a => a.AdjustmentId == adjustmentId, ct);
if (adjustment is null) return null;
// The ledger reference is polymorphic (docs/10 C.9) — there is no FK to follow,
// so the refs this adjustment posted are recovered by source-doc lookup.
var ledgerRefs = await _ledger.Query().AsNoTracking()
.Where(l => l.SourceDocType == DocumentTypes.Adjustment && l.SourceDocId == adjustmentId)
.OrderBy(l => l.LedgerId)
.Select(l => l.LedgerId)
.ToListAsync(ct);
return ToDto(adjustment, ledgerRefs);
}
public async Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default)
{
if (request.ReasonCodeId is null)
@@ -94,11 +139,12 @@ public sealed class AdjustmentService : IAdjustmentService
return (entity, refs);
}, ct);
return new AdjustmentDto(
adjustment.AdjustmentId, adjustment.DocNo, adjustment.WarehouseId, adjustment.ReasonCodeId,
adjustment.Status, adjustment.CreatedBy, adjustment.CreatedAt,
adjustment.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs.Select(l => l.LedgerId).ToList());
return ToDto(adjustment, ledgerRefs.Select(l => l.LedgerId).ToList());
}
private static AdjustmentDto ToDto(StockAdjustment a, IReadOnlyList<int> ledgerRefs) => new(
a.AdjustmentId, a.DocNo, a.WarehouseId, a.ReasonCodeId, a.Status, a.CreatedBy, a.CreatedAt,
a.Lines.OrderBy(l => l.AdjLineId)
.Select(l => new AdjustmentLineDto(l.AdjLineId, l.ItemId, l.BinId, l.BatchId, l.QtyDelta)).ToList(),
ledgerRefs);
}
@@ -0,0 +1,41 @@
using ERPCore.Dtos.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Services.Auth;
/// <inheritdoc cref="IAuthAltService"/>
public sealed class AuthAltService : IAuthAltService
{
private readonly IAuthHexClient _authHex;
public AuthAltService(IAuthHexClient authHex) => _authHex = authHex;
public Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default)
=> _authHex.IsAvailableAsync(request, ct);
public Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct = default)
=> _authHex.SendOtpAsync(request, ct);
public async Task<OtpAuthSessionResult> VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default)
{
var result = await _authHex.VerifyAltOtpAsync(request, ct);
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
return new OtpAuthSessionResult
{
AccessToken = result.AccessToken,
RefreshToken = result.RefreshToken,
Body = new OtpLoginVerifiedResponse
{
ReferenceNumber = result.ReferenceNumber,
UserId = result.UserId,
Verified = result.Verified ?? true,
User = result.User,
ExpiresIn = result.ExpiresIn
}
};
}
}
@@ -0,0 +1,25 @@
using ERPCore.Dtos.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services.Auth;
/// <inheritdoc cref="IAuthRecoveryService"/>
public sealed class AuthRecoveryService : IAuthRecoveryService
{
private readonly IAuthHexClient _authHex;
public AuthRecoveryService(IAuthHexClient authHex) => _authHex = authHex;
public Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default)
=> _authHex.ForgotPasswordAsync(request, ct);
public Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default)
=> _authHex.VerifyRecoveryOtpAsync(request, ct);
public Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordAsync(request, ct);
public Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default)
=> _authHex.ResetPasswordWithTokenAsync(request, ct);
}
@@ -0,0 +1,25 @@
using ERPCore.Dtos.Auth;
namespace ERPCore.Services.Auth;
/// <summary>
/// Carries a freshly issued AuthHex session from a Services/Auth method back to
/// AuthController. Never serialized directly — the controller pulls
/// AccessToken/RefreshToken into httpOnly cookies (see AuthCookieWriter) and
/// returns only <see cref="Body"/> in the response.
/// </summary>
public sealed class AuthSessionResult
{
public required string AccessToken { get; init; }
public required string RefreshToken { get; init; }
public required AuthSessionResponse Body { get; init; }
}
/// <summary>Same purpose as <see cref="AuthSessionResult"/>, for the two OTP-verify
/// flows whose body also carries ReferenceNumber/Verified alongside the user/session.</summary>
public sealed class OtpAuthSessionResult
{
public required string AccessToken { get; init; }
public required string RefreshToken { get; init; }
public required OtpLoginVerifiedResponse Body { get; init; }
}
@@ -0,0 +1,114 @@
using ERPCore.Dtos.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
namespace ERPCore.Services.Auth;
/// <inheritdoc cref="IAuthUserService"/>
public sealed class AuthUserService : IAuthUserService
{
private readonly IAuthHexClient _authHex;
public AuthUserService(IAuthHexClient authHex) => _authHex = authHex;
public async Task<AuthSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default)
{
request.UserId ??= Guid.NewGuid();
var result = await _authHex.RegisterAsync(request, ct);
return ToSessionResult(result);
}
public async Task<AuthSessionResult> LoginAsync(LoginRequest request, CancellationToken ct = default)
{
var result = await _authHex.LoginAsync(request, ct);
return ToSessionResult(result);
}
public async Task<OtpAuthSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default)
{
var result = await _authHex.VerifyOtpForLoginAsync(request, ct);
return ToOtpSessionResult(result);
}
public async Task<AuthSessionResult> RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default)
{
var result = await _authHex.RefreshTokenAsync(refreshToken, request.DeviceName, ct);
return ToSessionResult(result);
}
public Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default)
=> _authHex.GetUserDetailsAsync(userId, ct);
public Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default)
=> _authHex.GetUserSessionsAsync(bearerToken, ct);
public Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.ChangeUserStatusAsync(request.IsActive, bearerToken, ct);
public Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.LockUserAccountAsync(request.IsLocked, bearerToken, ct);
public Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.ChangeUserPasswordAsync(request, bearerToken, ct);
public Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.VerifyPasswordAsync(request, bearerToken, ct);
/// <summary>The controller resolves the id (from body or token claim) before calling here.</summary>
public Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default)
=> request.UserId is null
? Task.CompletedTask
: _authHex.LogoutUserAsync(request.UserId.Value, ct);
public Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.UpdateUserAsync(request, bearerToken, ct);
public Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default)
=> _authHex.InitiateTwoFaSetupAsync(bearerToken, ct);
public Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.CompleteTwoFaSetupAsync(request, bearerToken, ct);
public Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.VerifyTwoFaAsync(request, bearerToken, ct);
public Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default)
=> _authHex.DisableTwoFaAsync(request, bearerToken, ct);
public Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default)
=> _authHex.GetTwoFaStatusAsync(bearerToken, ct);
private static AuthSessionResult ToSessionResult(AuthHexSessionResult? result)
{
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
return new AuthSessionResult
{
AccessToken = result.AccessToken,
RefreshToken = result.RefreshToken,
Body = new AuthSessionResponse { User = result.User, ExpiresIn = result.ExpiresIn }
};
}
private static OtpAuthSessionResult ToOtpSessionResult(AuthHexSessionResult? result)
{
if (result is null || string.IsNullOrEmpty(result.AccessToken) || string.IsNullOrEmpty(result.RefreshToken))
throw new DomainException(ErrorCodes.AuthUpstreamError, "The authentication service did not return a session.", 502);
return new OtpAuthSessionResult
{
AccessToken = result.AccessToken,
RefreshToken = result.RefreshToken,
Body = new OtpLoginVerifiedResponse
{
ReferenceNumber = result.ReferenceNumber,
UserId = result.UserId,
Verified = result.Verified ?? true,
User = result.User,
ExpiresIn = result.ExpiresIn
}
};
}
}
+114
View File
@@ -0,0 +1,114 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Brands;
using ERPCore.Dtos.Common;
using ERPCore.Infra.UoW;
using ERPCore.Repositories.Interfaces;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Brand master service (FR-MD-09). Enforces name uniqueness and optimistic concurrency
/// per docs/11-BACKEND-PHASE1.md §2.6.
/// </summary>
public sealed class BrandService : IBrandService
{
private readonly IRepository<Brand> _brands;
private readonly IUnitOfWork _uow;
public BrandService(IRepository<Brand> brands, IUnitOfWork uow)
{
_brands = brands;
_uow = uow;
}
public async Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _brands.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(b => EF.Functions.ILike(b.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(b => b.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(b => b.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(b => new BrandDto(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<BrandDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default)
{
var brand = await _brands.Query().AsNoTracking()
.FirstOrDefaultAsync(b => b.BrandId == brandId, ct);
return brand is null ? null : new ETagged<BrandDto>(Map(brand), brand.RowVersion);
}
public async Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default)
{
var name = request.Name.Trim();
if (await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower(), ct))
throw new ConflictException($"A brand named '{name}' already exists.");
var brand = new Brand
{
Name = name,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _brands.AddAsync(brand, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
}
public async Task<ETagged<BrandDto>> UpdateAsync(
int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var brand = await _brands.GetByIdAsync(brandId, ct)
?? throw new NotFoundException($"Brand {brandId} was not found.");
if (brand.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
var name = request.Name.Trim();
if (!string.Equals(brand.Name, name, StringComparison.Ordinal)
&& await _brands.Query().AnyAsync(b => b.Name.ToLower() == name.ToLower() && b.BrandId != brandId, ct))
throw new ConflictException($"A brand named '{name}' already exists.");
brand.Name = name;
brand.UpdatedAt = DateTime.UtcNow;
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The brand was modified by another request.", 412);
}
return new ETagged<BrandDto>(Map(brand), brand.RowVersion);
}
public async Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default)
{
var brand = await _brands.GetByIdAsync(brandId, ct)
?? throw new NotFoundException($"Brand {brandId} was not found.");
brand.Status = status;
brand.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
private static BrandDto Map(Brand b) => new(b.BrandId, b.Name, b.Status, b.CreatedAt, b.UpdatedAt);
}
+181 -23
View File
@@ -1,4 +1,6 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Categories;
using ERPCore.Dtos.Common;
using ERPCore.Infra.UoW;
@@ -9,18 +11,31 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Services;
/// <summary>
/// Category + subcategory master service (FR-MD-04). The hierarchy is exactly two levels:
/// categories no longer self-nest, so there is no cycle to detect and no tree to build
/// (docs/11-BACKEND-PHASE1.md §2.3).
/// </summary>
public sealed class CategoryService : ICategoryService
{
private readonly IRepository<Category> _categories;
private readonly IRepository<SubCategory> _subCategories;
private readonly IUnitOfWork _uow;
public CategoryService(IRepository<Category> categories, IUnitOfWork uow)
public CategoryService(
IRepository<Category> categories,
IRepository<SubCategory> subCategories,
IUnitOfWork uow)
{
_categories = categories;
_subCategories = subCategories;
_uow = uow;
}
public async Task<PagedResponse<CategoryDto>> ListAsync(PageQuery query, CancellationToken ct = default)
// Categories ---------------------------------------------------------------
public async Task<PagedResponse<CategoryDto>> ListAsync(
PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
var q = _categories.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -28,43 +43,186 @@ public sealed class CategoryService : ICategoryService
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(c => c.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<CategoryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<IReadOnlyList<CategoryTreeDto>> GetTreeAsync(CancellationToken ct = default)
public async Task<ETagged<CategoryDto>?> GetAsync(int categoryId, CancellationToken ct = default)
{
var all = await _categories.Query().AsNoTracking()
.OrderBy(c => c.Name)
.Select(c => new CategoryDto(c.CategoryId, c.Name, c.ParentId))
.ToListAsync(ct);
var byParent = all.ToLookup(c => c.ParentId);
List<CategoryTreeDto> Build(int? parentId) =>
byParent[parentId]
.Select(c => new CategoryTreeDto(c.CategoryId, c.Name, c.ParentId, Build(c.CategoryId)))
.ToList();
return Build(null);
var category = await _categories.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct);
return category is null ? null : new ETagged<CategoryDto>(Map(category), category.RowVersion);
}
public async Task<CategoryDto> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
public async Task<ETagged<CategoryDto>> CreateAsync(CreateCategoryRequest request, CancellationToken ct = default)
{
if (request.ParentId is not null
&& !await _categories.Query().AnyAsync(c => c.CategoryId == request.ParentId, ct))
throw new DomainException(ErrorCodes.Validation, $"Parent category {request.ParentId} does not exist.", 422);
var name = request.Name.Trim();
if (await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower(), ct))
throw new ConflictException($"A category named '{name}' already exists.");
var category = new Category
{
Name = name,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
var category = new Category { Name = request.Name.Trim(), ParentId = request.ParentId };
await _categories.AddAsync(category, ct);
await _uow.SaveChangesAsync(ct);
return new CategoryDto(category.CategoryId, category.Name, category.ParentId);
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
}
public async Task<ETagged<CategoryDto>> UpdateAsync(
int categoryId, UpdateCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var category = await _categories.GetByIdAsync(categoryId, ct)
?? throw new NotFoundException($"Category {categoryId} was not found.");
if (category.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The category was modified by another request.", 412);
var name = request.Name.Trim();
if (!string.Equals(category.Name, name, StringComparison.Ordinal)
&& await _categories.Query().AnyAsync(c => c.Name.ToLower() == name.ToLower() && c.CategoryId != categoryId, ct))
throw new ConflictException($"A category named '{name}' already exists.");
category.Name = name;
category.UpdatedAt = DateTime.UtcNow;
await SaveGuardingConcurrencyAsync("category", ct);
return new ETagged<CategoryDto>(Map(category), category.RowVersion);
}
public async Task SetStatusAsync(int categoryId, EntityStatus status, CancellationToken ct = default)
{
var category = await _categories.GetByIdAsync(categoryId, ct)
?? throw new NotFoundException($"Category {categoryId} was not found.");
category.Status = status;
category.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
// Subcategories ------------------------------------------------------------
public async Task<PagedResponse<SubCategoryDto>> ListSubCategoriesAsync(
int categoryId, PageQuery query, EntityStatus? status, CancellationToken ct = default)
{
if (!await _categories.Query().AnyAsync(c => c.CategoryId == categoryId, ct))
throw new NotFoundException($"Category {categoryId} was not found.");
var q = _subCategories.Query().AsNoTracking().Where(s => s.CategoryId == categoryId);
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(s => EF.Functions.ILike(s.Name, $"%{term}%"));
}
if (status is not null) q = q.Where(s => s.Status == status);
var total = await q.CountAsync(ct);
var rows = await q.OrderBy(s => s.Name)
.Skip(query.Skip).Take(query.PageSize)
.Select(s => new SubCategoryDto(
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt))
.ToListAsync(ct);
return PagedResponse<SubCategoryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<ETagged<SubCategoryDto>?> GetSubCategoryAsync(int subCategoryId, CancellationToken ct = default)
{
var sub = await _subCategories.Query().AsNoTracking()
.FirstOrDefaultAsync(s => s.SubCategoryId == subCategoryId, ct);
return sub is null ? null : new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
}
public async Task<ETagged<SubCategoryDto>> CreateSubCategoryAsync(
int categoryId, CreateSubCategoryRequest request, CancellationToken ct = default)
{
var category = await _categories.Query().AsNoTracking()
.FirstOrDefaultAsync(c => c.CategoryId == categoryId, ct)
?? throw new NotFoundException($"Category {categoryId} was not found.");
if (category.Status != EntityStatus.Active)
throw new DomainException(ErrorCodes.Validation, $"Category {categoryId} is inactive.", 422);
var name = request.Name.Trim();
if (await _subCategories.Query().AnyAsync(
s => s.CategoryId == categoryId && s.Name.ToLower() == name.ToLower(), ct))
throw new ConflictException($"A subcategory named '{name}' already exists under category {categoryId}.");
var sub = new SubCategory
{
CategoryId = categoryId,
Name = name,
Status = EntityStatus.Active,
CreatedAt = DateTime.UtcNow
};
await _subCategories.AddAsync(sub, ct);
await _uow.SaveChangesAsync(ct);
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
}
public async Task<ETagged<SubCategoryDto>> UpdateSubCategoryAsync(
int subCategoryId, UpdateSubCategoryRequest request, uint expectedRowVersion, CancellationToken ct = default)
{
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
if (sub.RowVersion != expectedRowVersion)
throw new DomainException(ErrorCodes.ConcurrencyConflict, "The subcategory was modified by another request.", 412);
var name = request.Name.Trim();
if (!string.Equals(sub.Name, name, StringComparison.Ordinal)
&& await _subCategories.Query().AnyAsync(
s => s.CategoryId == sub.CategoryId && s.Name.ToLower() == name.ToLower() && s.SubCategoryId != subCategoryId, ct))
throw new ConflictException($"A subcategory named '{name}' already exists under category {sub.CategoryId}.");
// Name only — reparenting is not offered, since it would silently invalidate the
// category of every item pointing at this subcategory.
sub.Name = name;
sub.UpdatedAt = DateTime.UtcNow;
await SaveGuardingConcurrencyAsync("subcategory", ct);
return new ETagged<SubCategoryDto>(MapSub(sub), sub.RowVersion);
}
public async Task SetSubCategoryStatusAsync(int subCategoryId, EntityStatus status, CancellationToken ct = default)
{
var sub = await _subCategories.GetByIdAsync(subCategoryId, ct)
?? throw new NotFoundException($"Subcategory {subCategoryId} was not found.");
sub.Status = status;
sub.UpdatedAt = DateTime.UtcNow;
await _uow.SaveChangesAsync(ct);
}
// ---------------------------------------------------------------------------
private async Task SaveGuardingConcurrencyAsync(string label, CancellationToken ct)
{
try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
throw new DomainException(ErrorCodes.ConcurrencyConflict, $"The {label} was modified by another request.", 412);
}
}
private static CategoryDto Map(Category c) => new(c.CategoryId, c.Name, c.Status, c.CreatedAt, c.UpdatedAt);
private static SubCategoryDto MapSub(SubCategory s) => new(
s.SubCategoryId, s.CategoryId, s.Name, s.Status, s.CreatedAt, s.UpdatedAt);
}
+26 -1
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -49,6 +50,30 @@ public sealed class CountService : ICountService
_uow = uow;
}
public async Task<PagedResponse<CountSummaryDto>> ListAsync(
PageQuery query, CountStatus? status, int? warehouseId, CancellationToken ct = default)
{
var q = _counts.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(c => EF.Functions.ILike(c.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(c => c.Status == status);
if (warehouseId is not null) q = q.Where(c => c.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(c => c.CountId)
.Skip(query.Skip).Take(query.PageSize)
.Select(c => new CountSummaryDto(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CreatedBy, c.CreatedAt, c.Lines.Count))
.ToListAsync(ct);
return PagedResponse<CountSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<CountDto?> GetAsync(int countId, CancellationToken ct = default)
{
var count = await _counts.Query().AsNoTracking().Include(c => c.Lines)
@@ -175,7 +200,7 @@ public sealed class CountService : ICountService
}
private static CountDto Map(StockCount c) => new(
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status,
c.CountId, c.DocNo, c.WarehouseId, c.CountType, c.Status, c.CreatedBy, c.CreatedAt,
c.Lines.OrderBy(l => l.CountLineId)
.Select(l => new CountLineDto(l.CountLineId, l.ItemId, l.BinId, l.SystemQty, l.CountedQty, l.Variance)).ToList());
}
+52 -5
View File
@@ -1,6 +1,7 @@
using ERPCore.Domain;
using ERPCore.Domain.Entities;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Grn;
using ERPCore.Infra.Auth;
using ERPCore.Infra.UoW;
@@ -65,6 +66,32 @@ public sealed class GrnService : IGrnService
_uow = uow;
}
public async Task<PagedResponse<GrnSummaryDto>> ListAsync(
PageQuery query, GrnStatus? status, int? poId, int? vendorId, int? warehouseId, CancellationToken ct = default)
{
var q = _grns.Query().AsNoTracking();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var term = query.Q.Trim();
q = q.Where(g => EF.Functions.ILike(g.DocNo, $"%{term}%"));
}
if (status is not null) q = q.Where(g => g.Status == status);
if (poId is not null) q = q.Where(g => g.PoId == poId);
if (vendorId is not null) q = q.Where(g => g.VendorId == vendorId);
if (warehouseId is not null) q = q.Where(g => g.WarehouseId == warehouseId);
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(g => g.GrnId)
.Skip(query.Skip).Take(query.PageSize)
.Select(g => new GrnSummaryDto(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status,
g.CreatedBy, g.CreatedAt, g.PostedAt, g.Lines.Count))
.ToListAsync(ct);
return PagedResponse<GrnSummaryDto>.Create(rows, query.Page, query.PageSize, total);
}
public async Task<GrnDto?> GetAsync(int grnId, CancellationToken ct = default)
{
var grn = await _grns.Query().AsNoTracking()
@@ -111,8 +138,11 @@ public sealed class GrnService : IGrnService
if (input.BinId is not null && !await _bins.Query().AnyAsync(b => b.BinId == input.BinId && b.WarehouseId == request.WarehouseId, ct))
throw new DomainException(ErrorCodes.Validation, $"Bin {input.BinId} is not in warehouse {request.WarehouseId}.", 422);
// Cost: PO-derived for PO lines (client cost ignored, C.3); entered for direct.
// Cost: for a PO line, the PO price is used unless an override is entered (then it
// wins and a variance is recorded against the PO snapshot — docs/02-SECURITY C.3,
// revised). Direct receipts always use the entered cost.
decimal unitCost;
decimal? poUnitPrice = null;
if (input.PoLineId is not null)
{
var poLine = po?.Lines.FirstOrDefault(l => l.PoLineId == input.PoLineId)
@@ -125,13 +155,19 @@ public sealed class GrnService : IGrnService
throw new DomainException(ErrorCodes.OverReceiptTolerance,
$"Receiving {input.Qty} exceeds the open quantity {openQty} on PO line {input.PoLineId}.", 422);
unitCost = poLine.UnitPrice;
poUnitPrice = poLine.UnitPrice;
unitCost = input.UnitCost > 0 ? input.UnitCost : poLine.UnitPrice;
}
else
{
unitCost = input.UnitCost;
}
// Derived figures are always computed server-side, never accepted from the client.
var netUnitCost = Math.Round(unitCost * (1 - input.DiscountPct / 100m), 6, MidpointRounding.AwayFromZero);
var receivedValue = Math.Round(input.Qty * netUnitCost, 4, MidpointRounding.AwayFromZero);
var vatAmount = Math.Round(receivedValue * input.VatPct / 100m, 4, MidpointRounding.AwayFromZero);
var batch = await ResolveBatchAsync(item, input.Batch, batchCache, ct);
lines.Add(new GrnLine
@@ -143,7 +179,13 @@ public sealed class GrnService : IGrnService
Batch = batch, // navigation so EF fixes up BatchId once the batch is inserted
Qty = input.Qty,
UnitCost = unitCost,
ReceivedValue = Math.Round(input.Qty * unitCost, 4, MidpointRounding.AwayFromZero),
PoUnitPrice = poUnitPrice,
DiscountPct = input.DiscountPct,
NetUnitCost = netUnitCost,
VatPct = input.VatPct,
VatAmount = vatAmount,
ReceivedValue = receivedValue,
LineTotal = receivedValue + vatAmount,
HoldStatus = input.HoldStatus
});
}
@@ -193,7 +235,9 @@ public sealed class GrnService : IGrnService
foreach (var line in grn.Lines.OrderBy(l => l.GrnLineId))
{
var item = await _items.Query().AsNoTracking().FirstAsync(i => i.ItemId == line.ItemId, token);
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.UnitCost, token);
// FIFO layer costs at the after-discount net price; VAT is recoverable and never
// enters stock value (docs/10 FR-GRN-06, revised).
var (qtyBase, unitCostBase) = await ToBaseAsync(item, line.UomId, line.Qty, line.NetUnitCost, token);
var layer = await _fifo.CreateInboundLayerAsync(
line.ItemId, grn.WarehouseId, line.BatchId, null, line.GrnLineId,
@@ -353,5 +397,8 @@ public sealed class GrnService : IGrnService
private static GrnDto Map(Grn g) => new(
g.GrnId, g.DocNo, g.PoId, g.VendorId, g.WarehouseId, g.Status, g.CreatedBy, g.CreatedAt, g.PostedAt,
g.Lines.OrderBy(l => l.GrnLineId).Select(l => new GrnLineDto(
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.ReceivedValue, l.HoldStatus, l.BatchId)).ToList());
l.GrnLineId, l.PoLineId, l.ItemId, l.UomId, l.BinId, l.Qty, l.UnitCost, l.PoUnitPrice,
l.DiscountPct, l.NetUnitCost, l.VatPct, l.VatAmount, l.ReceivedValue, l.LineTotal,
l.PoUnitPrice is null ? 0m : Math.Round((l.UnitCost - l.PoUnitPrice.Value) * l.Qty, 4, MidpointRounding.AwayFromZero),
l.HoldStatus, l.BatchId)).ToList());
}
@@ -1,3 +1,4 @@
using ERPCore.Dtos.Common;
using ERPCore.Dtos.Stock;
namespace ERPCore.Services.Interfaces;
@@ -5,5 +6,10 @@ namespace ERPCore.Services.Interfaces;
/// <summary>Stock-adjustment business logic (docs/11 §5.5; FR-STK-07; 02-SECURITY C.5).</summary>
public interface IAdjustmentService
{
Task<PagedResponse<AdjustmentSummaryDto>> ListAsync(
PageQuery query, int? warehouseId, int? reasonCodeId, CancellationToken ct = default);
Task<AdjustmentDto?> GetAsync(int adjustmentId, CancellationToken ct = default);
Task<AdjustmentDto> CreateAsync(CreateAdjustmentRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Auth;
using ERPCore.Services.Auth;
namespace ERPCore.Services.Interfaces;
/// <summary>AltOptionManager proxy business logic (API_REFERENCE.md §5), fronting AuthHex.</summary>
public interface IAuthAltService
{
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct = default);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct = default);
Task<OtpAuthSessionResult> VerifyOtpAsync(VerifyAltOtpRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using ERPCore.Dtos.Auth;
namespace ERPCore.Services.Interfaces;
/// <summary>RecoveryManager proxy business logic (API_REFERENCE.md §4), fronting AuthHex.</summary>
public interface IAuthRecoveryService
{
Task<ForgotPasswordResponse> ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct = default);
Task<VerifyRecoveryOtpResponse> VerifyOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct = default);
Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct = default);
Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,26 @@
using ERPCore.Dtos.Auth;
using ERPCore.Services.Auth;
namespace ERPCore.Services.Interfaces;
/// <summary>UserManager proxy business logic (API_REFERENCE.md §3), fronting AuthHex.</summary>
public interface IAuthUserService
{
Task<AuthSessionResult> RegisterAsync(RegisterRequest request, CancellationToken ct = default);
Task<AuthSessionResult> LoginAsync(LoginRequest request, CancellationToken ct = default);
Task<OtpAuthSessionResult> VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct = default);
Task<AuthSessionResult> RefreshTokenAsync(string refreshToken, RefreshTokenRequest request, CancellationToken ct = default);
Task<GetUserDetailsResponse> GetUserDetailsAsync(Guid userId, CancellationToken ct = default);
Task<List<SessionDto>> GetUserSessionsAsync(string bearerToken, CancellationToken ct = default);
Task ChangeUserStatusAsync(ChangeUserStatusRequest request, string bearerToken, CancellationToken ct = default);
Task LockUserAccountAsync(LockUserAccountRequest request, string bearerToken, CancellationToken ct = default);
Task ChangeUserPasswordAsync(ChangeUserPasswordRequest request, string bearerToken, CancellationToken ct = default);
Task<VerifyPasswordResponse> VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct = default);
Task LogoutUserAsync(LogoutRequest request, CancellationToken ct = default);
Task<UserSummaryDto?> UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct = default);
Task<TwoFaSetupResponse> InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct = default);
Task<CompleteTwoFaSetupResponse> CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct = default);
Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct = default);
Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct = default);
Task<TwoFaStatusResponse> GetTwoFaStatusAsync(string bearerToken, CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using ERPCore.Common.Http;
using ERPCore.Domain.Enums;
using ERPCore.Dtos.Brands;
using ERPCore.Dtos.Common;
namespace ERPCore.Services.Interfaces;
/// <summary>Brand master business logic (docs/11-BACKEND-PHASE1.md §2.6).</summary>
public interface IBrandService
{
Task<PagedResponse<BrandDto>> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default);
Task<ETagged<BrandDto>?> GetAsync(int brandId, CancellationToken ct = default);
Task<ETagged<BrandDto>> CreateAsync(CreateBrandRequest request, CancellationToken ct = default);
Task<ETagged<BrandDto>> UpdateAsync(int brandId, UpdateBrandRequest request, uint expectedRowVersion, CancellationToken ct = default);
Task SetStatusAsync(int brandId, EntityStatus status, CancellationToken ct = default);
}

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