diff --git a/.gitignore b/.gitignore
index e4f2245..6b1868c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs
new file mode 100644
index 0000000..0b7dd25
--- /dev/null
+++ b/Backend/ERPCore/Controllers/AuthController.cs
@@ -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;
+
+///
+/// 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
+/// — response bodies never carry raw tokens. Does not inherit
+/// : most actions here are pre-session and need
+/// , and the ETag/If-Match handling that
+/// base provides doesn't apply to auth flows.
+///
+[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;
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpGet("me")]
+ [ProducesResponseType(typeof(MeResponseDto), StatusCodes.Status200OK)]
+ public async Task> 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> 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> 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> 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> 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> GetUserDetails(Guid userId, CancellationToken ct)
+ => Ok(await _users.GetUserDetailsAsync(userId, ct));
+
+ [HttpGet("sessions")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> GetSessions(CancellationToken ct)
+ => Ok(await _users.GetUserSessionsAsync(RequireBearerToken(), ct));
+
+ [HttpPost("status")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task ChangeStatus([FromBody] ChangeUserStatusRequest request, CancellationToken ct)
+ {
+ await _users.ChangeUserStatusAsync(request, RequireBearerToken(), ct);
+ return NoContent();
+ }
+
+ [HttpPost("lock")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task 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 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> VerifyPassword([FromBody] VerifyPasswordRequest request, CancellationToken ct)
+ => Ok(await _users.VerifyPasswordAsync(request, RequireBearerToken(), ct));
+
+ ///
+ /// Ends the session: revokes it upstream where possible, and always clears our cookies.
+ ///
+ /// userId is optional because callers usually cannot supply it — AuthHex returns
+ /// user.userId: null in its own login/register response, so a browser has no id
+ /// to send. It is resolved from the session token's UserId claim instead.
+ ///
+ ///
+ /// 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).
+ ///
+ ///
+ [HttpPost("logout")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task 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();
+ }
+
+ /// AuthHex's identity claim, present when the request carried a valid session.
+ 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> 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> InitiateTwoFa(CancellationToken ct)
+ => Ok(await _users.InitiateTwoFaSetupAsync(RequireBearerToken(), ct));
+
+ [HttpPost("2fa/complete")]
+ [ValidateCsrf]
+ [ProducesResponseType(typeof(CompleteTwoFaSetupResponse), StatusCodes.Status200OK)]
+ public async Task> CompleteTwoFa([FromBody] CompleteTwoFaSetupRequest request, CancellationToken ct)
+ => Ok(await _users.CompleteTwoFaSetupAsync(request, RequireBearerToken(), ct));
+
+ [HttpPost("2fa/verify")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task VerifyTwoFa([FromBody] VerifyTwoFaRequest request, CancellationToken ct)
+ {
+ await _users.VerifyTwoFaAsync(request, RequireBearerToken(), ct);
+ return NoContent();
+ }
+
+ [HttpPost("2fa/disable")]
+ [ValidateCsrf]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task 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> GetTwoFaStatus(CancellationToken ct)
+ => Ok(await _users.GetTwoFaStatusAsync(RequireBearerToken(), ct));
+
+ // ---- Recovery -----------------------------------------------------------
+
+ [HttpPost("recovery/forgot-password")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(ForgotPasswordResponse), StatusCodes.Status200OK)]
+ public async Task> 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> VerifyRecoveryOtp([FromBody] VerifyRecoveryOtpRequest request, CancellationToken ct)
+ => Ok(await _recovery.VerifyOtpAsync(request, ct));
+
+ [HttpPost("recovery/reset-password")]
+ [AllowAnonymous]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public async Task 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 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> CheckAvailability([FromBody] IsAvailableRequest request, CancellationToken ct)
+ => Ok(await _alt.IsAvailableAsync(request, ct));
+
+ [HttpPost("otp/send")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(SendOtpResponse), StatusCodes.Status200OK)]
+ public async Task> SendOtp([FromBody] SendOtpRequest request, CancellationToken ct)
+ => Ok(await _alt.SendOtpAsync(request, ct));
+
+ [HttpPost("otp/verify")]
+ [AllowAnonymous]
+ [ProducesResponseType(typeof(OtpLoginVerifiedResponse), StatusCodes.Status200OK)]
+ public async Task> 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 --------------------------------------------------------------
+
+ /// The token that authenticated this request — Bearer header if present, else the session cookie.
+ 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);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/BrandsController.cs b/Backend/ERPCore/Controllers/BrandsController.cs
new file mode 100644
index 0000000..7d4c5b3
--- /dev/null
+++ b/Backend/ERPCore/Controllers/BrandsController.cs
@@ -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;
+
+/// Brand master endpoints (docs/11-BACKEND-PHASE1.md §2.6).
+[Route("api/v1/brands")]
+public sealed class BrandsController : ApiControllerBase
+{
+ private readonly IBrandService _brands;
+
+ public BrandsController(IBrandService brands) => _brands = brands;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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);
+ }
+
+ /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).
+ [HttpPatch("{brandId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(int brandId, [FromBody] UpdateBrandStatusRequest request, CancellationToken ct)
+ {
+ await _brands.SetStatusAsync(brandId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/CategoriesController.cs b/Backend/ERPCore/Controllers/CategoriesController.cs
index dab7c60..3fcd0e2 100644
--- a/Backend/ERPCore/Controllers/CategoriesController.cs
+++ b/Backend/ERPCore/Controllers/CategoriesController.cs
@@ -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;
-/// Category endpoints (docs/11-BACKEND-PHASE1.md §2.3).
+///
+/// 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
+/// ?tree=true parameter is gone along with the self-nesting model.
+///
[Route("api/v1/categories")]
public sealed class CategoriesController : ApiControllerBase
{
@@ -13,19 +18,79 @@ public sealed class CategoriesController : ApiControllerBase
public CategoriesController(ICategoryService categories) => _categories = categories;
- /// Flat paged list, or a nested tree when tree=true.
[HttpGet]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
- [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
- public async Task List([FromQuery] PageQuery query, [FromQuery] bool tree, CancellationToken ct)
- => tree ? Ok(await _categories.GetTreeAsync(ct)) : Ok(await _categories.ListAsync(query, ct));
+ public async Task>> 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> 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> 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> 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);
+ }
+
+ /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).
+ [HttpPatch("{categoryId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task 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), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task>> 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> 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);
}
}
diff --git a/Backend/ERPCore/Controllers/GrnsController.cs b/Backend/ERPCore/Controllers/GrnsController.cs
index 20ba722..3ad1f5a 100644
--- a/Backend/ERPCore/Controllers/GrnsController.cs
+++ b/Backend/ERPCore/Controllers/GrnsController.cs
@@ -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;
+ /// List GRNs, newest first.
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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)]
diff --git a/Backend/ERPCore/Controllers/ItemTypesController.cs b/Backend/ERPCore/Controllers/ItemTypesController.cs
new file mode 100644
index 0000000..efbc4f7
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ItemTypesController.cs
@@ -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;
+
+///
+/// Item type master endpoints (docs/11-BACKEND-PHASE1.md §2.7) — the Color/Size/Material
+/// dimension names. GET 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).
+///
+[Route("api/v1/item-types")]
+public sealed class ItemTypesController : ApiControllerBase
+{
+ private readonly IItemTypeService _itemTypes;
+
+ public ItemTypesController(IItemTypeService itemTypes) => _itemTypes = itemTypes;
+
+ /// Feeds the frontend item-builder dropdown; filter status=Active for selectable rows.
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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);
+ }
+
+ /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).
+ [HttpPatch("{itemTypeId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(int itemTypeId, [FromBody] UpdateItemTypeStatusRequest request, CancellationToken ct)
+ {
+ await _itemTypes.SetStatusAsync(itemTypeId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/ItemsController.cs b/Backend/ERPCore/Controllers/ItemsController.cs
index c98493a..fe4ed11 100644
--- a/Backend/ERPCore/Controllers/ItemsController.cs
+++ b/Backend/ERPCore/Controllers/ItemsController.cs
@@ -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));
/// Get a single item; returns an ETag for optimistic concurrency.
[HttpGet("{itemId:int}")]
diff --git a/Backend/ERPCore/Controllers/NavController.cs b/Backend/ERPCore/Controllers/NavController.cs
new file mode 100644
index 0000000..4ebcef8
--- /dev/null
+++ b/Backend/ERPCore/Controllers/NavController.cs
@@ -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;
+
+///
+/// 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.
+///
+[Route("api/v1/nav")]
+public sealed class NavController : ApiControllerBase
+{
+ private readonly IRepository _navItems;
+
+ public NavController(IRepository navItems) => _navItems = navItems;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> 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);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/ProductConfigController.cs b/Backend/ERPCore/Controllers/ProductConfigController.cs
new file mode 100644
index 0000000..300f446
--- /dev/null
+++ b/Backend/ERPCore/Controllers/ProductConfigController.cs
@@ -0,0 +1,46 @@
+using ERPCore.Dtos.Config;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Product configuration endpoints (docs/11-BACKEND-PHASE1.md §2.8) — the singleton
+/// feature gate for subcategories/brands/item-types.
+///
+/// Authorization: writes are admitted by the inherited ERP door policy only.
+/// A dedicated CONFIG_MANAGE 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.
+///
+///
+[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> 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> Update(
+ [FromBody] UpdateProductConfigRequest request, CancellationToken ct)
+ {
+ var expected = RequireIfMatch();
+ var result = await _config.UpdateAsync(request, expected, ct);
+ SetETag(result.RowVersion);
+ return Ok(result.Value);
+ }
+}
diff --git a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
index a1bf9b9..ff9dc71 100644
--- a/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
+++ b/Backend/ERPCore/Controllers/PurchaseOrdersController.cs
@@ -57,6 +57,25 @@ public sealed class PurchaseOrdersController : ApiControllerBase
return Ok(result.Value);
}
+ /// Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft.
+ [HttpPost("{poId:int}/submit")]
+ [ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> Submit(int poId, CancellationToken ct)
+ => Ok(await _pos.SubmitAsync(poId, ct));
+
+ /// Delete a PO — permitted only while Draft (409 PO_NOT_EDITABLE otherwise).
+ [HttpDelete("{poId:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task Delete(int poId, CancellationToken ct)
+ {
+ await _pos.DeleteAsync(poId, ct);
+ return NoContent();
+ }
+
/// Approve — no-op in Phase 1 (POs auto-approve); transitions PendingApproval→Approved when enabled.
[HttpPost("{poId:int}/approve")]
[ProducesResponseType(typeof(PurchaseOrderDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs
index 9f59a56..40ce9a2 100644
--- a/Backend/ERPCore/Controllers/PurchaseReturnsController.cs
+++ b/Backend/ERPCore/Controllers/PurchaseReturnsController.cs
@@ -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;
+ /// List posted returns, newest first.
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] int? vendorId, [FromQuery] int? warehouseId, CancellationToken ct)
+ => Ok(await _returns.ListAsync(query, vendorId, warehouseId, ct));
+
+ /// Get one return with its lines and the ledger entries it posted.
+ [HttpGet("{returnId:int}")]
+ [ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int returnId, CancellationToken ct)
+ {
+ var dto = await _returns.GetAsync(returnId, ct);
+ return dto is null ? NotFound() : Ok(dto);
+ }
+
/// Create + auto-post a return (outbound movement). 409 if return exceeds available stock.
[HttpPost]
[ProducesResponseType(typeof(PurchaseReturnDto), StatusCodes.Status201Created)]
diff --git a/Backend/ERPCore/Controllers/RequisitionsController.cs b/Backend/ERPCore/Controllers/RequisitionsController.cs
index ab849b9..e16d2e1 100644
--- a/Backend/ERPCore/Controllers/RequisitionsController.cs
+++ b/Backend/ERPCore/Controllers/RequisitionsController.cs
@@ -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), StatusCodes.Status200OK)]
- public async Task>> List([FromQuery] PageQuery query, CancellationToken ct)
- => Ok(await _requisitions.ListAsync(query, ct));
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] RequisitionStatus? status, CancellationToken ct)
+ => Ok(await _requisitions.ListAsync(query, status, ct));
[HttpGet("{requisitionId:int}")]
[ProducesResponseType(typeof(RequisitionDto), StatusCodes.Status200OK)]
diff --git a/Backend/ERPCore/Controllers/RfqsController.cs b/Backend/ERPCore/Controllers/RfqsController.cs
index 7cc0e1f..c917479 100644
--- a/Backend/ERPCore/Controllers/RfqsController.cs
+++ b/Backend/ERPCore/Controllers/RfqsController.cs
@@ -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;
+ /// List RFQs, newest first.
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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)]
diff --git a/Backend/ERPCore/Controllers/RolesController.cs b/Backend/ERPCore/Controllers/RolesController.cs
new file mode 100644
index 0000000..69db895
--- /dev/null
+++ b/Backend/ERPCore/Controllers/RolesController.cs
@@ -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;
+
+/// Role CRUD + permission assignment (mirrors AuthHex's Role; see docs/10 C.9).
+[Route("api/v1/roles")]
+public sealed class RolesController : ApiControllerBase
+{
+ private readonly IRoleService _roles;
+
+ public RolesController(IRoleService roles) => _roles = roles;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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> 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> 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> 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 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 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> 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> AssignPermissions(
+ int roleId, [FromBody] AssignRolePermissionsRequest request, CancellationToken ct)
+ => Ok(await _roles.AssignPermissionsAsync(roleId, request, ct));
+}
diff --git a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
index 7952ba6..51b2544 100644
--- a/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
+++ b/Backend/ERPCore/Controllers/StockAdjustmentsController.cs
@@ -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;
+ /// List posted adjustments, newest first.
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List(
+ [FromQuery] PageQuery query, [FromQuery] int? warehouseId, [FromQuery] int? reasonCodeId, CancellationToken ct)
+ => Ok(await _adjustments.ListAsync(query, warehouseId, reasonCodeId, ct));
+
+ /// Get one adjustment with its lines and the ledger entries it posted.
+ [HttpGet("{adjustmentId:int}")]
+ [ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> GetById(int adjustmentId, CancellationToken ct)
+ {
+ var dto = await _adjustments.GetAsync(adjustmentId, ct);
+ return dto is null ? NotFound() : Ok(dto);
+ }
+
/// Create + auto-post an adjustment (mandatory reason code; decrease FIFO-consumes).
[HttpPost]
[ProducesResponseType(typeof(AdjustmentDto), StatusCodes.Status201Created)]
diff --git a/Backend/ERPCore/Controllers/StockController.cs b/Backend/ERPCore/Controllers/StockController.cs
index 652942a..3189268 100644
--- a/Backend/ERPCore/Controllers/StockController.cs
+++ b/Backend/ERPCore/Controllers/StockController.cs
@@ -24,12 +24,26 @@ public sealed class StockController : ApiControllerBase
public async Task> OnHand([FromQuery] int itemId, [FromQuery] int warehouseId, CancellationToken ct)
=> Ok(await _stock.GetOnHandAsync(itemId, warehouseId, ct));
+ /// On-hand across every stocked (item, warehouse) pair; both filters optional.
+ [HttpGet("on-hand/list")]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> OnHandList(
+ [FromQuery] int? itemId, [FromQuery] int? warehouseId, [FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _stock.GetOnHandListAsync(itemId, warehouseId, query, ct));
+
+ ///
+ /// Immutable movement history. sourceDocType/sourceDocId 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).
+ ///
[HttpGet("ledger")]
[ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
public async Task>> 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)]
diff --git a/Backend/ERPCore/Controllers/StockCountsController.cs b/Backend/ERPCore/Controllers/StockCountsController.cs
index 008ca79..aa8b477 100644
--- a/Backend/ERPCore/Controllers/StockCountsController.cs
+++ b/Backend/ERPCore/Controllers/StockCountsController.cs
@@ -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;
+ /// List counts, newest first.
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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)]
diff --git a/Backend/ERPCore/Controllers/StockTransfersController.cs b/Backend/ERPCore/Controllers/StockTransfersController.cs
index fd5f52b..49d1a3f 100644
--- a/Backend/ERPCore/Controllers/StockTransfersController.cs
+++ b/Backend/ERPCore/Controllers/StockTransfersController.cs
@@ -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;
+ /// List transfers, newest first.
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> 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)]
diff --git a/Backend/ERPCore/Controllers/SubCategoriesController.cs b/Backend/ERPCore/Controllers/SubCategoriesController.cs
new file mode 100644
index 0000000..162a89e
--- /dev/null
+++ b/Backend/ERPCore/Controllers/SubCategoriesController.cs
@@ -0,0 +1,56 @@
+using ERPCore.Dtos.Categories;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// Subcategory endpoints addressed by their own id (docs/11-BACKEND-PHASE1.md §2.3).
+/// Listing and creation live under the parent category on ,
+/// since a subcategory only exists in the context of one.
+///
+[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> 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);
+ }
+
+ /// Renames a subcategory. It cannot be moved to another category — see the request DTO.
+ [HttpPut("{subCategoryId:int}")]
+ [ProducesResponseType(typeof(SubCategoryDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ [ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
+ public async Task> 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);
+ }
+
+ /// Deactivate/reactivate. Masters are never hard-deleted (FR-MD-08).
+ [HttpPatch("{subCategoryId:int}/status")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task SetStatus(
+ int subCategoryId, [FromBody] UpdateSubCategoryStatusRequest request, CancellationToken ct)
+ {
+ await _categories.SetSubCategoryStatusAsync(subCategoryId, request.Status, ct);
+ return NoContent();
+ }
+}
diff --git a/Backend/ERPCore/Controllers/UsersController.cs b/Backend/ERPCore/Controllers/UsersController.cs
new file mode 100644
index 0000000..60e2fe3
--- /dev/null
+++ b/Backend/ERPCore/Controllers/UsersController.cs
@@ -0,0 +1,53 @@
+using ERPCore.Dtos.Common;
+using ERPCore.Dtos.Users;
+using ERPCore.Services.Interfaces;
+using Microsoft.AspNetCore.Mvc;
+
+namespace ERPCore.Controllers;
+
+///
+/// User management: local shadow `User` list/detail + role assignment, and
+/// account creation orchestrated against AuthHex (see ).
+///
+[Route("api/v1/users")]
+public sealed class UsersController : ApiControllerBase
+{
+ private readonly IUserManagementService _users;
+
+ public UsersController(IUserManagementService users) => _users = users;
+
+ [HttpGet]
+ [ProducesResponseType(typeof(PagedResponse), StatusCodes.Status200OK)]
+ public async Task>> List([FromQuery] PageQuery query, CancellationToken ct)
+ => Ok(await _users.ListAsync(query, ct));
+
+ /// AuthHex UserType options for the create-user form's select.
+ [HttpGet("user-types")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ public async Task>> ListUserTypes(CancellationToken ct)
+ => Ok(await _users.ListUserTypesAsync(ct));
+
+ [HttpGet("{userId:int}")]
+ [ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task> 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> 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> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
+ => Ok(await _users.UpdateRoleAsync(userId, request, ct));
+}
diff --git a/Backend/ERPCore/Domain/Entities/Brand.cs b/Backend/ERPCore/Domain/Entities/Brand.cs
new file mode 100644
index 0000000..1544349
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Brand.cs
@@ -0,0 +1,21 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Brand master (FR-MD-09). Referenced optionally by .
+/// Mutable aggregate with a ETag token. Deactivated, not
+/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
+///
+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; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Category.cs b/Backend/ERPCore/Domain/Entities/Category.cs
index 6c34b87..7d04a6d 100644
--- a/Backend/ERPCore/Domain/Entities/Category.cs
+++ b/Backend/ERPCore/Domain/Entities/Category.cs
@@ -1,15 +1,25 @@
+using ERPCore.Domain.Enums;
+
namespace ERPCore.Domain.Entities;
///
-/// Hierarchical item category (FR-MD-04). A null 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 ; categories no longer self-nest (the former
+/// parent_id tree was replaced in migration #2).
+/// Mutable aggregate with a ETag token. Deactivated, not
+/// deleted, when referenced (FR-MD-08). Model: docs/10-BACKEND-PHASE1.md Part C.1.
///
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 Children { get; set; } = new List();
+ public DateTime CreatedAt { get; set; }
+ public DateTime? UpdatedAt { get; set; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+
+ public ICollection SubCategories { get; set; } = new List();
}
diff --git a/Backend/ERPCore/Domain/Entities/GrnLine.cs b/Backend/ERPCore/Domain/Entities/GrnLine.cs
index 5805127..f6981d4 100644
--- a/Backend/ERPCore/Domain/Entities/GrnLine.cs
+++ b/Backend/ERPCore/Domain/Entities/GrnLine.cs
@@ -3,9 +3,13 @@ using ERPCore.Domain.Enums;
namespace ERPCore.Domain.Entities;
///
-/// GRN line (FR-GRN-04..08). is the PO-derived cost for
-/// PO-based receipts (client cost ignored — 02-SECURITY C.3) or the entered cost
-/// for direct receipts. = qty × unitCost.
+/// GRN line (FR-GRN-04..08). 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).
+/// snapshots the PO price at receipt so the variance survives later PO edits.
+/// = unitCost after trade discount — this is what the FIFO layer
+/// costs at (VAT never enters stock value; it is recoverable input tax).
+/// = qty × netUnitCost (after discount, before VAT).
/// gates issuability. Model: docs/10 Part C.3.
///
public class GrnLine
@@ -31,7 +35,30 @@ public class GrnLine
public Batch? Batch { get; set; }
public decimal Qty { get; set; }
+
+ /// Gross unit cost received at (entered, or PO price when omitted).
public decimal UnitCost { get; set; }
+
+ /// Snapshot of the PO line price at receipt; null for direct receipts.
+ public decimal? PoUnitPrice { get; set; }
+
+ /// Trade discount percentage (0–100), entered.
+ public decimal DiscountPct { get; set; }
+
+ /// UnitCost × (1 − DiscountPct/100) — the inventory (FIFO layer) cost.
+ public decimal NetUnitCost { get; set; }
+
+ /// VAT percentage (0–100), entered. Recoverable — does not affect stock value.
+ public decimal VatPct { get; set; }
+
+ /// Qty × NetUnitCost × VatPct/100.
+ public decimal VatAmount { get; set; }
+
+ /// Qty × NetUnitCost (after discount, before VAT).
public decimal ReceivedValue { get; set; }
+
+ /// Qty × NetUnitCost + VatAmount — payable to the vendor.
+ public decimal LineTotal { get; set; }
+
public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
}
diff --git a/Backend/ERPCore/Domain/Entities/Item.cs b/Backend/ERPCore/Domain/Entities/Item.cs
index f308dd3..ba9ed6c 100644
--- a/Backend/ERPCore/Domain/Entities/Item.cs
+++ b/Backend/ERPCore/Domain/Entities/Item.cs
@@ -17,13 +17,20 @@ public class Item
public int CategoryId { get; set; }
public Category? Category { get; set; }
+ /// Optional second level below ; must belong to it.
+ 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; }
public EntityStatus Status { get; set; } = EntityStatus.Active;
diff --git a/Backend/ERPCore/Domain/Entities/ItemType.cs b/Backend/ERPCore/Domain/Entities/ItemType.cs
new file mode 100644
index 0000000..668b1b7
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ItemType.cs
@@ -0,0 +1,31 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Item type master (FR-MD-10) — a selectable dimension name such as Color, Size or
+/// Material.
+///
+/// Deliberately unlinked. Nothing references this entity and it references
+/// nothing: there is no value table and no join to . Its only job is
+/// to feed the frontend's item-builder dropdown via GET /item-types. The chosen
+/// values (Red, S, M) are encoded by the client into the generated SKU
+/// (e.g. BL-100-0003) 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.
+///
+/// Not to be confused with (Stocked/NonStocked/Service),
+/// which is what the old ItemType enum became.
+/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
+///
+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; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/NavItem.cs b/Backend/ERPCore/Domain/Entities/NavItem.cs
new file mode 100644
index 0000000..7fca8c0
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/NavItem.cs
@@ -0,0 +1,22 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// 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 /,
+/// not by editing these rows through the UI.
+///
+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 Children { get; set; } = new List();
+}
diff --git a/Backend/ERPCore/Domain/Entities/Permission.cs b/Backend/ERPCore/Domain/Entities/Permission.cs
new file mode 100644
index 0000000..f1d0cac
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Permission.cs
@@ -0,0 +1,18 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// A grantable sidebar-visibility unit — exactly one of /
+/// is set (enforced in NavSeedService/service layer,
+/// not by a DB constraint). One row is seeded per /;
+/// grants it to a role.
+///
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/PoLine.cs b/Backend/ERPCore/Domain/Entities/PoLine.cs
index 1f38169..0a5f087 100644
--- a/Backend/ERPCore/Domain/Entities/PoLine.cs
+++ b/Backend/ERPCore/Domain/Entities/PoLine.cs
@@ -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; }
}
diff --git a/Backend/ERPCore/Domain/Entities/ProductConfig.cs b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
new file mode 100644
index 0000000..4120cdc
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/ProductConfig.cs
@@ -0,0 +1,35 @@
+namespace ERPCore.Domain.Entities;
+
+///
+/// Product configuration (FR-MD-11) — a singleton row (single-tenant, docs/00-CORE §1)
+/// gating optional product master-data features.
+///
+/// and are enforced
+/// server-side: an Item write carrying a subcategory/brand while the flag is off is
+/// rejected with CONFIG_DISABLED. is
+/// advisory only — items carry no item-type reference (see ),
+/// 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.
+///
+/// Model: docs/10-BACKEND-PHASE1.md Part C.1.
+///
+public class ProductConfig
+{
+ /// Always 1 — the singleton row's id.
+ 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; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/Role.cs b/Backend/ERPCore/Domain/Entities/Role.cs
new file mode 100644
index 0000000..8ae9f43
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/Role.cs
@@ -0,0 +1,27 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Local shadow/projection of an AuthHex Role row, mirroring the same
+/// pattern uses for AuthHex identities:
+/// maps to AuthHex's Guid RoleId, while the local (int)
+/// is what //
+/// FKs reference. AuthHex remains the source of truth; writes are forwarded there
+/// first (IAuthHexClient) and mirrored here on success.
+///
+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; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/RolePermission.cs b/Backend/ERPCore/Domain/Entities/RolePermission.cs
new file mode 100644
index 0000000..75734d4
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/RolePermission.cs
@@ -0,0 +1,11 @@
+namespace ERPCore.Domain.Entities;
+
+/// Join row granting a visibility of a (nav node).
+public class RolePermission
+{
+ public int RoleId { get; set; }
+ public int PermissionId { get; set; }
+
+ public Role? Role { get; set; }
+ public Permission? Permission { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SubCategory.cs b/Backend/ERPCore/Domain/Entities/SubCategory.cs
new file mode 100644
index 0000000..a270f41
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SubCategory.cs
@@ -0,0 +1,26 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+///
+/// Subcategory — the single optional level below (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
+/// . Model: docs/10-BACKEND-PHASE1.md Part C.1.
+///
+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; }
+
+ /// PostgreSQL xmin-backed optimistic concurrency token (ETag source).
+ public uint RowVersion { get; set; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/SubNavItem.cs b/Backend/ERPCore/Domain/Entities/SubNavItem.cs
new file mode 100644
index 0000000..a40b58c
--- /dev/null
+++ b/Backend/ERPCore/Domain/Entities/SubNavItem.cs
@@ -0,0 +1,18 @@
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Domain.Entities;
+
+/// A child sidebar entry under a (e.g. Products' children).
+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; }
+}
diff --git a/Backend/ERPCore/Domain/Entities/User.cs b/Backend/ERPCore/Domain/Entities/User.cs
index ddd0048..45ad625 100644
--- a/Backend/ERPCore/Domain/Entities/User.cs
+++ b/Backend/ERPCore/Domain/Entities/User.cs
@@ -22,4 +22,8 @@ public class User
/// AuthHex identity (token UserId GUID); null for the seeded system user.
public Guid? AuthUserId { get; set; }
+
+ /// Local shadow assignment; null until an admin assigns one.
+ public int? RoleId { get; set; }
+ public Role? Role { get; set; }
}
diff --git a/Backend/ERPCore/Domain/Enums/ItemType.cs b/Backend/ERPCore/Domain/Enums/ItemType.cs
deleted file mode 100644
index c81a1d6..0000000
--- a/Backend/ERPCore/Domain/Enums/ItemType.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace ERPCore.Domain.Enums;
-
-///
-/// Item classification (FR-MD-01). Values match the itemType enum in
-/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
-///
-public enum ItemType
-{
- Stocked,
- NonStocked,
- Service
-}
diff --git a/Backend/ERPCore/Domain/Enums/StockNature.cs b/Backend/ERPCore/Domain/Enums/StockNature.cs
new file mode 100644
index 0000000..7e75517
--- /dev/null
+++ b/Backend/ERPCore/Domain/Enums/StockNature.cs
@@ -0,0 +1,14 @@
+namespace ERPCore.Domain.Enums;
+
+///
+/// Whether an item holds stock (FR-MD-01). Values match the stockNature enum in
+/// docs/11-BACKEND-PHASE1.md §8. Stored as a string in the database.
+/// Renamed from ItemType so that name could be taken by the ItemType master
+/// entity (Color/Size/Material) — the two concepts are unrelated (docs/10 Part C.9).
+///
+public enum StockNature
+{
+ Stocked,
+ NonStocked,
+ Service
+}
diff --git a/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs
new file mode 100644
index 0000000..26df8d5
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Auth/AuthAltDtos.cs
@@ -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; }
+ /// Passed through as-is when `Recovery` matched existing users — shape isn't in the documented catalog.
+ 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; }
+}
diff --git a/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs
new file mode 100644
index 0000000..7aa7ba8
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Auth/AuthRecoveryDtos.cs
@@ -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;
+}
diff --git a/Backend/ERPCore/Dtos/Auth/AuthRoleDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthRoleDtos.cs
new file mode 100644
index 0000000..6222a6b
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Auth/AuthRoleDtos.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Auth;
+
+/// AuthHex's Role projection (ERP_Auth_Service/API_DOCUMENTATION.md, RoleManager section).
+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; }
+}
diff --git a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
new file mode 100644
index 0000000..a8ca585
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
@@ -0,0 +1,191 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+
+namespace ERPCore.Dtos.Auth;
+
+/// Shared AuthHex user projection (API_REFERENCE.md §3). Field set is
+/// AuthHex's best-documented subset; unknown fields are ignored on deserialize.
+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; }
+}
+
+/// Body returned by every session-issuing endpoint. Tokens never appear
+/// here — they are delivered only as httpOnly cookies (docs/02-SECURITY.md §B.2).
+public sealed class AuthSessionResponse
+{
+ public UserSummaryDto? User { get; set; }
+ public int ExpiresIn { get; set; }
+}
+
+public sealed class RegisterRequest
+{
+ /// Optional — AuthHex requires a client-supplied id; ERPCore generates one when omitted.
+ 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; }
+ /// Passed through as-is — AuthHex's Role/UserType shapes aren't in the documented catalog.
+ public JsonElement? Role { get; set; }
+ public JsonElement? UserType { get; set; }
+}
+
+/// AuthHex's UserType lookup (ERP_Auth_Service/API_DOCUMENTATION.md, listUserTypes).
+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; }
+}
+
+/// Passthrough — TOTP secret/QR payload shape is only loosely documented
+/// ("secret key, QR/otpauth URL ... from the third-party service").
+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 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
+{
+ ///
+ /// Optional: AuthHex returns no userId on login, so browsers cannot supply one.
+ /// When omitted, the controller resolves it from the session token's UserId claim.
+ ///
+ public Guid? UserId { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Brands/BrandDtos.cs b/Backend/ERPCore/Dtos/Brands/BrandDtos.cs
new file mode 100644
index 0000000..b5f6c80
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Brands/BrandDtos.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.Brands;
+
+/// Brand resource (docs/11-BACKEND-PHASE1.md §2.6).
+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; }
+}
diff --git a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs
index 744026e..ed27120 100644
--- a/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs
+++ b/Backend/ERPCore/Dtos/Categories/CategoryDtos.cs
@@ -1,15 +1,56 @@
using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
namespace ERPCore.Dtos.Categories;
-/// Flat category resource (docs/11-BACKEND-PHASE1.md §2.3).
-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.
-/// Nested category node for GET /categories?tree=true.
-public sealed record CategoryTreeDto(int CategoryId, string Name, int? ParentId, IReadOnlyList Children);
+/// Category resource — the top level.
+public sealed record CategoryDto(
+ int CategoryId, string Name, EntityStatus Status, DateTime CreatedAt, DateTime? UpdatedAt);
+
+/// Subcategory resource — the single optional level below a category.
+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; }
+}
+
+/// Body for POST /categories/{categoryId}/subcategories; the parent comes from the route.
+public sealed class CreateSubCategoryRequest
+{
+ [Required, StringLength(200)] public string Name { get; set; } = string.Empty;
+}
+
+///
+/// Body for PUT /subcategories/{id}. Name only — a subcategory cannot be reparented,
+/// since moving one would silently invalidate the category of every item referencing it.
+///
+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; }
}
diff --git a/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
new file mode 100644
index 0000000..f4e2e71
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Config/ProductConfigDtos.cs
@@ -0,0 +1,27 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ERPCore.Dtos.Config;
+
+///
+/// Product configuration resource (docs/11-BACKEND-PHASE1.md §2.8). Singleton.
+/// is advisory (frontend-honoured) — see the entity docs.
+///
+public sealed record ProductConfigDto(
+ bool SubcategoriesEnabled, bool BrandsEnabled, bool ItemTypesEnabled,
+ DateTime? UpdatedAt, int? UpdatedBy);
+
+///
+/// Full replacement of the flags. UpdatedBy is derived from the token, never posted.
+///
+/// The flags are ? deliberately: [Required] on a non-nullable bool
+/// is a no-op (it always has a value), so a body of {} would bind every flag to
+/// false and silently switch all three features off. Nullable makes the requirement
+/// actually bind — an omitted flag is a 400, not an accidental disable.
+///
+///
+public sealed class UpdateProductConfigRequest
+{
+ [Required] public bool? SubcategoriesEnabled { get; set; }
+ [Required] public bool? BrandsEnabled { get; set; }
+ [Required] public bool? ItemTypesEnabled { get; set; }
+}
diff --git a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
index bdcff2b..aec88c2 100644
--- a/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
+++ b/Backend/ERPCore/Dtos/Grn/GrnDtos.cs
@@ -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 Lines);
+/// Row shape for GET /grns — line count instead of the lines themselves.
+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; }
- /// Used only for direct (no-PO) receipts; ignored when is set.
+ ///
+ /// 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).
+ ///
[Range(0, double.MaxValue)] public decimal UnitCost { get; set; }
+ /// Trade discount percentage (0–100). Reduces the inventory cost.
+ [Range(0, 100)] public decimal DiscountPct { get; set; }
+ /// VAT percentage (0–100). Recoverable — does not affect stock value.
+ [Range(0, 100)] public decimal VatPct { get; set; }
[EnumDataType(typeof(HoldStatus))] public HoldStatus HoldStatus { get; set; } = HoldStatus.Available;
public BatchInput? Batch { get; set; }
}
diff --git a/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs
new file mode 100644
index 0000000..35d2e92
--- /dev/null
+++ b/Backend/ERPCore/Dtos/ItemTypes/ItemTypeDtos.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel.DataAnnotations;
+using ERPCore.Domain.Enums;
+
+namespace ERPCore.Dtos.ItemTypes;
+
+///
+/// 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: GET /item-types 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).
+///
+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; }
+}
diff --git a/Backend/ERPCore/Dtos/Items/ItemDtos.cs b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
index 7bb11e7..adddcfa 100644
--- a/Backend/ERPCore/Dtos/Items/ItemDtos.cs
+++ b/Backend/ERPCore/Dtos/Items/ItemDtos.cs
@@ -7,18 +7,28 @@ namespace ERPCore.Dtos.Items;
/// Row shape for GET /items.
public sealed record ItemListItemDto(
- int ItemId, string Sku, string Name, int CategoryId, int BaseUomId,
- int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
+ int ItemId, string Sku, string Name, int CategoryId, int? SubCategoryId, int? BrandId,
+ int BaseUomId, int? DefaultVendorId, StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status);
/// A single per-warehouse reorder policy row.
public sealed record ItemReorderDto(int WarehouseId, decimal ReorderPoint, decimal ReorderQty);
-/// Full item resource for GET /items/{id} and create/update responses.
+///
+/// Full item resource for GET /items/{id} and create/update responses.
+///
+/// is embedded because they are otherwise unreadable: they can
+/// only be written via PUT /items/{id}/uom-conversions, which returns them, but no
+/// endpoint reads them back — so a detail screen could never show current state before
+/// editing. Mirrors how is already inlined.
+///
+///
public sealed record ItemDetailDto(
int ItemId, string Sku, string Name, string? Description, int CategoryId,
- int BaseUomId, int? DefaultVendorId, ItemType ItemType, TrackingMode TrackingMode,
+ int? SubCategoryId, int? BrandId, int BaseUomId, int? DefaultVendorId,
+ StockNature StockNature, TrackingMode TrackingMode,
string? TaxClass, EntityStatus Status, IReadOnlyList Reorder,
+ IReadOnlyList Conversions,
DateTime CreatedAt, DateTime? UpdatedAt);
/// UOM conversion row (docs/11 §2.2).
@@ -33,15 +43,23 @@ public sealed record ItemReorderSettingsDto(IReadOnlyList 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; }
+ /// Optional; must belong to . Rejected when subcategories are disabled.
+ public int? SubCategoryId { get; set; }
+ /// Optional. Rejected when brands are disabled.
+ 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; }
}
@@ -52,9 +70,13 @@ 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; }
+ /// Optional; must belong to . Rejected when subcategories are disabled.
+ public int? SubCategoryId { get; set; }
+ /// Optional. Rejected when brands are disabled.
+ 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; }
}
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
index c70e9a7..d062179 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseOrderDtos.cs
@@ -37,6 +37,13 @@ public sealed class CreatePurchaseOrderRequest
[Required] public int VendorId { get; set; }
public int? RequisitionId { get; set; }
[Required, MinLength(1)] public List Lines { get; set; } = new();
+
+ ///
+ /// When true the PO is created in Draft (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).
+ ///
+ public bool SaveAsDraft { get; set; }
}
public sealed class UpdatePurchaseOrderRequest
diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
index f9deb75..5854fbb 100644
--- a/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/PurchaseReturnDtos.cs
@@ -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 Lines, IReadOnlyList LedgerRefs);
+ int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs);
+
+/// Row shape for GET /purchase-returns — no lines/ledgerRefs (those need a per-row query).
+public sealed record PurchaseReturnSummaryDto(
+ int ReturnId, string DocNo, int VendorId, int WarehouseId, int ReasonCodeId, ReturnStatus Status,
+ int CreatedBy, DateTime CreatedAt, int LineCount);
// Requests ----------------------------------------------------------------------
diff --git a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
index 63b5e0a..ef8cae1 100644
--- a/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/RequisitionDtos.cs
@@ -12,7 +12,7 @@ public sealed record RequisitionDto(
DateTime CreatedAt, IReadOnlyList 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 ----
diff --git a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
index d54ab90..f9a5635 100644
--- a/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
+++ b/Backend/ERPCore/Dtos/Procurement/RfqDtos.cs
@@ -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 Lines);
+/// Row shape for GET /rfqs — line/quotation counts instead of the lines themselves.
+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(
diff --git a/Backend/ERPCore/Dtos/Rbac/MeDtos.cs b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
new file mode 100644
index 0000000..9396bcf
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Rbac/MeDtos.cs
@@ -0,0 +1,6 @@
+namespace ERPCore.Dtos.Rbac;
+
+/// 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).
+public sealed record MeResponseDto(string? RoleCode, string? RoleName, IReadOnlyList NavCodes);
diff --git a/Backend/ERPCore/Dtos/Rbac/NavDtos.cs b/Backend/ERPCore/Dtos/Rbac/NavDtos.cs
new file mode 100644
index 0000000..76e5e30
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Rbac/NavDtos.cs
@@ -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 Children);
diff --git a/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs b/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs
new file mode 100644
index 0000000..2c0d26b
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Rbac/RoleDtos.cs
@@ -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; }
+}
+
+/// Replaces a role's full permission set (checkbox-tree save from the frontend).
+public sealed class AssignRolePermissionsRequest
+{
+ public List NavItemIds { get; set; } = new();
+ public List SubNavItemIds { get; set; } = new();
+}
+
+public sealed record RolePermissionsDto(int RoleId, List NavItemIds, List SubNavItemIds);
diff --git a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs
index a8e9696..d7b97ce 100644
--- a/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs
+++ b/Backend/ERPCore/Dtos/Stock/AdjustmentDtos.cs
@@ -11,6 +11,11 @@ public sealed record AdjustmentDto(
int AdjustmentId, string DocNo, int WarehouseId, int ReasonCodeId, AdjustmentStatus Status,
int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines, IReadOnlyList LedgerRefs);
+/// Row shape for GET /stock-adjustments — no lines/ledgerRefs (those need a per-row query).
+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
diff --git a/Backend/ERPCore/Dtos/Stock/CountDtos.cs b/Backend/ERPCore/Dtos/Stock/CountDtos.cs
index 44a67ec..a75a262 100644
--- a/Backend/ERPCore/Dtos/Stock/CountDtos.cs
+++ b/Backend/ERPCore/Dtos/Stock/CountDtos.cs
@@ -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 Lines);
+ int CountId, string DocNo, int WarehouseId, CountType CountType, CountStatus Status,
+ int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines);
+
+/// Row shape for GET /stock-counts — line count instead of the lines themselves.
+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 LedgerRefs);
diff --git a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs
index dd91237..5d4d1d7 100644
--- a/Backend/ERPCore/Dtos/Stock/TransferDtos.cs
+++ b/Backend/ERPCore/Dtos/Stock/TransferDtos.cs
@@ -10,7 +10,12 @@ public sealed record TransferLineDto(
public sealed record TransferDto(
int TransferId, string DocNo, int SrcWarehouseId, int DestWarehouseId,
- TransferStatus Status, IReadOnlyList Lines);
+ TransferStatus Status, int CreatedBy, DateTime CreatedAt, IReadOnlyList Lines);
+
+/// Row shape for GET /stock-transfers — line count instead of the lines themselves.
+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);
diff --git a/Backend/ERPCore/Dtos/Users/UserDtos.cs b/Backend/ERPCore/Dtos/Users/UserDtos.cs
new file mode 100644
index 0000000..0d4c5ce
--- /dev/null
+++ b/Backend/ERPCore/Dtos/Users/UserDtos.cs
@@ -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);
+
+///
+/// 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`.
+///
+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; }
+ /// Left empty to auto-generate (AuthHex emails it to ).
+ public string? Password { get; set; }
+}
+
+public sealed class UpdateUserRoleRequest
+{
+ [Required] public int RoleId { get; set; }
+}
+
+/// AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).
+public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
diff --git a/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs
new file mode 100644
index 0000000..bc1ba9f
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthCookieWriter.cs
@@ -0,0 +1,52 @@
+using System.Security.Cryptography;
+
+namespace ERPCore.Infra.Auth;
+
+///
+/// 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).
+///
+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));
+}
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
new file mode 100644
index 0000000..ef6b616
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
@@ -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;
+
+///
+/// HTTP implementation of . Registered as a typed
+/// client (`AddHttpClient<IAuthHexClient, AuthHexClient>`) with its
+/// `BaseAddress` bound from `AuthHex:BaseUrl`. Every call POSTs AuthHex's
+/// `{ functionName, payload, reference }` envelope to the matching manager
+/// route and unwraps the `{ statusCode, success, message, data }` response,
+/// translating upstream failures into .
+///
+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 RegisterAsync(RegisterRequest request, CancellationToken ct)
+ => CallAsync("user", "registerUser", request, null, ct);
+
+ public Task LoginAsync(LoginRequest request, CancellationToken ct)
+ => CallAsync("user", "loginUser", request, null, ct);
+
+ public Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct)
+ => CallAsync("user", "VerifyOtpForLogin", request, null, ct);
+
+ public Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct)
+ => CallAsync("user", "refreshToken", new { refreshToken, deviceName }, null, ct);
+
+ public Task GetUserDetailsAsync(Guid userId, CancellationToken ct)
+ => CallAsync("user", "getUserDetails", new { userId }, null, ct);
+
+ public Task> ListUserTypesAsync(CancellationToken ct)
+ => CallAsync>("user", "listUserTypes", new { }, null, ct);
+
+ public Task> GetUserSessionsAsync(string bearerToken, CancellationToken ct)
+ => CallAsync>("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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct)
+ => CallAsync("user", "VerifyPassword", request, bearerToken, ct);
+
+ public Task LogoutUserAsync(Guid userId, CancellationToken ct)
+ => CallVoidAsync("user", "LogoutUser", new { userId }, null, ct);
+
+ public Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct)
+ => CallAsync("user", "UpdateUser", request, bearerToken, ct);
+
+ public Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct)
+ => CallAsync("user", "initiateTwoFASetup", new { }, bearerToken, ct);
+
+ public Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct)
+ => CallAsync("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 GetTwoFaStatusAsync(string bearerToken, CancellationToken ct)
+ => CallAsync("user", "getTwoFAStatus", new { }, bearerToken, ct);
+
+ // ---- RecoveryManager ------------------------------------------------
+
+ public Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct)
+ => CallAsync("recovery", "forgotPassword", request, null, ct);
+
+ public Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct)
+ => CallAsync("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 IsAvailableAsync(IsAvailableRequest request, CancellationToken ct)
+ => CallAsync("alt", "IsAvailable", request, null, ct);
+
+ public Task SendOtpAsync(SendOtpRequest request, CancellationToken ct)
+ => CallAsync("alt", "sendOtp", request, null, ct);
+
+ public Task VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
+ => CallAsync("alt", "VerifyOTP", request, null, ct);
+
+ // ---- RoleManager --------------------------------------------------
+
+ public Task CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct)
+ => CallAsync("role", "createRole", request, null, ct);
+
+ public Task> ListRolesAsync(CancellationToken ct)
+ => CallAsync>("role", "listRoles", new { }, null, ct);
+
+ public Task GetRoleAsync(Guid roleId, CancellationToken ct)
+ => CallAsync("role", "getRole", new { roleId }, null, ct);
+
+ public Task UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct)
+ => CallAsync("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(routeGroup, functionName, payload, bearerToken, ct);
+
+ private async Task CallAsync(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? envelope;
+ try
+ {
+ envelope = await httpResponse.Content.ReadFromJsonAsync>(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!;
+ }
+}
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs
new file mode 100644
index 0000000..02f7794
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexEnvelope.cs
@@ -0,0 +1,37 @@
+using ERPCore.Dtos.Auth;
+
+namespace ERPCore.Infra.Auth.AuthHex;
+
+/// Wire shape of AuthHex's `ApiResponse` envelope (API_REFERENCE.md §1).
+public sealed class AuthHexEnvelope
+{
+ public int StatusCode { get; set; }
+ public bool Success { get; set; }
+ public string? Message { get; set; }
+ public T? Data { get; set; }
+}
+
+/// Wire shape of AuthHex's `ApiRequest` envelope (API_REFERENCE.md §1).
+public sealed class AuthHexRequestBody
+{
+ public string FunctionName { get; set; } = string.Empty;
+ public object Payload { get; set; } = new { };
+ public string Reference { get; set; } = string.Empty;
+}
+
+///
+/// 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 .
+///
+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; }
+}
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
new file mode 100644
index 0000000..cd0f614
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
@@ -0,0 +1,51 @@
+using ERPCore.Dtos.Auth;
+
+namespace ERPCore.Infra.Auth.AuthHex;
+
+///
+/// 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.
+///
+public interface IAuthHexClient
+{
+ // UserManager (POST /api/user)
+ Task RegisterAsync(RegisterRequest request, CancellationToken ct);
+ Task LoginAsync(LoginRequest request, CancellationToken ct);
+ Task VerifyOtpForLoginAsync(VerifyOtpForLoginRequest request, CancellationToken ct);
+ Task RefreshTokenAsync(string refreshToken, string? deviceName, CancellationToken ct);
+ Task GetUserDetailsAsync(Guid userId, CancellationToken ct);
+ Task> ListUserTypesAsync(CancellationToken ct);
+ Task> 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 VerifyPasswordAsync(VerifyPasswordRequest request, string bearerToken, CancellationToken ct);
+ Task LogoutUserAsync(Guid userId, CancellationToken ct);
+ Task UpdateUserAsync(UpdateUserRequest request, string bearerToken, CancellationToken ct);
+ Task InitiateTwoFaSetupAsync(string bearerToken, CancellationToken ct);
+ Task CompleteTwoFaSetupAsync(CompleteTwoFaSetupRequest request, string bearerToken, CancellationToken ct);
+ Task VerifyTwoFaAsync(VerifyTwoFaRequest request, string bearerToken, CancellationToken ct);
+ Task DisableTwoFaAsync(DisableTwoFaRequest request, string bearerToken, CancellationToken ct);
+ Task GetTwoFaStatusAsync(string bearerToken, CancellationToken ct);
+
+ // RecoveryManager (POST /api/recovery)
+ Task ForgotPasswordAsync(ForgotPasswordRequest request, CancellationToken ct);
+ Task VerifyRecoveryOtpAsync(VerifyRecoveryOtpRequest request, CancellationToken ct);
+ Task ResetPasswordAsync(ResetPasswordRequest request, CancellationToken ct);
+ Task ResetPasswordWithTokenAsync(ResetPasswordWithTokenRequest request, CancellationToken ct);
+
+ // AltOptionManager (POST /api/alt)
+ Task IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
+ Task SendOtpAsync(SendOtpRequest request, CancellationToken ct);
+ Task 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 CreateRoleAsync(CreateAuthHexRoleRequest request, CancellationToken ct);
+ Task> ListRolesAsync(CancellationToken ct);
+ Task GetRoleAsync(Guid roleId, CancellationToken ct);
+ Task UpdateRoleAsync(UpdateAuthHexRoleRequest request, CancellationToken ct);
+ Task DeleteRoleAsync(Guid roleId, CancellationToken ct);
+}
diff --git a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
index ea879ca..658433f 100644
--- a/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
+++ b/Backend/ERPCore/Infra/Auth/JwtAuthExtensions.cs
@@ -18,6 +18,18 @@ public static class JwtAuthExtensions
/// Authorization policy applied to every v1 controller (via ApiControllerBase).
public const string ErpAccessPolicy = "ErpAccess";
+ /// httpOnly cookie AuthController writes the AuthHex access token into (docs/02-SECURITY.md §B.2).
+ public const string AccessTokenCookie = "erp_at";
+
+ /// httpOnly cookie AuthController writes the AuthHex refresh token into, scoped to the refresh route.
+ public const string RefreshTokenCookie = "erp_rt";
+
+ /// Non-httpOnly CSRF cookie for the double-submit check on cookie-authenticated mutations.
+ public const string CsrfCookie = "XSRF-TOKEN";
+
+ /// Header the frontend echoes the CSRF cookie value back through.
+ 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 =>
diff --git a/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs
new file mode 100644
index 0000000..cf4a631
--- /dev/null
+++ b/Backend/ERPCore/Infra/Auth/ValidateCsrfAttribute.cs
@@ -0,0 +1,35 @@
+using ERPCore.System.Errors;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace ERPCore.Infra.Auth;
+
+///
+/// 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 X-XSRF-TOKEN
+/// header to match the non-httpOnly XSRF-TOKEN cookie AuthController
+/// issues alongside the session cookies.
+///
+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();
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs
new file mode 100644
index 0000000..52724a8
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/BrandConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs
index a0565cb..8cc8732 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/CategoryConfiguration.cs
@@ -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
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().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);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
index 087ea19..0c47198 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/GrnConfiguration.cs
@@ -37,7 +37,13 @@ public sealed class GrnLineConfiguration : IEntityTypeConfiguration
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().HasMaxLength(20).IsRequired();
builder.HasOne(l => l.Grn).WithMany(g => g.Lines).HasForeignKey(l => l.GrnId).OnDelete(DeleteBehavior.Cascade);
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
index 4a74f03..dab0f1f 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemConfiguration.cs
@@ -19,7 +19,7 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration-
builder.Property(i => i.Description).HasMaxLength(1000);
builder.Property(i => i.TaxClass).HasMaxLength(20);
- builder.Property(i => i.ItemType)
+ builder.Property(i => i.StockNature)
.HasConversion().HasMaxLength(20).IsRequired();
builder.Property(i => i.TrackingMode)
.HasConversion().HasMaxLength(20).IsRequired();
@@ -37,6 +37,16 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration
-
.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 +59,6 @@ public sealed class ItemConfiguration : IEntityTypeConfiguration
-
builder.HasIndex(i => i.Status);
builder.HasIndex(i => i.CategoryId);
+ builder.HasIndex(i => i.BrandId);
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs
new file mode 100644
index 0000000..c26f97c
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ItemTypeConfiguration.cs
@@ -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;
+
+///
+/// Configures the ItemType master (Color/Size/Material). Note there are deliberately no
+/// relationships here — nothing references this table (docs/10 Part C.9).
+///
+public sealed class ItemTypeConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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().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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs
new file mode 100644
index 0000000..0a89d0a
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/NavItemConfiguration.cs
@@ -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;
+
+///
+/// Seeded to mirror the frontend's hardcoded sidebar
+/// (ERP-core/Frontend/erp-system/components/Layouts/AppSidebar.tsx). Codes here
+/// must match the code given to each frontend nav entry.
+///
+public sealed class NavItemConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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().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 }
+ );
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
new file mode 100644
index 0000000..065f5b8
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
@@ -0,0 +1,51 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+///
+/// One row per /, seeded in lockstep
+/// with /.
+///
+public sealed class PermissionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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 }
+ );
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
new file mode 100644
index 0000000..d916d09
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/ProductConfigConfiguration.cs
@@ -0,0 +1,37 @@
+using ERPCore.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace ERPCore.Infra.Persistence.Configurations;
+
+///
+/// Configures the singleton product-configuration row (FR-MD-11). The check constraint
+/// is what makes "singleton" a database guarantee rather than a convention.
+///
+public sealed class ProductConfigConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder 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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RoleConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RoleConfiguration.cs
new file mode 100644
index 0000000..dcf2712
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/RoleConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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();
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs
new file mode 100644
index 0000000..62c903f
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/RolePermissionConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs
new file mode 100644
index 0000000..9fadd72
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubCategoryConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("subcategories");
+ builder.HasKey(s => s.SubCategoryId);
+
+ builder.Property(s => s.Name).IsRequired().HasMaxLength(200);
+
+ builder.Property(s => s.Status)
+ .HasConversion().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);
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
new file mode 100644
index 0000000..eb4f156
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
@@ -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
+{
+ public void Configure(EntityTypeBuilder 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().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 }
+ );
+ }
+}
diff --git a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs
index eee8827..685a9aa 100644
--- a/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/UserConfiguration.cs
@@ -23,6 +23,10 @@ public sealed class UserConfiguration : IEntityTypeConfiguration
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
{
diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
index 59a8803..79a2352 100644
--- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
+++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs
@@ -12,6 +12,13 @@ namespace ERPCore.Infra.Persistence;
///
public static class DataSeeder
{
+ ///
+ /// 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.
+ ///
+ 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 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 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static async Task 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;
}
}
diff --git a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index 6a7d7e6..dfcdc6c 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore;
namespace ERPCore.Infra.Persistence;
///
-/// 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
/// 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 Categories => Set();
+ public DbSet SubCategories => Set();
+ public DbSet Brands => Set();
+ /// Color/Size/Material dimension names. Unlinked to Item by design (docs/10 C.9).
+ public DbSet ItemTypes => Set();
public DbSet Uoms => Set();
public DbSet UomConversions => Set();
public DbSet
- Items => Set
- ();
@@ -30,11 +34,20 @@ public class ErpDbContext : DbContext
public DbSet Vendors => Set();
public DbSet Warehouses => Set