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("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + 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("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + 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("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + 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("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ToUomId") + .HasColumnType("integer"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + 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("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .HasForeignKey("DefaultVendorId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.cs new file mode 100644 index 0000000..4c89398 --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260718081219_ini2.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class ini2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs new file mode 100644 index 0000000..ca80bfc --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.Designer.cs @@ -0,0 +1,3001 @@ +// +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("20260718092655_AddRolesNavPermissions")] + partial class AddRolesNavPermissions + { + /// + 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.NavItem", b => + { + b.Property("NavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.HasKey("NavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("nav_items", (string)null); + + b.HasData( + new + { + NavItemId = 1, + Code = "dashboard", + Href = "/dashboard", + Label = "Dashboard", + SortOrder = 1, + Status = "Active" + }, + new + { + NavItemId = 2, + Code = "products", + Href = "/dashboard/products", + Label = "Products", + SortOrder = 2, + Status = "Active" + }, + new + { + NavItemId = 3, + Code = "vendors", + Href = "/dashboard/vendors", + Label = "Vendors", + SortOrder = 3, + Status = "Active" + }, + new + { + NavItemId = 4, + Code = "procurement", + Href = "/dashboard/procurement", + Label = "Procurement", + SortOrder = 4, + Status = "Active" + }, + new + { + NavItemId = 5, + Code = "receiving", + Href = "/dashboard/receiving/grn", + Label = "Receiving", + SortOrder = 5, + Status = "Active" + }, + new + { + NavItemId = 6, + Code = "stock", + Href = "/dashboard/stock", + Label = "Stock", + SortOrder = 6, + Status = "Active" + }, + new + { + NavItemId = 7, + Code = "warehouses", + Href = "/dashboard/warehouse", + Label = "Warehouses", + SortOrder = 7, + Status = "Active" + }, + new + { + NavItemId = 8, + Code = "orders", + Href = "/dashboard/orders", + Label = "Orders", + SortOrder = 8, + Status = "Active" + }, + new + { + NavItemId = 9, + Code = "settings", + Href = "/dashboard/settings", + Label = "Settings", + SortOrder = 9, + Status = "Active" + }, + new + { + NavItemId = 10, + Code = "help", + Href = "/dashboard/help", + Label = "Help", + SortOrder = 10, + Status = "Active" + }); + }); + + 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.Permission", b => + { + b.Property("PermissionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PermissionId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("NavItemId") + .HasColumnType("integer"); + + b.Property("SubNavItemId") + .HasColumnType("integer"); + + b.HasKey("PermissionId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.HasIndex("SubNavItemId"); + + b.ToTable("permissions", (string)null); + + b.HasData( + new + { + PermissionId = 1, + Code = "NAV:dashboard", + NavItemId = 1 + }, + new + { + PermissionId = 2, + Code = "NAV:products", + NavItemId = 2 + }, + new + { + PermissionId = 3, + Code = "NAV:vendors", + NavItemId = 3 + }, + new + { + PermissionId = 4, + Code = "NAV:procurement", + NavItemId = 4 + }, + new + { + PermissionId = 5, + Code = "NAV:receiving", + NavItemId = 5 + }, + new + { + PermissionId = 6, + Code = "NAV:stock", + NavItemId = 6 + }, + new + { + PermissionId = 7, + Code = "NAV:warehouses", + NavItemId = 7 + }, + new + { + PermissionId = 8, + Code = "NAV:orders", + NavItemId = 8 + }, + new + { + PermissionId = 9, + Code = "NAV:settings", + NavItemId = 9 + }, + new + { + PermissionId = 10, + Code = "NAV:help", + NavItemId = 10 + }, + new + { + PermissionId = 11, + Code = "NAV:products.item", + SubNavItemId = 1 + }, + new + { + PermissionId = 12, + Code = "NAV:products.category", + SubNavItemId = 2 + }, + new + { + PermissionId = 13, + Code = "NAV:products.brand", + SubNavItemId = 3 + }, + new + { + PermissionId = 14, + Code = "NAV:products.item-type", + SubNavItemId = 4 + }, + new + { + PermissionId = 15, + Code = "NAV:products.uom", + SubNavItemId = 5 + }, + new + { + PermissionId = 16, + Code = "NAV:products.configuration", + SubNavItemId = 6 + }, + new + { + PermissionId = 17, + Code = "NAV:settings.roles", + SubNavItemId = 7 + }, + new + { + PermissionId = 18, + Code = "NAV:settings.users", + SubNavItemId = 8 + }); + }); + + 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("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.HasKey("RfqLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("RfqId"); + + b.ToTable("rfq_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => + { + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RoleId")); + + b.Property("AuthRoleId") + .HasColumnType("uuid") + .HasColumnName("auth_role_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystemRole") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("RoleId"); + + b.HasIndex("AuthRoleId") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("PermissionId") + .HasColumnType("integer"); + + b.HasKey("RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.Property("SerialId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SerialId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SerialNo") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("SerialId"); + + b.HasIndex("ItemId", "SerialNo") + .IsUnique(); + + b.ToTable("serials", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Property("AdjustmentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjustmentId")); + + 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("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("AdjustmentId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("ReasonCodeId"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_adjustments", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.Property("AdjLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdjLineId")); + + b.Property("AdjustmentId") + .HasColumnType("integer"); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyDelta") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.HasKey("AdjLineId"); + + b.HasIndex("AdjustmentId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.ToTable("stock_adjustment_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Property("CountId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountId")); + + b.Property("CountType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + 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("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("CountId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("Status"); + + b.HasIndex("WarehouseId"); + + b.ToTable("stock_counts", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.Property("CountLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CountLineId")); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CountId") + .HasColumnType("integer"); + + b.Property("CountedQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("SystemQty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("Variance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("CountLineId"); + + b.HasIndex("BinId"); + + b.HasIndex("CountId"); + + b.HasIndex("ItemId"); + + b.ToTable("stock_count_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.Property("LayerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LayerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("GrnLineId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyRemaining") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("ReceiptDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LayerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("GrnLineId"); + + b.HasIndex("SerialId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("ItemId", "WarehouseId", "ReceiptDate", "LayerId"); + + b.ToTable("stock_layers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.Property("LedgerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LedgerId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("BinId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Direction") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("QtyBase") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("RunningBalance") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SourceDocId") + .HasColumnType("integer"); + + b.Property("SourceDocType") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("WarehouseId") + .HasColumnType("integer"); + + b.HasKey("LedgerId"); + + b.HasIndex("BatchId"); + + b.HasIndex("BinId"); + + b.HasIndex("SerialId"); + + b.HasIndex("UserId"); + + b.HasIndex("WarehouseId"); + + b.HasIndex("SourceDocType", "SourceDocId"); + + b.HasIndex("ItemId", "WarehouseId", "CreatedAt"); + + b.HasIndex("ItemId", "WarehouseId", "LedgerId"); + + b.ToTable("stock_ledger", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Property("TransferId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("integer"); + + b.Property("DestWarehouseId") + .HasColumnType("integer"); + + b.Property("DocNo") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SrcWarehouseId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("TransferId"); + + b.HasIndex("CreatedBy"); + + b.HasIndex("DestWarehouseId"); + + b.HasIndex("DocNo") + .IsUnique(); + + b.HasIndex("SrcWarehouseId"); + + b.HasIndex("Status"); + + b.ToTable("stock_transfers", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.Property("TransferLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TransferLineId")); + + b.Property("BatchId") + .HasColumnType("integer"); + + b.Property("DestBinId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("Qty") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("QtyReceived") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SerialId") + .HasColumnType("integer"); + + b.Property("SrcBinId") + .HasColumnType("integer"); + + b.Property("TransferId") + .HasColumnType("integer"); + + b.Property("UnitCost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("TransferLineId"); + + b.HasIndex("BatchId"); + + b.HasIndex("DestBinId"); + + b.HasIndex("ItemId"); + + b.HasIndex("SerialId"); + + b.HasIndex("SrcBinId"); + + b.HasIndex("TransferId"); + + b.ToTable("stock_transfer_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.Property("SubCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubCategoryId")); + + b.Property("CategoryId") + .HasColumnType("integer"); + + 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("SubCategoryId"); + + b.HasIndex("Status"); + + b.HasIndex("CategoryId", "Name") + .IsUnique(); + + b.ToTable("subcategories", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.Property("SubNavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubNavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NavItemId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.HasKey("SubNavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.ToTable("sub_nav_items", (string)null); + + b.HasData( + new + { + SubNavItemId = 1, + Code = "products.item", + Href = "/dashboard/products", + Label = "Item", + NavItemId = 2, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 2, + Code = "products.category", + Href = "/dashboard/products/categories", + Label = "Category", + NavItemId = 2, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 3, + Code = "products.brand", + Href = "/dashboard/products/brands", + Label = "Brand", + NavItemId = 2, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 4, + Code = "products.item-type", + Href = "/dashboard/products/item-types", + Label = "Item Type", + NavItemId = 2, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 5, + Code = "products.uom", + Href = "/dashboard/products/uoms", + Label = "UOM", + NavItemId = 2, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 6, + Code = "products.configuration", + Href = "/dashboard/products/settings", + Label = "Configuration", + NavItemId = 2, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 7, + Code = "settings.roles", + Href = "/dashboard/settings/roles", + Label = "Roles", + NavItemId = 9, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 8, + Code = "settings.users", + Href = "/dashboard/settings/users", + Label = "Users", + NavItemId = 9, + SortOrder = 2, + Status = "Active" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => + { + b.Property("UomId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UomId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("UomId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("uoms", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.Property("ConversionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ConversionId")); + + b.Property("Factor") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FromUomId") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("ToUomId") + .HasColumnType("integer"); + + b.HasKey("ConversionId"); + + b.HasIndex("FromUomId"); + + b.HasIndex("ToUomId"); + + b.HasIndex("ItemId", "FromUomId", "ToUomId") + .IsUnique(); + + b.ToTable("uom_conversions", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserId")); + + b.Property("AuthUserId") + .HasColumnType("uuid") + .HasColumnName("auth_user_id"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId"); + + b.HasIndex("AuthUserId") + .IsUnique(); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + + b.HasData( + new + { + UserId = 1, + DisplayName = "System", + Status = "Active", + Username = "system" + }); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Vendor", b => + { + b.Property("VendorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("VendorId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("LKR"); + + 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("TaxReg") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Terms") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("VendorId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("Status"); + + b.ToTable("vendors", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Property("QuotationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationId")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RfqId") + .HasColumnType("integer"); + + b.Property("VendorId") + .HasColumnType("integer"); + + b.HasKey("QuotationId"); + + b.HasIndex("VendorId"); + + b.HasIndex("RfqId", "VendorId") + .IsUnique(); + + b.ToTable("vendor_quotations", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.Property("QuotationLineId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("QuotationLineId")); + + b.Property("ItemId") + .HasColumnType("integer"); + + b.Property("LeadDays") + .HasColumnType("integer"); + + b.Property("QuotationId") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.HasKey("QuotationLineId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuotationId"); + + b.ToTable("vendor_quotation_lines", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Property("WarehouseId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("WarehouseId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("WarehouseId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("warehouses", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.AuditLog", b => + { + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Batch", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Bin", b => + { + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany("Bins") + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany() + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.GrnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", "Bin") + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Grn", "Grn") + .WithMany("Lines") + .HasForeignKey("GrnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PoLine", "PoLine") + .WithMany() + .HasForeignKey("PoLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("Bin"); + + b.Navigation("Grn"); + + b.Navigation("Item"); + + b.Navigation("PoLine"); + + b.Navigation("Uom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "BaseUom") + .WithMany() + .HasForeignKey("BaseUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Brand", "Brand") + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "DefaultVendor") + .WithMany() + .HasForeignKey("DefaultVendorId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.SubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("BaseUom"); + + b.Navigation("Brand"); + + b.Navigation("Category"); + + b.Navigation("DefaultVendor"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ItemReorder", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("ReorderSettings") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany() + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem") + .WithMany() + .HasForeignKey("SubNavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("NavItem"); + + b.Navigation("SubNavItem"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseOrder", "PurchaseOrder") + .WithMany("Lines") + .HasForeignKey("PoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "Uom") + .WithMany() + .HasForeignKey("UomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("PurchaseOrder"); + + b.Navigation("Uom"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.ProductConfig", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedBy") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Requisition"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Vendor"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturnLine", b => + { + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.PurchaseReturn", "Return") + .WithMany("Lines") + .HasForeignKey("ReturnId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Return"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Requester") + .WithMany() + .HasForeignKey("RequestedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RequisitionLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany("Lines") + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.HasOne("ERPCore.Domain.Entities.Requisition", "Requisition") + .WithMany() + .HasForeignKey("RequisitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Requisition"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RfqLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Lines") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Rfq"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.HasOne("ERPCore.Domain.Entities.Permission", "Permission") + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.ReasonCode", "ReasonCode") + .WithMany() + .HasForeignKey("ReasonCodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("ReasonCode"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustmentLine", b => + { + b.HasOne("ERPCore.Domain.Entities.StockAdjustment", "Adjustment") + .WithMany("Lines") + .HasForeignKey("AdjustmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Adjustment"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCountLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockCount", "Count") + .WithMany("Lines") + .HasForeignKey("CountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Count"); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLayer", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", "Batch") + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.GrnLine", "GrnLine") + .WithMany() + .HasForeignKey("GrnLineId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", "Serial") + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "Warehouse") + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Batch"); + + b.Navigation("GrnLine"); + + b.Navigation("Item"); + + b.Navigation("Serial"); + + b.Navigation("Warehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockLedger", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("BinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", null) + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", null) + .WithMany() + .HasForeignKey("WarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.HasOne("ERPCore.Domain.Entities.User", "Creator") + .WithMany() + .HasForeignKey("CreatedBy") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "DestWarehouse") + .WithMany() + .HasForeignKey("DestWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Warehouse", "SrcWarehouse") + .WithMany() + .HasForeignKey("SrcWarehouseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + + b.Navigation("DestWarehouse"); + + b.Navigation("SrcWarehouse"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransferLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Batch", null) + .WithMany() + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("DestBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Serial", null) + .WithMany() + .HasForeignKey("SerialId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.Bin", null) + .WithMany() + .HasForeignKey("SrcBinId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ERPCore.Domain.Entities.StockTransfer", "Transfer") + .WithMany("Lines") + .HasForeignKey("TransferId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Transfer"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubCategory", b => + { + b.HasOne("ERPCore.Domain.Entities.Category", "Category") + .WithMany("SubCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany("Children") + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NavItem"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => + { + b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") + .WithMany() + .HasForeignKey("FromUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany("UomConversions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Uom", "ToUom") + .WithMany() + .HasForeignKey("ToUomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUom"); + + b.Navigation("Item"); + + b.Navigation("ToUom"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") + .WithMany("Quotations") + .HasForeignKey("RfqId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Rfq"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotationLine", b => + { + b.HasOne("ERPCore.Domain.Entities.Item", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.VendorQuotation", "Quotation") + .WithMany("Lines") + .HasForeignKey("QuotationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("Quotation"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Category", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Grn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Item", b => + { + b.Navigation("ReorderSettings"); + + b.Navigation("UomConversions"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseReturn", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Requisition", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Rfq", b => + { + b.Navigation("Lines"); + + b.Navigation("Quotations"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockAdjustment", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockCount", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.StockTransfer", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.Warehouse", b => + { + b.Navigation("Bins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.cs b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.cs new file mode 100644 index 0000000..07ace7f --- /dev/null +++ b/Backend/ERPCore/Infra/Persistence/Migrations/20260718092655_AddRolesNavPermissions.cs @@ -0,0 +1,303 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace ERPCore.Infra.Persistence.Migrations +{ + /// + public partial class AddRolesNavPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RoleId", + table: "users", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "nav_items", + columns: table => new + { + NavItemId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Icon = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Href = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active") + }, + constraints: table => + { + table.PrimaryKey("PK_nav_items", x => x.NavItemId); + }); + + migrationBuilder.CreateTable( + name: "roles", + columns: table => new + { + RoleId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + auth_role_id = table.Column(type: "uuid", nullable: false), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + IsSystemRole = table.Column(type: "boolean", nullable: false, defaultValue: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_roles", x => x.RoleId); + }); + + migrationBuilder.CreateTable( + name: "sub_nav_items", + columns: table => new + { + SubNavItemId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + NavItemId = table.Column(type: "integer", nullable: false), + Code = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Label = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Icon = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Href = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Active") + }, + constraints: table => + { + table.PrimaryKey("PK_sub_nav_items", x => x.SubNavItemId); + table.ForeignKey( + name: "FK_sub_nav_items_nav_items_NavItemId", + column: x => x.NavItemId, + principalTable: "nav_items", + principalColumn: "NavItemId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "permissions", + columns: table => new + { + PermissionId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(80)", maxLength: 80, nullable: false), + NavItemId = table.Column(type: "integer", nullable: true), + SubNavItemId = table.Column(type: "integer", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_permissions", x => x.PermissionId); + table.ForeignKey( + name: "FK_permissions_nav_items_NavItemId", + column: x => x.NavItemId, + principalTable: "nav_items", + principalColumn: "NavItemId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_permissions_sub_nav_items_SubNavItemId", + column: x => x.SubNavItemId, + principalTable: "sub_nav_items", + principalColumn: "SubNavItemId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "role_permissions", + columns: table => new + { + RoleId = table.Column(type: "integer", nullable: false), + PermissionId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_role_permissions", x => new { x.RoleId, x.PermissionId }); + table.ForeignKey( + name: "FK_role_permissions_permissions_PermissionId", + column: x => x.PermissionId, + principalTable: "permissions", + principalColumn: "PermissionId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_role_permissions_roles_RoleId", + column: x => x.RoleId, + principalTable: "roles", + principalColumn: "RoleId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "nav_items", + columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" }, + values: new object[,] + { + { 1, "dashboard", "/dashboard", null, "Dashboard", 1 }, + { 2, "products", "/dashboard/products", null, "Products", 2 }, + { 3, "vendors", "/dashboard/vendors", null, "Vendors", 3 }, + { 4, "procurement", "/dashboard/procurement", null, "Procurement", 4 }, + { 5, "receiving", "/dashboard/receiving/grn", null, "Receiving", 5 }, + { 6, "stock", "/dashboard/stock", null, "Stock", 6 }, + { 7, "warehouses", "/dashboard/warehouse", null, "Warehouses", 7 }, + { 8, "orders", "/dashboard/orders", null, "Orders", 8 }, + { 9, "settings", "/dashboard/settings", null, "Settings", 9 }, + { 10, "help", "/dashboard/help", null, "Help", 10 } + }); + + migrationBuilder.UpdateData( + table: "users", + keyColumn: "UserId", + keyValue: 1, + column: "RoleId", + value: null); + + migrationBuilder.InsertData( + table: "permissions", + columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" }, + values: new object[,] + { + { 1, "NAV:dashboard", 1, null }, + { 2, "NAV:products", 2, null }, + { 3, "NAV:vendors", 3, null }, + { 4, "NAV:procurement", 4, null }, + { 5, "NAV:receiving", 5, null }, + { 6, "NAV:stock", 6, null }, + { 7, "NAV:warehouses", 7, null }, + { 8, "NAV:orders", 8, null }, + { 9, "NAV:settings", 9, null }, + { 10, "NAV:help", 10, null } + }); + + migrationBuilder.InsertData( + table: "sub_nav_items", + columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" }, + values: new object[,] + { + { 1, "products.item", "/dashboard/products", null, "Item", 2, 1 }, + { 2, "products.category", "/dashboard/products/categories", null, "Category", 2, 2 }, + { 3, "products.brand", "/dashboard/products/brands", null, "Brand", 2, 3 }, + { 4, "products.item-type", "/dashboard/products/item-types", null, "Item Type", 2, 4 }, + { 5, "products.uom", "/dashboard/products/uoms", null, "UOM", 2, 5 }, + { 6, "products.configuration", "/dashboard/products/settings", null, "Configuration", 2, 6 }, + { 7, "settings.roles", "/dashboard/settings/roles", null, "Roles", 9, 1 }, + { 8, "settings.users", "/dashboard/settings/users", null, "Users", 9, 2 } + }); + + migrationBuilder.InsertData( + table: "permissions", + columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" }, + values: new object[,] + { + { 11, "NAV:products.item", null, 1 }, + { 12, "NAV:products.category", null, 2 }, + { 13, "NAV:products.brand", null, 3 }, + { 14, "NAV:products.item-type", null, 4 }, + { 15, "NAV:products.uom", null, 5 }, + { 16, "NAV:products.configuration", null, 6 }, + { 17, "NAV:settings.roles", null, 7 }, + { 18, "NAV:settings.users", null, 8 } + }); + + migrationBuilder.CreateIndex( + name: "IX_users_RoleId", + table: "users", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_nav_items_Code", + table: "nav_items", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_permissions_Code", + table: "permissions", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_permissions_NavItemId", + table: "permissions", + column: "NavItemId"); + + migrationBuilder.CreateIndex( + name: "IX_permissions_SubNavItemId", + table: "permissions", + column: "SubNavItemId"); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_PermissionId", + table: "role_permissions", + column: "PermissionId"); + + migrationBuilder.CreateIndex( + name: "IX_roles_auth_role_id", + table: "roles", + column: "auth_role_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_roles_Code", + table: "roles", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sub_nav_items_Code", + table: "sub_nav_items", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_sub_nav_items_NavItemId", + table: "sub_nav_items", + column: "NavItemId"); + + migrationBuilder.AddForeignKey( + name: "FK_users_roles_RoleId", + table: "users", + column: "RoleId", + principalTable: "roles", + principalColumn: "RoleId", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_users_roles_RoleId", + table: "users"); + + migrationBuilder.DropTable( + name: "role_permissions"); + + migrationBuilder.DropTable( + name: "permissions"); + + migrationBuilder.DropTable( + name: "roles"); + + migrationBuilder.DropTable( + name: "sub_nav_items"); + + migrationBuilder.DropTable( + name: "nav_items"); + + migrationBuilder.DropIndex( + name: "IX_users_RoleId", + table: "users"); + + migrationBuilder.DropColumn( + name: "RoleId", + table: "users"); + } + } +} diff --git a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs index 88ab528..56e9e02 100644 --- a/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs +++ b/Backend/ERPCore/Infra/Persistence/Migrations/ErpDbContextModelSnapshot.cs @@ -524,6 +524,142 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("journal_entry_stubs", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Property("NavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.HasKey("NavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("nav_items", (string)null); + + b.HasData( + new + { + NavItemId = 1, + Code = "dashboard", + Href = "/dashboard", + Label = "Dashboard", + SortOrder = 1, + Status = "Active" + }, + new + { + NavItemId = 2, + Code = "products", + Href = "/dashboard/products", + Label = "Products", + SortOrder = 2, + Status = "Active" + }, + new + { + NavItemId = 3, + Code = "vendors", + Href = "/dashboard/vendors", + Label = "Vendors", + SortOrder = 3, + Status = "Active" + }, + new + { + NavItemId = 4, + Code = "procurement", + Href = "/dashboard/procurement", + Label = "Procurement", + SortOrder = 4, + Status = "Active" + }, + new + { + NavItemId = 5, + Code = "receiving", + Href = "/dashboard/receiving/grn", + Label = "Receiving", + SortOrder = 5, + Status = "Active" + }, + new + { + NavItemId = 6, + Code = "stock", + Href = "/dashboard/stock", + Label = "Stock", + SortOrder = 6, + Status = "Active" + }, + new + { + NavItemId = 7, + Code = "warehouses", + Href = "/dashboard/warehouse", + Label = "Warehouses", + SortOrder = 7, + Status = "Active" + }, + new + { + NavItemId = 8, + Code = "orders", + Href = "/dashboard/orders", + Label = "Orders", + SortOrder = 8, + Status = "Active" + }, + new + { + NavItemId = 9, + Code = "settings", + Href = "/dashboard/settings", + Label = "Settings", + SortOrder = 9, + Status = "Active" + }, + new + { + NavItemId = 10, + Code = "help", + Href = "/dashboard/help", + Label = "Help", + SortOrder = 10, + Status = "Active" + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.NumberSequence", b => { b.Property("SequenceId") @@ -554,6 +690,147 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("number_sequences", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.Property("PermissionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PermissionId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("NavItemId") + .HasColumnType("integer"); + + b.Property("SubNavItemId") + .HasColumnType("integer"); + + b.HasKey("PermissionId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.HasIndex("SubNavItemId"); + + b.ToTable("permissions", (string)null); + + b.HasData( + new + { + PermissionId = 1, + Code = "NAV:dashboard", + NavItemId = 1 + }, + new + { + PermissionId = 2, + Code = "NAV:products", + NavItemId = 2 + }, + new + { + PermissionId = 3, + Code = "NAV:vendors", + NavItemId = 3 + }, + new + { + PermissionId = 4, + Code = "NAV:procurement", + NavItemId = 4 + }, + new + { + PermissionId = 5, + Code = "NAV:receiving", + NavItemId = 5 + }, + new + { + PermissionId = 6, + Code = "NAV:stock", + NavItemId = 6 + }, + new + { + PermissionId = 7, + Code = "NAV:warehouses", + NavItemId = 7 + }, + new + { + PermissionId = 8, + Code = "NAV:orders", + NavItemId = 8 + }, + new + { + PermissionId = 9, + Code = "NAV:settings", + NavItemId = 9 + }, + new + { + PermissionId = 10, + Code = "NAV:help", + NavItemId = 10 + }, + new + { + PermissionId = 11, + Code = "NAV:products.item", + SubNavItemId = 1 + }, + new + { + PermissionId = 12, + Code = "NAV:products.category", + SubNavItemId = 2 + }, + new + { + PermissionId = 13, + Code = "NAV:products.brand", + SubNavItemId = 3 + }, + new + { + PermissionId = 14, + Code = "NAV:products.item-type", + SubNavItemId = 4 + }, + new + { + PermissionId = 15, + Code = "NAV:products.uom", + SubNavItemId = 5 + }, + new + { + PermissionId = 16, + Code = "NAV:products.configuration", + SubNavItemId = 6 + }, + new + { + PermissionId = 17, + Code = "NAV:settings.roles", + SubNavItemId = 7 + }, + new + { + PermissionId = 18, + Code = "NAV:settings.users", + SubNavItemId = 8 + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => { b.Property("PoLineId") @@ -942,6 +1219,78 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("rfq_lines", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Role", b => + { + b.Property("RoleId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RoleId")); + + b.Property("AuthRoleId") + .HasColumnType("uuid") + .HasColumnName("auth_role_id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystemRole") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("RoleId"); + + b.HasIndex("AuthRoleId") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("PermissionId") + .HasColumnType("integer"); + + b.HasKey("RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("role_permissions", (string)null); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { b.Property("SerialId") @@ -1439,6 +1788,137 @@ namespace ERPCore.Infra.Persistence.Migrations b.ToTable("subcategories", (string)null); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.Property("SubNavItemId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SubNavItemId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Href") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Icon") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NavItemId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Active"); + + b.HasKey("SubNavItemId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("NavItemId"); + + b.ToTable("sub_nav_items", (string)null); + + b.HasData( + new + { + SubNavItemId = 1, + Code = "products.item", + Href = "/dashboard/products", + Label = "Item", + NavItemId = 2, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 2, + Code = "products.category", + Href = "/dashboard/products/categories", + Label = "Category", + NavItemId = 2, + SortOrder = 2, + Status = "Active" + }, + new + { + SubNavItemId = 3, + Code = "products.brand", + Href = "/dashboard/products/brands", + Label = "Brand", + NavItemId = 2, + SortOrder = 3, + Status = "Active" + }, + new + { + SubNavItemId = 4, + Code = "products.item-type", + Href = "/dashboard/products/item-types", + Label = "Item Type", + NavItemId = 2, + SortOrder = 4, + Status = "Active" + }, + new + { + SubNavItemId = 5, + Code = "products.uom", + Href = "/dashboard/products/uoms", + Label = "UOM", + NavItemId = 2, + SortOrder = 5, + Status = "Active" + }, + new + { + SubNavItemId = 6, + Code = "products.configuration", + Href = "/dashboard/products/settings", + Label = "Configuration", + NavItemId = 2, + SortOrder = 6, + Status = "Active" + }, + new + { + SubNavItemId = 7, + Code = "settings.roles", + Href = "/dashboard/settings/roles", + Label = "Roles", + NavItemId = 9, + SortOrder = 1, + Status = "Active" + }, + new + { + SubNavItemId = 8, + Code = "settings.users", + Href = "/dashboard/settings/users", + Label = "Users", + NavItemId = 9, + SortOrder = 2, + Status = "Active" + }); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Uom", b => { b.Property("UomId") @@ -1510,6 +1990,9 @@ namespace ERPCore.Infra.Persistence.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("RoleId") + .HasColumnType("integer"); + b.Property("Status") .IsRequired() .HasMaxLength(20) @@ -1525,6 +2008,8 @@ namespace ERPCore.Infra.Persistence.Migrations b.HasIndex("AuthUserId") .IsUnique(); + b.HasIndex("RoleId"); + b.HasIndex("Username") .IsUnique(); @@ -1857,6 +2342,23 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Warehouse"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.Permission", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany() + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("ERPCore.Domain.Entities.SubNavItem", "SubNavItem") + .WithMany() + .HasForeignKey("SubNavItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("NavItem"); + + b.Navigation("SubNavItem"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PoLine", b => { b.HasOne("ERPCore.Domain.Entities.Item", "Item") @@ -2049,6 +2551,25 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Rfq"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.RolePermission", b => + { + b.HasOne("ERPCore.Domain.Entities.Permission", "Permission") + .WithMany() + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.Serial", b => { b.HasOne("ERPCore.Domain.Entities.Item", "Item") @@ -2317,6 +2838,17 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("Category"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.SubNavItem", b => + { + b.HasOne("ERPCore.Domain.Entities.NavItem", "NavItem") + .WithMany("Children") + .HasForeignKey("NavItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NavItem"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.UomConversion", b => { b.HasOne("ERPCore.Domain.Entities.Uom", "FromUom") @@ -2344,6 +2876,16 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("ToUom"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.User", b => + { + b.HasOne("ERPCore.Domain.Entities.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Role"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.VendorQuotation", b => { b.HasOne("ERPCore.Domain.Entities.Rfq", "Rfq") @@ -2399,6 +2941,11 @@ namespace ERPCore.Infra.Persistence.Migrations b.Navigation("UomConversions"); }); + modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b => + { + b.Navigation("Children"); + }); + modelBuilder.Entity("ERPCore.Domain.Entities.PurchaseOrder", b => { b.Navigation("Lines"); diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index d06df6e..5fcf63c 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -68,6 +68,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// RBAC / sidebar: Role (shadow of AuthHex) + Permission assignment + user management +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Cross-cutting + procurement services (docs/11 §3) builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/ERPCore/Services/Interfaces/IRoleService.cs b/Backend/ERPCore/Services/Interfaces/IRoleService.cs new file mode 100644 index 0000000..c561850 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IRoleService.cs @@ -0,0 +1,28 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Rbac; + +namespace ERPCore.Services.Interfaces; + +/// +/// Role CRUD + permission assignment. AuthHex is the source of truth for Role +/// identity (docs/10 C.9 "shadow user" pattern, applied to Role): every write is +/// forwarded to AuthHex first, then mirrored into the local shadow Role row. +/// Permission assignment is purely local (ERPCore/UI concern, not an AuthHex one). +/// +public interface IRoleService +{ + Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default); + Task?> GetAsync(int roleId, CancellationToken ct = default); + Task> CreateAsync(CreateRoleRequest request, CancellationToken ct = default); + Task> UpdateAsync(int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default); + Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int roleId, CancellationToken ct = default); + + Task GetPermissionsAsync(int roleId, CancellationToken ct = default); + Task AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default); + + /// Resolves the nav codes a role (by AuthHex `RoleCode` claim) may see. Used by `GET /auth/me`. + Task GetMeAsync(string? roleCode, CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs b/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs new file mode 100644 index 0000000..56ea564 --- /dev/null +++ b/Backend/ERPCore/Services/Interfaces/IUserManagementService.cs @@ -0,0 +1,20 @@ +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Users; + +namespace ERPCore.Services.Interfaces; + +/// +/// Admin-facing user management: list/create/reassign-role against the local +/// shadow `User` table, orchestrating account creation in AuthHex too (see +/// ). +/// +public interface IUserManagementService +{ + Task> ListAsync(PageQuery query, CancellationToken ct = default); + Task GetAsync(int userId, CancellationToken ct = default); + Task CreateAsync(CreateUserRequest request, CancellationToken ct = default); + Task UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default); + + /// AuthHex UserType options for the create-user form's select. + Task> ListUserTypesAsync(CancellationToken ct = default); +} diff --git a/Backend/ERPCore/Services/RoleService.cs b/Backend/ERPCore/Services/RoleService.cs new file mode 100644 index 0000000..d9a9acd --- /dev/null +++ b/Backend/ERPCore/Services/RoleService.cs @@ -0,0 +1,214 @@ +using ERPCore.Common.Http; +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Auth; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Rbac; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class RoleService : IRoleService +{ + private readonly IRepository _roles; + private readonly IRepository _rolePermissions; + private readonly IRepository _permissions; + private readonly IAuthHexClient _authHex; + private readonly IUnitOfWork _uow; + + public RoleService( + IRepository roles, IRepository rolePermissions, IRepository permissions, + IAuthHexClient authHex, IUnitOfWork uow) + { + _roles = roles; + _rolePermissions = rolePermissions; + _permissions = permissions; + _authHex = authHex; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, EntityStatus? status, CancellationToken ct = default) + { + var q = _roles.Query().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(r => EF.Functions.ILike(r.Code, $"%{term}%") || EF.Functions.ILike(r.Name, $"%{term}%")); + } + if (status is not null) q = q.Where(r => r.Status == status); + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(r => r.Code) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); + } + + public async Task?> GetAsync(int roleId, CancellationToken ct = default) + { + var role = await _roles.Query().AsNoTracking().FirstOrDefaultAsync(r => r.RoleId == roleId, ct); + return role is null ? null : new ETagged(Map(role), role.RowVersion); + } + + public async Task> CreateAsync(CreateRoleRequest request, CancellationToken ct = default) + { + var code = request.Code.Trim(); + if (await _roles.Query().AnyAsync(r => r.Code == code, ct)) + throw new ConflictException($"A role with code '{code}' already exists."); + + var authRole = await _authHex.CreateRoleAsync( + new CreateAuthHexRoleRequest { Code = code, Name = request.Name.Trim() }, ct); + + var role = new Role + { + AuthRoleId = authRole.RoleId, + Code = code, + Name = request.Name.Trim(), + IsSystemRole = authRole.IsSystemRole ?? false, + Status = EntityStatus.Active, + CreatedAt = DateTime.UtcNow + }; + + await _roles.AddAsync(role, ct); + await _uow.SaveChangesAsync(ct); + + return new ETagged(Map(role), role.RowVersion); + } + + public async Task> UpdateAsync( + int roleId, UpdateRoleRequest request, uint expectedRowVersion, CancellationToken ct = default) + { + var role = await _roles.GetByIdAsync(roleId, ct) + ?? throw new NotFoundException($"Role {roleId} was not found."); + + if (role.RowVersion != expectedRowVersion) + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412); + + var code = request.Code.Trim(); + if (!string.Equals(role.Code, code, StringComparison.Ordinal) + && await _roles.Query().AnyAsync(r => r.Code == code && r.RoleId != roleId, ct)) + throw new ConflictException($"A role with code '{code}' already exists."); + + await _authHex.UpdateRoleAsync( + new UpdateAuthHexRoleRequest { RoleId = role.AuthRoleId, Code = code, Name = request.Name.Trim() }, ct); + + role.Code = code; + role.Name = request.Name.Trim(); + role.UpdatedAt = DateTime.UtcNow; + + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + throw new DomainException(ErrorCodes.ConcurrencyConflict, "The role was modified by another request.", 412); + } + + return new ETagged(Map(role), role.RowVersion); + } + + public async Task SetStatusAsync(int roleId, EntityStatus status, CancellationToken ct = default) + { + var role = await _roles.GetByIdAsync(roleId, ct) + ?? throw new NotFoundException($"Role {roleId} was not found."); + + role.Status = status; + role.UpdatedAt = DateTime.UtcNow; + await _uow.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(int roleId, CancellationToken ct = default) + { + var role = await _roles.GetByIdAsync(roleId, ct) + ?? throw new NotFoundException($"Role {roleId} was not found."); + + try + { + await _authHex.DeleteRoleAsync(role.AuthRoleId, ct); + } + catch (DomainException ex) when (ex.Message.Contains("ROLE_IN_USE", StringComparison.OrdinalIgnoreCase)) + { + throw new DomainException(ErrorCodes.RoleInUse, "This role is assigned to one or more users.", 409); + } + + _roles.Remove(role); + await _uow.SaveChangesAsync(ct); + } + + public async Task GetPermissionsAsync(int roleId, CancellationToken ct = default) + { + _ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); + + var granted = await _rolePermissions.Query().AsNoTracking() + .Where(rp => rp.RoleId == roleId) + .Include(rp => rp.Permission) + .Select(rp => rp.Permission!) + .ToListAsync(ct); + + return new RolePermissionsDto( + roleId, + granted.Where(p => p.NavItemId is not null).Select(p => p.NavItemId!.Value).ToList(), + granted.Where(p => p.SubNavItemId is not null).Select(p => p.SubNavItemId!.Value).ToList()); + } + + public async Task AssignPermissionsAsync(int roleId, AssignRolePermissionsRequest request, CancellationToken ct = default) + { + _ = await _roles.GetByIdAsync(roleId, ct) ?? throw new NotFoundException($"Role {roleId} was not found."); + + var existing = await _rolePermissions.Query().Where(rp => rp.RoleId == roleId).ToListAsync(ct); + foreach (var rp in existing) _rolePermissions.Remove(rp); + + var navIds = request.NavItemIds.Distinct().ToList(); + var subNavIds = request.SubNavItemIds.Distinct().ToList(); + + var permissionIds = await _permissions.Query().AsNoTracking() + .Where(p => (p.NavItemId != null && navIds.Contains(p.NavItemId.Value)) + || (p.SubNavItemId != null && subNavIds.Contains(p.SubNavItemId.Value))) + .Select(p => p.PermissionId) + .ToListAsync(ct); + + foreach (var permissionId in permissionIds) + await _rolePermissions.AddAsync(new RolePermission { RoleId = roleId, PermissionId = permissionId }, ct); + + await _uow.SaveChangesAsync(ct); + + return await GetPermissionsAsync(roleId, ct); + } + + public async Task GetMeAsync(string? roleCode, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(roleCode)) + return new MeResponseDto(null, null, Array.Empty()); + + var role = await _roles.Query().AsNoTracking() + .FirstOrDefaultAsync(r => r.Code == roleCode, ct); + if (role is null) + return new MeResponseDto(roleCode, null, Array.Empty()); + + var permissions = await _rolePermissions.Query().AsNoTracking() + .Where(rp => rp.RoleId == role.RoleId) + .Include(rp => rp.Permission!).ThenInclude(p => p.NavItem) + .Include(rp => rp.Permission!).ThenInclude(p => p.SubNavItem) + .Select(rp => rp.Permission!) + .ToListAsync(ct); + + var navCodes = permissions + .Select(p => p.NavItem?.Code ?? p.SubNavItem?.Code) + .Where(code => code is not null) + .Select(code => code!) + .Distinct() + .ToList(); + + return new MeResponseDto(role.Code, role.Name, navCodes); + } + + private static RoleDto Map(Role r) => new( + r.RoleId, r.Code, r.Name, r.IsSystemRole, r.Status, r.CreatedAt, r.UpdatedAt); +} diff --git a/Backend/ERPCore/Services/UserManagementService.cs b/Backend/ERPCore/Services/UserManagementService.cs new file mode 100644 index 0000000..3471270 --- /dev/null +++ b/Backend/ERPCore/Services/UserManagementService.cs @@ -0,0 +1,124 @@ +using ERPCore.Domain.Entities; +using ERPCore.Domain.Enums; +using ERPCore.Dtos.Auth; +using ERPCore.Dtos.Common; +using ERPCore.Dtos.Users; +using ERPCore.Infra.Auth.AuthHex; +using ERPCore.Infra.UoW; +using ERPCore.Repositories.Interfaces; +using ERPCore.Services.Interfaces; +using ERPCore.System.Errors; +using Microsoft.EntityFrameworkCore; + +namespace ERPCore.Services; + +public sealed class UserManagementService : IUserManagementService +{ + private readonly IRepository _users; + private readonly IRepository _roles; + private readonly IAuthUserService _authUsers; + private readonly IAuthHexClient _authHex; + private readonly IUnitOfWork _uow; + + public UserManagementService( + IRepository users, IRepository roles, IAuthUserService authUsers, IAuthHexClient authHex, IUnitOfWork uow) + { + _users = users; + _roles = roles; + _authUsers = authUsers; + _authHex = authHex; + _uow = uow; + } + + public async Task> ListAsync(PageQuery query, CancellationToken ct = default) + { + IQueryable q = _users.Query().AsNoTracking().Include(u => u.Role); + if (!string.IsNullOrWhiteSpace(query.Q)) + { + var term = query.Q.Trim(); + q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%")); + } + + var total = await q.CountAsync(ct); + var rows = await q.OrderBy(u => u.Username) + .Skip(query.Skip).Take(query.PageSize) + .ToListAsync(ct); + + return PagedResponse.Create(rows.Select(Map).ToList(), query.Page, query.PageSize, total); + } + + public async Task GetAsync(int userId, CancellationToken ct = default) + { + var user = await _users.Query().AsNoTracking().Include(u => u.Role) + .FirstOrDefaultAsync(u => u.UserId == userId, ct); + return user is null ? null : Map(user); + } + + public async Task CreateAsync(CreateUserRequest request, CancellationToken ct = default) + { + var role = await _roles.GetByIdAsync(request.RoleId, ct) + ?? throw new NotFoundException($"Role {request.RoleId} was not found."); + + var username = request.Username.Trim(); + if (await _users.Query().AnyAsync(u => u.Username == username, ct)) + throw new ConflictException($"A user with username '{username}' already exists."); + + var authUserId = Guid.NewGuid(); + + // Source of truth: AuthHex creates the credential + emails it (registerUser, + // ERP_Auth_Service/Services/UserManager/UserManagerService.cs). + await _authUsers.RegisterAsync(new RegisterRequest + { + UserId = authUserId, + RoleId = role.AuthRoleId, + UserTypeId = request.UserTypeId, + Fullname = request.FullName.Trim(), + UserName = username, + Nic = request.Nic, + Email = request.Email.Trim(), + MobileNumber = request.MobileNumber, + Password = request.Password, + ChkUser = true + }, ct); + + // Mirror into the local shadow User row immediately, rather than waiting + // for ShadowUserClaimsTransformation's next-login JIT provisioning. + var user = new User + { + AuthUserId = authUserId, + Username = username, + DisplayName = request.FullName.Trim(), + RoleId = role.RoleId, + Status = EntityStatus.Active + }; + + await _users.AddAsync(user, ct); + await _uow.SaveChangesAsync(ct); + + user.Role = role; + return Map(user); + } + + public async Task UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default) + { + var user = await _users.GetByIdAsync(userId, ct) + ?? throw new NotFoundException($"User {userId} was not found."); + var role = await _roles.GetByIdAsync(request.RoleId, ct) + ?? throw new NotFoundException($"Role {request.RoleId} was not found."); + + user.RoleId = role.RoleId; + await _uow.SaveChangesAsync(ct); + + user.Role = role; + return Map(user); + } + + public async Task> ListUserTypesAsync(CancellationToken ct = default) + { + var userTypes = await _authHex.ListUserTypesAsync(ct); + return userTypes.Select(t => new UserTypeOptionDto(t.UserTypeId, t.Code, t.Description)).ToList(); + } + + private static ManagedUserDto Map(User u) => new( + u.UserId, u.Username, u.DisplayName, u.Status, u.RoleId, u.Role?.Code, u.Role?.Name); +} diff --git a/Backend/ERPCore/System/Errors/ErrorCodes.cs b/Backend/ERPCore/System/Errors/ErrorCodes.cs index 1316272..fdba3e0 100644 --- a/Backend/ERPCore/System/Errors/ErrorCodes.cs +++ b/Backend/ERPCore/System/Errors/ErrorCodes.cs @@ -25,6 +25,7 @@ public static class ErrorCodes public const string ConcurrencyConflict = "CONCURRENCY_CONFLICT"; public const string IdempotencyReplay = "IDEMPOTENCY_REPLAY"; public const string ConfigDisabled = "CONFIG_DISABLED"; + public const string RoleInUse = "ROLE_IN_USE"; // Auth proxy (AuthController → AuthHex, docs/11 §2.0) public const string AuthUpstreamError = "AUTH_UPSTREAM_ERROR"; diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index 8645532..7735c12 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=CHANGE_ME;Password=CHANGE_ME" + "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root" }, "Auth": { "Issuer": "AuthHex", @@ -16,7 +16,7 @@ "RequiredRoleCode": "" }, "AuthHex": { - "BaseUrl": "CHANGE_ME" + "BaseUrl": "http://localhost:5011" }, "AllowedHosts": "*" } diff --git a/Frontend/erp-system/app/dashboard/layout.tsx b/Frontend/erp-system/app/dashboard/layout.tsx index 49ec7f9..55e4289 100644 --- a/Frontend/erp-system/app/dashboard/layout.tsx +++ b/Frontend/erp-system/app/dashboard/layout.tsx @@ -2,6 +2,7 @@ import { AppSidebar } from "@/components/Layouts/AppSidebar" import { Header } from "@/components/Layouts/Header" import { Breadcrumbs } from "@/components/Layouts/Breadcrumbs" import { Toaster } from "@/components/ui/toast" +import { AuthProvider } from "@/components/auth/AuthProvider" export default function DashboardLayout({ children, @@ -9,22 +10,24 @@ export default function DashboardLayout({ children: React.ReactNode }) { return ( -
- -
-
-
-
- -
-
- {children} + +
+ +
+
+
+
+ +
+
+ {children} +
-
-
- -
+ + + + ) } diff --git a/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx new file mode 100644 index 0000000..4a439ce --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/roles/[id]/page.tsx @@ -0,0 +1,222 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { AlertTriangle, ArrowLeft, Save } from "lucide-react" + +import { navApi } from "@/lib/api/nav" +import { rolesApi } from "@/lib/api/roles" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { NavItem, Role } from "@/types/rbac" + +import { RolePermissionTree } from "@/components/auth/RolePermissionTree" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { FieldError } from "@/components/ui/field" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +export default function RoleDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const roleId = Number(params.id) + + const [role, setRole] = useState(null) + const [etag, setEtag] = useState(null) + const [tree, setTree] = useState(null) + const [loadError, setLoadError] = useState(null) + + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [navItemIds, setNavItemIds] = useState>(new Set()) + const [subNavItemIds, setSubNavItemIds] = useState>(new Set()) + + const [errors, setErrors] = useState>({}) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + const [savingPermissions, setSavingPermissions] = useState(false) + + function load() { + setLoadError(null) + Promise.all([rolesApi.get(roleId), navApi.tree(), rolesApi.getPermissions(roleId)]) + .then(([roleResult, navTree, permissions]) => { + setRole(roleResult.data) + setEtag(roleResult.etag) + setCode(roleResult.data.code) + setName(roleResult.data.name) + setTree(navTree) + setNavItemIds(new Set(permissions.navItemIds)) + setSubNavItemIds(new Set(permissions.subNavItemIds)) + setConflict(false) + }) + .catch((err) => setLoadError(errorMessage(err))) + } + + useEffect(() => { + if (Number.isFinite(roleId)) load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roleId]) + + async function handleSave() { + setSaveError(null) + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Role code is required" + if (!name.trim()) nextErrors.name = "Role name is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0 || !etag) return + + setSaving(true) + try { + const result = await rolesApi.update(roleId, { code: code.trim().toUpperCase(), name: name.trim() }, etag) + setRole(result.data) + setEtag(result.etag) + toast.success("Role saved", `${result.data.code} — ${result.data.name}`) + } catch (err) { + const errCode = (err as { code?: string })?.code + if (errCode === "CONCURRENCY_CONFLICT") { + setConflict(true) + setSaveError(errorMessage(err)) + setSaving(false) + return + } + const fe = fieldErrors(err) + if (fe?.code) setErrors({ code: fe.code }) + setSaveError(errorMessage(err)) + toast.error("Could not save role", errorMessage(err)) + } finally { + setSaving(false) + } + } + + async function handleSavePermissions() { + setSavingPermissions(true) + try { + await rolesApi.assignPermissions(roleId, { + navItemIds: Array.from(navItemIds), + subNavItemIds: Array.from(subNavItemIds), + }) + toast.success("Permissions saved", "This role's visible sidebar sections have been updated.") + } catch (err) { + toast.error("Could not save permissions", errorMessage(err)) + } finally { + setSavingPermissions(false) + } + } + + if (loadError && !role) { + return ( +
+
{loadError}
+ + Back to roles + +
+ ) + } + + if (!role || !tree) { + return ( +
+ + +
+ ) + } + + return ( +
+
+
+ + + +
+
+

{role.code}

+ + {role.status} + +
+

{role.name}

+
+
+
+ + {conflict && ( +
+ +
+

{saveError ?? "This role was changed by someone else."} Reload before retrying.

+ +
+
+ )} + + {saveError && !conflict && ( +
{saveError}
+ )} + +
+

Details

+
+
+ + +
+
+ + setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} /> + +
+
+
+ +
+
+ +
+
+

Sidebar permissions

+

Choose which sections a user with this role can see.

+
+ { + setNavItemIds(nav) + setSubNavItemIds(sub) + }} + /> +
+ +
+
+ +
+ +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/settings/roles/page.tsx b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx new file mode 100644 index 0000000..3674e64 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/roles/page.tsx @@ -0,0 +1,309 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Pencil, Plus, ShieldCheck, Trash2 } from "lucide-react" + +import { navApi } from "@/lib/api/nav" +import { rolesApi } from "@/lib/api/roles" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { NavItem, Role } from "@/types/rbac" + +import { RolePermissionTree } from "@/components/auth/RolePermissionTree" +import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog" +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +/** Code is derived from Name, never typed directly (e.g. "Store Manager" -> "STORE_MANAGER"). */ +function deriveCode(name: string): string { + return name + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") +} + +export default function RolesPage() { + const [roles, setRoles] = useState(null) + const [pagination, setPagination] = useState(null) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [tree, setTree] = useState(null) + + const [open, setOpen] = useState(false) + const [name, setName] = useState("") + const [navItemIds, setNavItemIds] = useState>(new Set()) + const [subNavItemIds, setSubNavItemIds] = useState>(new Set()) + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + const [actionPendingId, setActionPendingId] = useState(null) + + function load() { + rolesApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { + setRoles(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page]) + + useEffect(() => { + navApi.tree().then(setTree).catch(() => setTree([])) + }, []) + + function resetForm() { + setName("") + setNavItemIds(new Set()) + setSubNavItemIds(new Set()) + setErrors({}) + } + + const code = deriveCode(name) + + async function handleCreate() { + const nextErrors: Record = {} + if (!name.trim()) nextErrors.name = "Role name is required" + else if (!code) nextErrors.name = "Role name must contain at least one letter or number" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const result = await rolesApi.create({ code, name: name.trim() }) + if (navItemIds.size > 0 || subNavItemIds.size > 0) { + await rolesApi.assignPermissions(result.data.roleId, { + navItemIds: Array.from(navItemIds), + subNavItemIds: Array.from(subNavItemIds), + }) + } + toast.success("Role created", `${result.data.code} — ${result.data.name}`) + setOpen(false) + resetForm() + load() + } catch (err) { + toast.error("Could not create role", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + async function handleDelete(role: Role) { + setActionPendingId(role.roleId) + try { + await rolesApi.remove(role.roleId) + toast.success("Role deleted", `${role.code} has been removed.`) + load() + } catch (err) { + toast.error("Could not delete role", errorMessage(err)) + } finally { + setActionPendingId(null) + } + } + + return ( +
+
+
+

Roles

+

+ Manage roles and which sidebar sections each one can see. +

+
+ + { + setOpen(v) + if (!v) resetForm() + }} + > + + + New Role + + } + /> + + + New role + Created in both the auth service and here. + + + + Name + setName(e.target.value)} placeholder="Manager" aria-invalid={!!errors.name} /> + + + + Code (auto-generated) + + + + +
+ Sidebar permissions + {tree === null ? ( + + ) : ( +
+ { + setNavItemIds(nav) + setSubNavItemIds(sub) + }} + /> +
+ )} +
+ +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && roles === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && roles !== null && roles.length === 0 && ( +
+ +

No roles yet.

+
+ )} + + {!error && roles !== null && roles.length > 0 && ( + + + + Code + Name + Status + Actions + + + + {roles.map((r) => ( + + {r.code} + {r.name} + + + {r.status} + + + +
+ + + + + + + } + > + + + handleDelete(r)} + /> + +
+
+
+ ))} +
+
+ )} + + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx new file mode 100644 index 0000000..6268997 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/users/[id]/page.tsx @@ -0,0 +1,133 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import Link from "next/link" +import { ArrowLeft, Save } from "lucide-react" + +import { rolesApi } from "@/lib/api/roles" +import { usersApi } from "@/lib/api/users" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { Role } from "@/types/rbac" +import { ManagedUser } from "@/types/users" + +import { Badge } from "@/components/ui/badge" +import { Button, buttonVariants } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +export default function UserDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const userId = Number(params.id) + + const [user, setUser] = useState(null) + const [roles, setRoles] = useState([]) + const [loadError, setLoadError] = useState(null) + const [roleId, setRoleId] = useState("") + const [saving, setSaving] = useState(false) + + function load() { + setLoadError(null) + Promise.all([usersApi.get(userId), rolesApi.list({ pageSize: 200, status: "Active" })]) + .then(([u, roleList]) => { + setUser(u) + setRoles(roleList.items) + setRoleId(u.roleId ? String(u.roleId) : "") + }) + .catch((err) => setLoadError(errorMessage(err))) + } + + useEffect(() => { + if (Number.isFinite(userId)) load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]) + + async function handleSave() { + if (!roleId) return + setSaving(true) + try { + const result = await usersApi.updateRole(userId, { roleId: Number(roleId) }) + setUser(result) + toast.success("Role updated", `${result.username} is now assigned to ${result.roleName}.`) + } catch (err) { + toast.error("Could not update role", errorMessage(err)) + } finally { + setSaving(false) + } + } + + if (loadError && !user) { + return ( +
+
{loadError}
+ + Back to users + +
+ ) + } + + if (!user) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ + + +
+
+

{user.username}

+ + {user.status} + +
+

{user.displayName}

+
+
+ +
+ + +
+ +
+ + +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/settings/users/page.tsx b/Frontend/erp-system/app/dashboard/settings/users/page.tsx new file mode 100644 index 0000000..7f31957 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/settings/users/page.tsx @@ -0,0 +1,309 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Pencil, Plus, Users as UsersIcon } from "lucide-react" + +import { rolesApi } from "@/lib/api/roles" +import { usersApi } from "@/lib/api/users" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" +import { Role } from "@/types/rbac" +import { ManagedUser, UserTypeOption } from "@/types/users" + +import { Button, buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { toast } from "@/components/ui/toast" + +const PAGE_SIZE = 10 + +export default function UsersPage() { + const [users, setUsers] = useState(null) + const [pagination, setPagination] = useState(null) + const [roles, setRoles] = useState([]) + const [userTypes, setUserTypes] = useState([]) + const [error, setError] = useState(null) + const [page, setPage] = useState(1) + + const [open, setOpen] = useState(false) + const [username, setUsername] = useState("") + const [fullName, setFullName] = useState("") + const [email, setEmail] = useState("") + const [mobileNumber, setMobileNumber] = useState("") + const [nic, setNic] = useState("") + const [roleId, setRoleId] = useState("") + const [userTypeId, setUserTypeId] = useState("") + const [errors, setErrors] = useState>({}) + const [submitting, setSubmitting] = useState(false) + + function load() { + usersApi + .list({ page, pageSize: PAGE_SIZE }) + .then((res) => { + setUsers(res.items) + setPagination(res.pagination) + }) + .catch((err) => setError(errorMessage(err))) + } + + useEffect(load, [page]) + + useEffect(() => { + rolesApi.list({ pageSize: 200, status: "Active" }).then((res) => setRoles(res.items)).catch(() => setRoles([])) + }, []) + + useEffect(() => { + usersApi + .userTypes() + .then((types) => { + setUserTypes(types) + // Only one user type exists today (AuthHex's "Admin"/"dev" seed) — default to it + // so the admin never has to pick a raw GUID; the select still lets them switch + // if more types are added later. + if (types.length > 0) setUserTypeId((current) => current || types[0].userTypeId) + }) + .catch(() => setUserTypes([])) + }, []) + + function resetForm() { + setUsername("") + setFullName("") + setEmail("") + setMobileNumber("") + setNic("") + setRoleId("") + setUserTypeId("") + setErrors({}) + } + + async function handleCreate() { + const nextErrors: Record = {} + if (!username.trim()) nextErrors.username = "Username is required" + if (!fullName.trim()) nextErrors.fullName = "Full name is required" + if (!email.trim()) nextErrors.email = "Email is required" + if (!roleId) nextErrors.roleId = "Role is required" + if (!userTypeId.trim()) nextErrors.userTypeId = "User type is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) return + + setSubmitting(true) + try { + const result = await usersApi.create({ + username: username.trim(), + fullName: fullName.trim(), + email: email.trim(), + mobileNumber: mobileNumber || null, + nic: nic || null, + roleId: Number(roleId), + userTypeId: userTypeId.trim(), + }) + toast.success("User created", `Credentials have been emailed to ${result.username}.`) + setOpen(false) + resetForm() + load() + } catch (err) { + const fe = fieldErrors(err) + if (fe) setErrors(fe) + toast.error("Could not create user", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

Users

+

+ Create accounts and assign roles. New users receive their credentials by email. +

+
+ + + + + New User + + } + /> + + + New user + Created in both the auth service and here; password is emailed. + + + + Username + setUsername(e.target.value)} aria-invalid={!!errors.username} /> + + + + Full name + setFullName(e.target.value)} aria-invalid={!!errors.fullName} /> + + + + Email + setEmail(e.target.value)} aria-invalid={!!errors.email} /> + + + + Mobile number (optional) + setMobileNumber(e.target.value)} /> + + + NIC (optional) + setNic(e.target.value)} /> + + + Role + + + + + User type + + + + +
+ + +
+
+
+
+ + {error && ( +
{error}
+ )} + + {!error && users === null && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + + {!error && users !== null && users.length === 0 && ( +
+ +

No users yet.

+
+ )} + + {!error && users !== null && users.length > 0 && ( + + + + Username + Display name + Role + Status + Actions + + + + {users.map((u) => ( + + {u.username} + {u.displayName} + {u.roleName ?? "—"} + + + {u.status} + + + + + + + + + ))} + +
+ )} + + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ )} +
+ ) +} diff --git a/Frontend/erp-system/app/login/page.tsx b/Frontend/erp-system/app/login/page.tsx index 5568fce..8ae1730 100644 --- a/Frontend/erp-system/app/login/page.tsx +++ b/Frontend/erp-system/app/login/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { Suspense, useState } from "react" import Image from "next/image" import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" @@ -41,6 +41,14 @@ function GoogleIcon() { } export default function LoginPage() { + return ( + + + + ) +} + +function LoginForm() { const router = useRouter() const searchParams = useSearchParams() const [showPassword, setShowPassword] = useState(false) diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index e3bdc52..fee71f5 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -16,57 +16,77 @@ import { PackageCheck, Ruler, Settings, + ShieldCheck, ShoppingCart, SlidersHorizontal, SwatchBook, Tag, Truck, + Users, Warehouse, X, type LucideIcon, } from "lucide-react" import { cn } from "@/lib/utils" +import { useAuth } from "@/components/auth/AuthProvider" +// `code` must match the seeded NavItem/SubNavItem codes in ERPCore +// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs) +// so role-based filtering (via GET /auth/me's navCodes) can match entries here. const navItems: { title: string + code: string href: string icon: LucideIcon chevron?: boolean - children?: { title: string; href: string; icon: LucideIcon }[] + children?: { title: string; code: string; href: string; icon: LucideIcon }[] }[] = [ - { title: "Dashboard", href: "/dashboard", icon: LayoutGrid }, + { title: "Dashboard", code: "dashboard", href: "/dashboard", icon: LayoutGrid }, { title: "Products", + code: "products", href: "/dashboard/products", icon: Package, chevron: true, children: [ - { title: "Item", href: "/dashboard/products", icon: Boxes }, - { title: "Category", href: "/dashboard/products/categories", icon: ListTree }, - { title: "Brand", href: "/dashboard/products/brands", icon: Tag }, - { title: "Item Type", href: "/dashboard/products/item-types", icon: SwatchBook }, - { title: "UOM", href: "/dashboard/products/uoms", icon: Ruler }, - { title: "Configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal }, + { title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes }, + { title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree }, + { title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag }, + { title: "Item Type", code: "products.item-type", href: "/dashboard/products/item-types", icon: SwatchBook }, + { title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler }, + { title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal }, ], }, - { title: "Vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, - { title: "Procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true }, - { title: "Receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, - { title: "Stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, - { title: "Warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, - { title: "Orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true }, - { title: "Settings", href: "/dashboard/settings", icon: Settings, chevron: true }, - { title: "Help", href: "/dashboard/help", icon: HelpCircle }, + { title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true }, + { title: "Procurement", code: "procurement", href: "/dashboard/procurement", icon: ClipboardList, chevron: true }, + { title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true }, + { title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true }, + { title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true }, + { title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true }, + { + title: "Settings", + code: "settings", + href: "/dashboard/settings", + icon: Settings, + chevron: true, + children: [ + { title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck }, + { title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users }, + ], + }, + { title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle }, ] function SidebarContent({ + items, collapsed, onCollapse, onClose, pathname, isMobile, }: { + items: typeof navItems collapsed: boolean onCollapse: () => void onClose?: () => void @@ -110,7 +130,7 @@ function SidebarContent({ {/* Nav items */}
    - {navItems.map((item) => { + {items.map((item) => { const isActive = item.href === "/dashboard" ? pathname === item.href : pathname.startsWith(item.href) @@ -195,6 +215,19 @@ export function AppSidebar() { const pathname = usePathname() || "/" const [collapsed, setCollapsed] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) + const { navCodes, loading } = useAuth() + + // While /auth/me hasn't resolved yet, show nothing rather than briefly + // flashing the full menu to a restricted role. Once resolved, a nav item + // is visible if its own code is granted, or (for parents) if any child is. + const visibleItems = loading + ? [] + : navItems + .filter((item) => navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code))) + .map((item) => ({ + ...item, + children: item.children?.filter((c) => navCodes.includes(c.code)), + })) // Close mobile menu on route change useEffect(() => { @@ -213,6 +246,7 @@ export function AppSidebar() { {/* ── Desktop sidebar ─────────────────────────────── */}
    setCollapsed((v) => !v)} pathname={pathname} @@ -247,6 +281,7 @@ export function AppSidebar() { )} > {}} onClose={() => setMobileOpen(false)} diff --git a/Frontend/erp-system/components/auth/AuthProvider.tsx b/Frontend/erp-system/components/auth/AuthProvider.tsx new file mode 100644 index 0000000..d55f5a0 --- /dev/null +++ b/Frontend/erp-system/components/auth/AuthProvider.tsx @@ -0,0 +1,63 @@ +"use client" + +// Authoritative session context: fetches GET /auth/me once per mount and exposes +// the current role + permitted sidebar nav codes. Replaces trusting the stale, +// client-only `roleId` cached by lib/auth-session.ts for anything access-related +// (that cache remains display-only, e.g. for the header's user name). +import { createContext, useContext, useEffect, useState } from "react" + +import { authApi } from "@/lib/api/auth" +import { MeResponse } from "@/types/rbac" + +interface AuthContextValue { + roleCode: string | null + roleName: string | null + navCodes: string[] + /** True until the first `/auth/me` response lands. */ + loading: boolean +} + +const AuthContext = createContext({ + roleCode: null, + roleName: null, + navCodes: [], + loading: true, +}) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [me, setMe] = useState(null) + + useEffect(() => { + let cancelled = false + authApi + .me() + .then((res) => { + if (!cancelled) setMe(res) + }) + .catch(() => { + // Unauthenticated/unreachable — fall back to "no permissions" rather than + // crash the shell; proxy.ts already redirects unauthenticated users to /login. + if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [] }) + }) + return () => { + cancelled = true + } + }, []) + + return ( + + {children} + + ) +} + +export function useAuth(): AuthContextValue { + return useContext(AuthContext) +} diff --git a/Frontend/erp-system/components/auth/RolePermissionTree.tsx b/Frontend/erp-system/components/auth/RolePermissionTree.tsx new file mode 100644 index 0000000..d5bdc17 --- /dev/null +++ b/Frontend/erp-system/components/auth/RolePermissionTree.tsx @@ -0,0 +1,85 @@ +"use client" + +// Checkbox tree for assigning a role's visible sidebar sections. Leaf nav items +// (no children) are toggled directly; parent nav items with children are a +// "select all children" convenience toggle — the parent's own visibility is +// derived from its children on the frontend (see AppSidebar.tsx's filter), so +// only the children need to carry the actual grant for those groups. +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import { NavItem } from "@/types/rbac" + +interface Props { + tree: NavItem[] + selectedNavItemIds: Set + selectedSubNavItemIds: Set + onChange: (navItemIds: Set, subNavItemIds: Set) => void +} + +export function RolePermissionTree({ tree, selectedNavItemIds, selectedSubNavItemIds, onChange }: Props) { + function toggleLeaf(navItemId: number) { + const next = new Set(selectedNavItemIds) + if (next.has(navItemId)) next.delete(navItemId) + else next.add(navItemId) + onChange(next, selectedSubNavItemIds) + } + + function toggleChild(subNavItemId: number) { + const next = new Set(selectedSubNavItemIds) + if (next.has(subNavItemId)) next.delete(subNavItemId) + else next.add(subNavItemId) + onChange(selectedNavItemIds, next) + } + + function toggleAllChildren(item: NavItem, checked: boolean) { + const next = new Set(selectedSubNavItemIds) + for (const child of item.children) { + if (checked) next.add(child.subNavItemId) + else next.delete(child.subNavItemId) + } + onChange(selectedNavItemIds, next) + } + + return ( +
    + {tree.map((item) => { + if (item.children.length === 0) { + return ( + + ) + } + + const checkedCount = item.children.filter((c) => selectedSubNavItemIds.has(c.subNavItemId)).length + const allChecked = checkedCount === item.children.length + + return ( +
    + +
    + {item.children.map((child) => ( + + ))} +
    +
    + ) + })} +
    + ) +} diff --git a/Frontend/erp-system/lib/api/auth.ts b/Frontend/erp-system/lib/api/auth.ts index a214964..a2477d7 100644 --- a/Frontend/erp-system/lib/api/auth.ts +++ b/Frontend/erp-system/lib/api/auth.ts @@ -2,8 +2,14 @@ // delivers the session as httpOnly cookies — there is no token for JS to hold or attach. import { apiRequest } from "@/lib/api-client" import { AuthSession, LoginRequest, RegisterRequest } from "@/types/auth" +import { MeResponse } from "@/types/rbac" export const authApi = { + /** Authoritative role + permitted sidebar nav codes for the current session. */ + me(): Promise { + return apiRequest("/auth/me") + }, + /** Sets erp_at / erp_rt / XSRF-TOKEN cookies on success. Body carries no tokens. */ login(request: LoginRequest): Promise { return apiRequest("/auth/login", { method: "POST", body: request }) diff --git a/Frontend/erp-system/lib/api/nav.ts b/Frontend/erp-system/lib/api/nav.ts new file mode 100644 index 0000000..b46b8b1 --- /dev/null +++ b/Frontend/erp-system/lib/api/nav.ts @@ -0,0 +1,9 @@ +// Read-only sidebar nav tree (ERPCore Controllers/NavController.cs). +import { apiRequest } from "@/lib/api-client" +import { NavItem } from "@/types/rbac" + +export const navApi = { + tree(): Promise { + return apiRequest("/nav") + }, +} diff --git a/Frontend/erp-system/lib/api/roles.ts b/Frontend/erp-system/lib/api/roles.ts new file mode 100644 index 0000000..7f3a6e6 --- /dev/null +++ b/Frontend/erp-system/lib/api/roles.ts @@ -0,0 +1,51 @@ +// Role CRUD + permission assignment (ERPCore Controllers/RolesController.cs). +import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" +import { ApiResult, EntityStatus, PagedResponse } from "@/types/common" +import { + AssignRolePermissionsRequest, + CreateRoleRequest, + Role, + RolePermissions, + UpdateRoleRequest, +} from "@/types/rbac" + +export interface ListRolesParams { + page?: number + pageSize?: number + q?: string + status?: EntityStatus +} + +export const rolesApi = { + list(params: ListRolesParams = {}): Promise> { + return apiRequest>(`/roles${buildQuery(params)}`) + }, + + get(roleId: number): Promise> { + return apiRequestWithETag(`/roles/${roleId}`) + }, + + create(request: CreateRoleRequest): Promise> { + return apiRequestWithETag("/roles", { method: "POST", body: request }) + }, + + update(roleId: number, request: UpdateRoleRequest, ifMatch: string): Promise> { + return apiRequestWithETag(`/roles/${roleId}`, { method: "PUT", body: request, ifMatch }) + }, + + updateStatus(roleId: number, status: EntityStatus): Promise { + return apiRequest(`/roles/${roleId}/status`, { method: "PATCH", body: { status } }) + }, + + remove(roleId: number): Promise { + return apiRequest(`/roles/${roleId}`, { method: "DELETE" }) + }, + + getPermissions(roleId: number): Promise { + return apiRequest(`/roles/${roleId}/permissions`) + }, + + assignPermissions(roleId: number, request: AssignRolePermissionsRequest): Promise { + return apiRequest(`/roles/${roleId}/permissions`, { method: "PUT", body: request }) + }, +} diff --git a/Frontend/erp-system/lib/api/users.ts b/Frontend/erp-system/lib/api/users.ts new file mode 100644 index 0000000..71bb640 --- /dev/null +++ b/Frontend/erp-system/lib/api/users.ts @@ -0,0 +1,33 @@ +// User management (ERPCore Controllers/UsersController.cs). Create orchestrates +// account creation in both AuthHex and ERPCore's local shadow table server-side. +import { apiRequest, buildQuery } from "@/lib/api-client" +import { PagedResponse } from "@/types/common" +import { CreateUserRequest, ManagedUser, UpdateUserRoleRequest, UserTypeOption } from "@/types/users" + +export interface ListUsersParams { + page?: number + pageSize?: number + q?: string +} + +export const usersApi = { + list(params: ListUsersParams = {}): Promise> { + return apiRequest>(`/users${buildQuery(params)}`) + }, + + get(userId: number): Promise { + return apiRequest(`/users/${userId}`) + }, + + create(request: CreateUserRequest): Promise { + return apiRequest("/users", { method: "POST", body: request }) + }, + + updateRole(userId: number, request: UpdateUserRoleRequest): Promise { + return apiRequest(`/users/${userId}/role`, { method: "PUT", body: request }) + }, + + userTypes(): Promise { + return apiRequest("/users/user-types") + }, +} diff --git a/Frontend/erp-system/types/rbac.ts b/Frontend/erp-system/types/rbac.ts new file mode 100644 index 0000000..cbd7d42 --- /dev/null +++ b/Frontend/erp-system/types/rbac.ts @@ -0,0 +1,58 @@ +// Role / Nav / Permission DTOs (mirrors ERPCore's Dtos/Rbac/*.cs exactly). +import { EntityStatus } from "@/types/common" + +export interface Role { + roleId: number + code: string + name: string + isSystemRole: boolean + status: EntityStatus + createdAt: string + updatedAt: string | null +} + +export interface CreateRoleRequest { + code: string + name: string +} + +export interface UpdateRoleRequest { + code: string + name: string +} + +export interface SubNavItem { + subNavItemId: number + code: string + label: string + icon: string | null + href: string | null + sortOrder: number +} + +export interface NavItem { + navItemId: number + code: string + label: string + icon: string | null + href: string | null + sortOrder: number + children: SubNavItem[] +} + +export interface RolePermissions { + roleId: number + navItemIds: number[] + subNavItemIds: number[] +} + +export interface AssignRolePermissionsRequest { + navItemIds: number[] + subNavItemIds: number[] +} + +export interface MeResponse { + roleCode: string | null + roleName: string | null + navCodes: string[] +} diff --git a/Frontend/erp-system/types/users.ts b/Frontend/erp-system/types/users.ts new file mode 100644 index 0000000..8ac5285 --- /dev/null +++ b/Frontend/erp-system/types/users.ts @@ -0,0 +1,35 @@ +// Managed-user DTOs (mirrors ERPCore's Dtos/Users/UserDtos.cs). +import { EntityStatus } from "@/types/common" + +export interface ManagedUser { + userId: number + username: string + displayName: string + status: EntityStatus + roleId: number | null + roleCode: string | null + roleName: string | null +} + +export interface CreateUserRequest { + username: string + fullName: string + roleId: number + userTypeId: string + email: string + nic?: string | null + mobileNumber?: string | null + /** Left empty to auto-generate — AuthHex emails it to `email`. */ + password?: string | null +} + +export interface UpdateUserRoleRequest { + roleId: number +} + +/** AuthHex UserType lookup, for the create-user form's select (no local shadow — read-only). */ +export interface UserTypeOption { + userTypeId: string + code: string | null + description: string | null +} diff --git a/docs/10-BACKEND-PHASE1.md b/docs/10-BACKEND-PHASE1.md index b181247..5f4ca3f 100644 --- a/docs/10-BACKEND-PHASE1.md +++ b/docs/10-BACKEND-PHASE1.md @@ -194,7 +194,7 @@ One base currency; invoicing/3-way match in Accounting (GRN carries data); users | FR-X-05 | No editing/deleting confirmed/posted transactions; corrections via reversing entries. | M | ## B.4 Data Model (summary) -Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and reserved RBAC (Role, Permission, UserRole, RolePermission). +Key entities and relationships are enumerated in **Part C**. The commitment: Item, UOM/UOMConversion, Category, SubCategory, Brand, ItemType, ProductConfig, Vendor, Warehouse/Bin, ItemReorder, Requisition(+Line), RFQ(+Line)/VendorQuotation, PurchaseOrder(+Line), GRN(+Line), PurchaseReturn(+Line), StockLayer (FIFO), StockLedger (immutable), Batch, Serial, StockTransfer(+Line), StockAdjustment(+Line), StockCount(+Line), User, ReasonCode, NumberSequence, AuditLog, JournalEntryStub, and RBAC for sidebar visibility (Role, NavItem, SubNavItem, Permission, RolePermission — see C.8; per-endpoint enforcement still deferred). ## B.5 External Interfaces UI: responsive; count/pick screens handheld-friendly; status badges; mandatory-field validation. Hardware: barcode/QR (designed-for). Software: relational DB with transactional integrity + row locking; internal service interfaces/events for Phase-2+ modules. @@ -348,13 +348,16 @@ AUDIT_LOG(audit_id PK, user_id FK→USER, entity_type, entity_id, action, change JOURNAL_ENTRY_STUB(journal_id PK, source_doc_type, source_doc_id, debit_account, credit_account, amount) ``` -## C.8 Reserved (RBAC — deferred, schema placeholder only) +## C.8 RBAC — sidebar-visibility only (implemented 2026-07-18); per-endpoint enforcement still deferred ``` -ROLE(role_id PK, name) -PERMISSION(permission_id PK, code) -USER_ROLE(user_id FK→USER, role_id FK→ROLE) +ROLE(role_id PK, auth_role_id [GUID, unique] → AuthHex Role, code, name, is_system_role, status, created_at, updated_at, row_version) -- local shadow/projection of AuthHex's Role, same pattern as USER +NAV_ITEM(nav_item_id PK, code, label, icon, href, sort_order, status) -- top-level sidebar entry; seeded to match the frontend +SUB_NAV_ITEM(sub_nav_item_id PK, nav_item_id FK→NAV_ITEM, code, label, icon, href, sort_order, status) +PERMISSION(permission_id PK, code, nav_item_id FK→NAV_ITEM [nullable], sub_nav_item_id FK→SUB_NAV_ITEM [nullable]) -- exactly one of the two FKs is set ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION) +USER(..., role_id FK→ROLE [nullable]) -- added to the existing USER shadow (see C.7) ``` +Note: `USER_ROLE` from the original placeholder sketch was dropped — a user has at most one role (`USER.role_id`), matching AuthHex's own `User.RoleId` being a single scalar FK, not a many-to-many. ## C.9 Modeling notes (load-bearing) - **Item types are a dropdown, not a relationship.** `ITEM_TYPE` (Color, Size, Material) exists **only** to populate the frontend item-builder's dropdown via `GET /item-types`. Nothing references it and it references nothing — there is no value table and no join to `ITEM`. The builder cross-products the checked types into **one standalone item per combination**; the chosen values (Red, S, M) are encoded by the **client** into the generated SKU (`BL-0002` for one type, `BL-100-0003` for two) and the server only checks that SKU for uniqueness. **The item list is the record of what was built.** This is not a product-variation model: there is no parent-product entity and no variant hierarchy. @@ -366,7 +369,7 @@ ROLE_PERMISSION(role_id FK→ROLE, permission_id FK→PERMISSION) - **In-transit + cost-preserving transfer.** `STOCK_TRANSFER` holds `src`/`dest` warehouse; dispatch consumes source layers into in-transit, receive creates the destination layer at the **inherited** source cost. - **FEFO ≠ FIFO.** FIFO governs *costing*; FEFO governs *physical picking* of perishables via `BATCH.expiry_date`. - **External IdP + shadow user.** Authentication is delegated to **AuthHex** (RS256, issuer `AuthHex`/audience `AuthHexClient`, static public key). `USER` is a **local shadow** of AuthHex identities: `auth_user_id` (GUID from the token's `UserId` claim) is JIT-mapped to the local `int` `user_id` that every `created_by`/`requested_by`/`AUDIT_LOG.user_id`/`STOCK_LEDGER.user_id` FK references — no FK type change. A door policy admits only ERP `UserType`/`Role` holders. -- **Reserved RBAC.** Role/Permission/UserRole/RolePermission exist for schema-completeness only; only `USER` is live (audit stamp). AuthHex's `RoleCode`/`UserTypeCode` claims drive the door gate today; per-endpoint RBAC is future work. +- **RBAC — sidebar visibility, not endpoint enforcement (2026-07-18).** `Role`/`NavItem`/`SubNavItem`/`Permission`/`RolePermission` are now live tables backing Role CRUD (`RolesController`) and a permission-assignment UI. AuthHex remains the source of truth for `Role` identity (Guid PK, referenced by its JWT `RoleId`/`RoleCode` claims); ERPCore's `Role` is a **local shadow synced on write** — `RolesController` calls AuthHex's new `/api/role` functions first, then mirrors the result into the local int-keyed row (`auth_role_id` maps the two), exactly like `USER`/`auth_user_id`. `GET /api/v1/auth/me` resolves the caller's `RoleCode` claim to its local `Role`, joins `RolePermission`, and returns the permitted `NavItem`/`SubNavItem` codes for the frontend to filter its sidebar by. **This is deliberately UI-only**: no endpoint in this API (including the new Role/User/Nav ones) gained an authorization check from this work — AR-01 in `02-SECURITY.md` is unchanged, and per-endpoint RBAC remains future work (Part D there). - **Reorder alerts are a query**, not an entity — computed from `ITEM_REORDER` vs available. Add a table only if alert history is required. ## C.10 Entity → implementation mapping diff --git a/docs/11-BACKEND-PHASE1.md b/docs/11-BACKEND-PHASE1.md index eeaaa89..6fad489 100644 --- a/docs/11-BACKEND-PHASE1.md +++ b/docs/11-BACKEND-PHASE1.md @@ -111,6 +111,57 @@ Request/response field shapes match AuthHex's own payloads one-for-one (project- session-issuing responses omit `AccessToken`/`RefreshToken` (cookie-delivered instead) and `refreshToken` is read from the `erp_rt` cookie rather than the request body. +### 2.0.1 RBAC — Roles, sidebar nav, Users (added 2026-07-18; sidebar-visibility only, see docs/10 C.8) + +`GET /api/v1/auth/me` — the frontend's authoritative source for the current session's role and permitted sidebar sections +(replaces the previous client-only `roleId` cached in localStorage). No payload. +**200 OK** +```json +{ "roleCode": "ADMIN", "roleName": "Administrator", "navCodes": ["dashboard", "products", "products.item", "settings.roles", "..."] } +``` + +`GET /api/v1/nav` — read-only sidebar tree (`NavItem` + nested `SubNavItem`), seeded to mirror the frontend's hardcoded +sidebar (`components/Layouts/AppSidebar.tsx`); used to render the Role permission-assignment checkbox UI. Not admin-editable +in this phase. + +**Roles** (`RolesController`) — `Role` is a **local shadow of AuthHex's Role** (same pattern as `USER`/`auth_user_id`, +docs/10 C.9): every write below forwards to AuthHex's new `/api/role` functions first, then mirrors the result locally. +| Route | Notes | +|---|---| +| `GET /roles` | Paged list; `q`, `status` filters. | +| `GET /roles/{roleId}` | `ETag` header for `If-Match` on update. | +| `POST /roles` | `{ code, name }` → `201`, forwards to AuthHex `createRole`. | +| `PUT /roles/{roleId}` | Requires `If-Match`; forwards to AuthHex `updateRole`. | +| `PATCH /roles/{roleId}/status` | `{ status }` → `204`. | +| `DELETE /roles/{roleId}` | Forwards to AuthHex `deleteRole`; `409 ROLE_IN_USE` if any user still holds it. | +| `GET /roles/{roleId}/permissions` | `{ roleId, navItemIds, subNavItemIds }`. | +| `PUT /roles/{roleId}/permissions` | Replaces the role's full permission set from `{ navItemIds, subNavItemIds }` — purely local, no AuthHex call. | + +> **`code` is server-accepted but frontend-derived, never hand-typed (2026-07-18).** The Roles UI computes `code` from +> `name` (uppercased, non-alphanumeric → `_`) and submits it read-only; the field stays free-form here for API callers, +> but no UI lets an operator type or edit it directly, on create or later. The Create Role dialog also now includes the +> permission checkbox tree, so `POST /roles` and `PUT /roles/{roleId}/permissions` fire as one user action. +> +> **Bug fixed (2026-07-18): `POST /roles`/`PUT /roles/{roleId}` 500ing via `AUTH_UPSTREAM_ERROR`.** `IsSystemRole` being +> omitted serialized as JSON `null`, and AuthHex's `createRole`/`updateRole` called `JsonElement.GetBoolean()` on it +> unconditionally when the key was present — which throws on `null` (unlike `GetString()`, which tolerates it). Fixed on +> both sides: AuthHex now checks `ValueKind != JsonValueKind.Null` before reading `isSystemRole`, and ERPCore's +> `AuthHexClient` now serializes with `JsonIgnoreCondition.WhenWritingNull` so unset nullable fields are omitted from the +> payload entirely rather than sent as explicit nulls — closing this class of bug for any other nullable field sent to AuthHex. + +**Users** (`UsersController`) — manages the local shadow `User` table and orchestrates account creation in AuthHex. +| Route | Notes | +|---|---| +| `GET /users` | Paged list, joined with `Role`. | +| `GET /users/{userId}` | Single record. | +| `POST /users` | Creates the account in **both** backends: calls AuthHex's `registerUser` (which persists the password and emails it to the given `email`), then immediately mirrors the local shadow `User` row (rather than waiting for next-login JIT provisioning). Body: `{ username, fullName, roleId, userTypeId, email, nic?, mobileNumber?, password? }` (`password` empty ⇒ AuthHex auto-generates one). | +| `PUT /users/{userId}/role` | `{ roleId }` — local role reassignment only; status/lock changes reuse the existing `/auth/status` and `/auth/lock` proxy endpoints. | +| `GET /users/user-types` | Added 2026-07-18. Proxies AuthHex's new `listUserTypes` — `[{ userTypeId, code, description }]`. Populates the Create User form's UserType select so operators pick from a real list instead of typing an AuthHex GUID by hand; defaults to the sole existing type when only one exists. | + +> **Known gap, not fixed (flagged 2026-07-18):** AuthHex's `loginUser` resolves `identifier` against `Email`/`MobileNumber`/`Nic` +> only — **not** `Username` (`UserManageRepository.GetUserByIdentifierAndType`). A user created via `POST /users` can log in +> with their email but not their username. Out of scope for this change; revisit if/when asked. + ### 2.1 Items > **`itemType` → `stockNature` (2026-07-16).** The Stocked/NonStocked/Service field was renamed so the name `itemType` could be taken by the new Item Type master (§2.7) — an unrelated concept. Items gained `subCategoryId` and `brandId` (both nullable). Items carry **no** item-type reference: the values chosen in the builder are encoded into the client-generated SKU (docs/10 Part C.9). diff --git a/docs/20-FRONTEND.md b/docs/20-FRONTEND.md index 2fcd1ae..151e246 100644 --- a/docs/20-FRONTEND.md +++ b/docs/20-FRONTEND.md @@ -28,7 +28,9 @@ Principles: ## 2. User flows -The flow below is the end-to-end Phase-1 journey: replenishment need → procurement → receiving → QC hold → stock available → stock operations, with the reorder loop closing back. Colour = functional role (roles are conceptual; RBAC is not enforced in Phase 1). +The flow below is the end-to-end Phase-1 journey: replenishment need → procurement → receiving → QC hold → stock available → stock operations, with the reorder loop closing back. Colour = functional role. Roles now drive real sidebar visibility (`GET /auth/me`'s `navCodes`, see docs/10 C.8/docs/11 §2.0.1, admin screens at `/dashboard/settings/roles` and `/dashboard/settings/users`) but **per-endpoint RBAC is still not enforced** — this remains a UI-level filter only. + +> **Roles screen (2026-07-18):** `code` is never typed by an operator — it's derived client-side from `name` (uppercased, non-alphanumeric → `_`) and shown read-only, on both create and edit. The Create Role dialog also includes the permission checkbox tree (`components/auth/RolePermissionTree.tsx`, fed by `GET /nav`), so creating a role and assigning its sidebar permissions is one Save action; the detail page (`/dashboard/settings/roles/[id]`) remains for later edits. The Create User dialog's "User type" is a `