diff --git a/Backend/ERPCore/Controllers/AuthController.cs b/Backend/ERPCore/Controllers/AuthController.cs
index a855e6c..0b7dd25 100644
--- a/Backend/ERPCore/Controllers/AuthController.cs
+++ b/Backend/ERPCore/Controllers/AuthController.cs
@@ -1,4 +1,5 @@
using ERPCore.Dtos.Auth;
+using ERPCore.Dtos.Rbac;
using ERPCore.Infra.Auth;
using ERPCore.Services.Interfaces;
using ERPCore.System.Errors;
@@ -25,12 +26,27 @@ 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)
+ 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) ------------------------------------
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/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/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/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/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/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/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
index 4b541a2..a8ca585 100644
--- a/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
+++ b/Backend/ERPCore/Dtos/Auth/AuthUserDtos.cs
@@ -82,6 +82,14 @@ public sealed class GetUserDetailsResponse
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; }
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/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/AuthHex/AuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
index 125230f..ef6b616 100644
--- a/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/AuthHexClient.cs
@@ -1,6 +1,7 @@
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;
@@ -16,9 +17,14 @@ namespace ERPCore.Infra.Auth.AuthHex;
///
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
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly HttpClient _http;
@@ -42,6 +48,9 @@ public sealed class AuthHexClient : IAuthHexClient
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);
@@ -103,6 +112,23 @@ public sealed class AuthHexClient : IAuthHexClient
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)
diff --git a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
index 081f74c..cd0f614 100644
--- a/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
+++ b/Backend/ERPCore/Infra/Auth/AuthHex/IAuthHexClient.cs
@@ -16,6 +16,7 @@ public interface IAuthHexClient
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);
@@ -39,4 +40,12 @@ public interface IAuthHexClient
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/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..35b2ede
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/PermissionConfiguration.cs
@@ -0,0 +1,47 @@
+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 }
+ );
+ }
+}
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/SubNavItemConfiguration.cs b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
new file mode 100644
index 0000000..bfb2c5d
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Configurations/SubNavItemConfiguration.cs
@@ -0,0 +1,38 @@
+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 }
+ );
+ }
+}
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/ErpDbContext.cs b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
index 7305983..dfcdc6c 100644
--- a/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
+++ b/Backend/ERPCore/Infra/Persistence/ErpDbContext.cs
@@ -41,6 +41,13 @@ public class ErpDbContext : DbContext
public DbSet Users => Set();
public DbSet NumberSequences => Set();
+ // --- RBAC / sidebar (docs/10 Part C.8) ---
+ public DbSet Roles => Set();
+ public DbSet NavItems => Set();
+ public DbSet SubNavItems => Set();
+ public DbSet Permissions => Set();
+ public DbSet RolePermissions => Set();
+
// --- Procurement (docs/10 Part C.2) ---
public DbSet Requisitions => Set();
public DbSet RequisitionLines => Set();
diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs
new file mode 100644
index 0000000..184ab18
--- /dev/null
+++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.Designer.cs
@@ -0,0 +1,2454 @@
+//
+using System;
+using ERPCore.Infra.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace ERPCore.Infra.Persistence.Migrations
+{
+ [DbContext(typeof(ErpDbContext))]
+ [Migration("20260718081219_ini2")]
+ partial class ini2
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b =>
+ {
+ b.Property("AuditId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuditId"));
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("ChangeSet")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EntityId")
+ .HasColumnType("integer");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(80)
+ .HasColumnType("character varying(80)");
+
+ b.Property("UserId")
+ .HasColumnType("integer");
+
+ b.HasKey("AuditId");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_logs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b =>
+ {
+ b.Property("BatchId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BatchId"));
+
+ b.Property("BatchNo")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ExpiryDate")
+ .HasColumnType("date");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.HasKey("BatchId");
+
+ b.HasIndex("ItemId", "BatchNo")
+ .IsUnique();
+
+ b.ToTable("batches", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b =>
+ {
+ b.Property("BinId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BinId"));
+
+ b.Property("BinType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("BinId");
+
+ b.HasIndex("WarehouseId", "Code")
+ .IsUnique();
+
+ b.ToTable("bins", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Brand", b =>
+ {
+ b.Property("BrandId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BrandId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("BrandId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("brands", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Category", b =>
+ {
+ b.Property("CategoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("CategoryId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("categories", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b =>
+ {
+ b.Property("GrnId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("PoId")
+ .HasColumnType("integer");
+
+ b.Property("PostedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("VendorId")
+ .HasColumnType("integer");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("GrnId");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("PoId");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("VendorId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("grns", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b =>
+ {
+ b.Property("GrnLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("GrnLineId"));
+
+ b.Property("BatchId")
+ .HasColumnType("integer");
+
+ b.Property("BinId")
+ .HasColumnType("integer");
+
+ b.Property("GrnId")
+ .HasColumnType("integer");
+
+ b.Property("HoldStatus")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("PoLineId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ReceivedValue")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UnitCost")
+ .HasPrecision(18, 6)
+ .HasColumnType("numeric(18,6)");
+
+ b.Property("UomId")
+ .HasColumnType("integer");
+
+ b.HasKey("GrnLineId");
+
+ b.HasIndex("BatchId");
+
+ b.HasIndex("BinId");
+
+ b.HasIndex("GrnId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("PoLineId");
+
+ b.HasIndex("UomId");
+
+ b.ToTable("grn_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Item", b =>
+ {
+ b.Property("ItemId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemId"));
+
+ b.Property("BaseUomId")
+ .HasColumnType("integer");
+
+ b.Property("BrandId")
+ .HasColumnType("integer");
+
+ b.Property("CategoryId")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DefaultVendorId")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Sku")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("StockNature")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("SubCategoryId")
+ .HasColumnType("integer");
+
+ b.Property("TaxClass")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("TrackingMode")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("ItemId");
+
+ b.HasIndex("BaseUomId");
+
+ b.HasIndex("BrandId");
+
+ b.HasIndex("CategoryId");
+
+ b.HasIndex("DefaultVendorId");
+
+ b.HasIndex("Sku")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.HasIndex("SubCategoryId");
+
+ b.ToTable("items", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b =>
+ {
+ b.Property("ReorderId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReorderId"));
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("ReorderPoint")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ReorderQty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("ReorderId");
+
+ b.HasIndex("WarehouseId");
+
+ b.HasIndex("ItemId", "WarehouseId")
+ .IsUnique();
+
+ b.ToTable("item_reorders", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.ItemType", b =>
+ {
+ b.Property("ItemTypeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ItemTypeId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("Active");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("ItemTypeId");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("item_types", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.JournalEntryStub", b =>
+ {
+ b.Property("JournalId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("JournalId"));
+
+ b.Property("Amount")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("CreditAccount")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("DebitAccount")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("SourceDocId")
+ .HasColumnType("integer");
+
+ b.Property("SourceDocType")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.HasKey("JournalId");
+
+ b.HasIndex("SourceDocType", "SourceDocId");
+
+ b.ToTable("journal_entry_stubs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b =>
+ {
+ b.Property("SequenceId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceId"));
+
+ b.Property("DocType")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("doc_type");
+
+ b.Property("LastNumber")
+ .HasColumnType("integer")
+ .HasColumnName("last_number");
+
+ b.Property("Year")
+ .HasColumnType("integer")
+ .HasColumnName("year");
+
+ b.HasKey("SequenceId");
+
+ b.HasIndex("DocType", "Year")
+ .IsUnique();
+
+ b.ToTable("number_sequences", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b =>
+ {
+ b.Property("PoLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoLineId"));
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("PoId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("QtyReceived")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("Tax")
+ .HasPrecision(9, 4)
+ .HasColumnType("numeric(9,4)");
+
+ b.Property("UnitPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("UomId")
+ .HasColumnType("integer");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("PoLineId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("PoId");
+
+ b.HasIndex("UomId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("po_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b =>
+ {
+ b.Property("ConfigId")
+ .HasColumnType("integer");
+
+ b.Property("BrandsEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("ItemTypesEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("SubcategoriesEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UpdatedBy")
+ .HasColumnType("integer");
+
+ b.HasKey("ConfigId");
+
+ b.HasIndex("UpdatedBy");
+
+ b.ToTable("product_config", null, t =>
+ {
+ t.HasCheckConstraint("ck_product_config_singleton", "\"ConfigId\" = 1");
+ });
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b =>
+ {
+ b.Property("PoId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PoId"));
+
+ b.Property("ApprovalRequired")
+ .HasColumnType("boolean");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RequisitionId")
+ .HasColumnType("integer");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VendorId")
+ .HasColumnType("integer");
+
+ b.HasKey("PoId");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("RequisitionId");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("VendorId");
+
+ b.ToTable("purchase_orders", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b =>
+ {
+ b.Property("ReturnId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedBy")
+ .HasColumnType("integer");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("ReasonCodeId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("VendorId")
+ .HasColumnType("integer");
+
+ b.Property("WarehouseId")
+ .HasColumnType("integer");
+
+ b.HasKey("ReturnId");
+
+ b.HasIndex("CreatedBy");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("ReasonCodeId");
+
+ b.HasIndex("VendorId");
+
+ b.HasIndex("WarehouseId");
+
+ b.ToTable("purchase_returns", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b =>
+ {
+ b.Property("ReturnLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReturnLineId"));
+
+ b.Property("GrnLineId")
+ .HasColumnType("integer");
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("ReturnId")
+ .HasColumnType("integer");
+
+ b.HasKey("ReturnLineId");
+
+ b.HasIndex("GrnLineId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("ReturnId");
+
+ b.ToTable("purchase_return_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.ReasonCode", b =>
+ {
+ b.Property("ReasonCodeId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReasonCodeId"));
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Context")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.HasKey("ReasonCodeId");
+
+ b.HasIndex("Context", "Code")
+ .IsUnique();
+
+ b.ToTable("reason_codes", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b =>
+ {
+ b.Property("RequisitionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RequisitionId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RequestedBy")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("RequisitionId");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("RequestedBy");
+
+ b.HasIndex("Status");
+
+ b.ToTable("requisitions", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b =>
+ {
+ b.Property("ReqLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ReqLineId"));
+
+ b.Property("ItemId")
+ .HasColumnType("integer");
+
+ b.Property("Qty")
+ .HasPrecision(18, 4)
+ .HasColumnType("numeric(18,4)");
+
+ b.Property("RequiredBy")
+ .HasColumnType("date");
+
+ b.Property("RequisitionId")
+ .HasColumnType("integer");
+
+ b.HasKey("ReqLineId");
+
+ b.HasIndex("ItemId");
+
+ b.HasIndex("RequisitionId");
+
+ b.ToTable("requisition_lines", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b =>
+ {
+ b.Property("RfqId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqId"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DocNo")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)");
+
+ b.Property("RequisitionId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)");
+
+ b.HasKey("RfqId");
+
+ b.HasIndex("DocNo")
+ .IsUnique();
+
+ b.HasIndex("RequisitionId");
+
+ b.ToTable("rfqs", (string)null);
+ });
+
+ modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b =>
+ {
+ b.Property("RfqLineId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RfqLineId"));
+
+ b.Property