emloyee and user fixings
This commit is contained in:
@@ -27,13 +27,15 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly IAuthRecoveryService _recovery;
|
||||
private readonly IAuthAltService _alt;
|
||||
private readonly IRoleService _roles;
|
||||
private readonly IAuditService _audit;
|
||||
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles)
|
||||
public AuthController(IAuthUserService users, IAuthRecoveryService recovery, IAuthAltService alt, IRoleService roles, IAuditService audit)
|
||||
{
|
||||
_users = users;
|
||||
_recovery = recovery;
|
||||
_alt = alt;
|
||||
_roles = roles;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,6 +70,7 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
var result = await _users.LoginAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
@@ -78,9 +81,13 @@ public sealed class AuthController : ControllerBase
|
||||
{
|
||||
var result = await _users.VerifyOtpForLoginAsync(request, ct);
|
||||
AuthCookieWriter.WriteSession(Response, result.AccessToken, result.RefreshToken, result.Body.ExpiresIn);
|
||||
if (result.Body.User?.UserId is Guid authUserId) await _audit.RecordLoginAsync(authUserId, ClientIp(), ClientUserAgent(), ct);
|
||||
return Ok(result.Body);
|
||||
}
|
||||
|
||||
private string? ClientIp() => HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
private string? ClientUserAgent() => Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null;
|
||||
|
||||
[HttpPost("refresh-token")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(AuthSessionResponse), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -61,4 +61,14 @@ public sealed class BranchesController : ApiControllerBase
|
||||
await _branches.SetStatusAsync(branchId, request.Status, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{branchId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int branchId, CancellationToken ct)
|
||||
{
|
||||
await _branches.DeleteAsync(branchId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,6 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
/// <summary>Never a hard delete — Employee is retained forever (docs/12-BACKEND-HRM.md C.2).</summary>
|
||||
[HttpPatch("{employeeId:int}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
@@ -93,6 +92,17 @@ public sealed class EmployeesController : ApiControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Permanently deletes the employee and all dependent HR records. Blocked (409) if payroll history exists.</summary>
|
||||
[HttpDelete("{employeeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int employeeId, CancellationToken ct)
|
||||
{
|
||||
await _employees.DeleteAsync(employeeId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{employeeId:int}/link-user")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Dtos.Users;
|
||||
@@ -16,12 +18,14 @@ public sealed class UsersController : ApiControllerBase
|
||||
{
|
||||
private readonly IUserManagementService _users;
|
||||
private readonly IEmployeeUserLinkService _links;
|
||||
private readonly IAuditService _audit;
|
||||
private readonly ICurrentUser _currentUser;
|
||||
|
||||
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, ICurrentUser currentUser)
|
||||
public UsersController(IUserManagementService users, IEmployeeUserLinkService links, IAuditService audit, ICurrentUser currentUser)
|
||||
{
|
||||
_users = users;
|
||||
_links = links;
|
||||
_audit = audit;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
@@ -33,8 +37,8 @@ public sealed class UsersController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<ManagedUserDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _users.ListAsync(query, ct));
|
||||
public async Task<ActionResult<PagedResponse<ManagedUserDto>>> List([FromQuery] PageQuery query, [FromQuery] EntityStatus? status, [FromQuery] string? roleCode, CancellationToken ct)
|
||||
=> Ok(await _users.ListAsync(query, status, roleCode, ct));
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
[HttpGet("user-types")]
|
||||
@@ -65,4 +69,18 @@ public sealed class UsersController : ApiControllerBase
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ManagedUserDto>> UpdateRole(int userId, [FromBody] UpdateUserRoleRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.UpdateRoleAsync(userId, request, ct));
|
||||
|
||||
/// <summary>Revokes/restores standing inside ERPCore only — see <see cref="IUserManagementService.SetStatusAsync"/>.</summary>
|
||||
[HttpPatch("{userId:int}/status")]
|
||||
[ProducesResponseType(typeof(ManagedUserDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ManagedUserDto>> UpdateStatus(int userId, [FromBody] UpdateUserStatusRequest request, CancellationToken ct)
|
||||
=> Ok(await _users.SetStatusAsync(userId, request.Status, ct));
|
||||
|
||||
/// <summary>Device/login history ERPCore captures itself — not AuthHex's "currently active sessions"
|
||||
/// (which only ever reports the caller's own). See <see cref="IAuditService.ListLoginDevicesAsync"/>.</summary>
|
||||
[HttpGet("{userId:int}/login-devices")]
|
||||
[ProducesResponseType(typeof(PagedResponse<LoginActivityDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<LoginActivityDto>>> ListLoginDevices(int userId, [FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _audit.ListLoginDevicesAsync(userId, query, ct));
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ public sealed class WarehousesController : ApiControllerBase
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(PagedResponse<WarehouseDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List([FromQuery] PageQuery query, CancellationToken ct)
|
||||
=> Ok(await _warehouses.ListAsync(query, ct));
|
||||
public async Task<ActionResult<PagedResponse<WarehouseDto>>> List(
|
||||
[FromQuery] PageQuery query, [FromQuery] int? branchId, CancellationToken ct)
|
||||
=> Ok(await _warehouses.ListAsync(query, branchId, ct));
|
||||
|
||||
[HttpGet("{warehouseId:int}")]
|
||||
[ProducesResponseType(typeof(WarehouseDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -6,8 +6,10 @@ namespace ERPCore.Domain.Entities;
|
||||
/// Staff record (FR-HR-MD-02) — distinct from <see cref="User"/> (the system login
|
||||
/// account): not every employee has a login, and not every login belongs to an
|
||||
/// employee. <see cref="UserId"/> is the optional, explicit, human-confirmed link
|
||||
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Never hard-deleted —
|
||||
/// separation is recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/>.
|
||||
/// between the two (docs/12-BACKEND-HRM.md A.5/C.2, Part B.3.2). Normal separation is
|
||||
/// recorded via <see cref="Status"/> + <see cref="LastWorkingDate"/> rather than deletion;
|
||||
/// an explicit hard delete is also available (<see cref="Services.Interfaces.IEmployeeService.DeleteAsync"/>)
|
||||
/// and cascades to every dependent HR record, blocked only if payroll history exists.
|
||||
/// <see cref="EmployeeCode"/> is user-entered (not <see cref="NumberSequence"/>-issued):
|
||||
/// HR departments keep their own legacy numbering scheme, and NumberSequence's
|
||||
/// year-scoping is the wrong shape for an identifier that must never look "reset".
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ERPCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per successful sign-in, captured directly by ERPCore (not AuthHex) so an
|
||||
/// admin can see a user's device/login history — AuthHex's own session listing only
|
||||
/// ever reports the currently-authenticated caller's own sessions, with no admin-scoped
|
||||
/// call to inspect anyone else's. This is a history of past logins, not a live "currently
|
||||
/// active sessions" view (that would need AuthHex to expose one).
|
||||
/// </summary>
|
||||
public class LoginActivity
|
||||
{
|
||||
public int LoginActivityId { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public User? User { get; set; }
|
||||
|
||||
public string? IpAddress { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
/// <summary>Best-effort "Browser on OS" label parsed from <see cref="UserAgent"/> for display.</summary>
|
||||
public string? DeviceLabel { get; set; }
|
||||
|
||||
public DateTime LoginAt { get; set; }
|
||||
}
|
||||
@@ -5,5 +5,8 @@ public enum AuditAction
|
||||
{
|
||||
Create,
|
||||
Update,
|
||||
Delete
|
||||
Delete,
|
||||
/// <summary>A successful sign-in — written explicitly (not via ErpDbContext's automatic
|
||||
/// entity-change capture, since a login mutates nothing) by AuthController.</summary>
|
||||
Login
|
||||
}
|
||||
|
||||
@@ -10,3 +10,7 @@ public sealed record AuditLogDto(
|
||||
/// <summary>A GL-ready journal stub emitted per stock movement (FR-STK-13).</summary>
|
||||
public sealed record JournalEntryStubDto(
|
||||
int JournalId, string SourceDocType, int SourceDocId, string DebitAccount, string CreditAccount, decimal Amount);
|
||||
|
||||
/// <summary>A captured past sign-in — device history, not a live "currently active sessions" view.</summary>
|
||||
public sealed record LoginActivityDto(
|
||||
int LoginActivityId, string? IpAddress, string? DeviceLabel, DateTime LoginAt);
|
||||
|
||||
@@ -53,5 +53,10 @@ public sealed class UpdateUserRoleRequest
|
||||
[Required] public int RoleId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UpdateUserStatusRequest
|
||||
{
|
||||
[Required, EnumDataType(typeof(EntityStatus))] public EntityStatus Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>AuthHex UserType lookup, for populating the create-user form's select (no local shadow — read-only passthrough).</summary>
|
||||
public sealed record UserTypeOptionDto(Guid UserTypeId, string? Code, string? Description);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace ERPCore.Infra.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort "Browser on OS" label from a raw User-Agent string, for display in the
|
||||
/// login-device history — not a full parsing library, just enough to be readable
|
||||
/// (docs call this out: exact device model detection is out of scope).
|
||||
/// </summary>
|
||||
public static class UserAgentLabeler
|
||||
{
|
||||
public static string Label(string? userAgent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown device";
|
||||
|
||||
var os = DetectOs(userAgent);
|
||||
var browser = DetectBrowser(userAgent);
|
||||
return $"{browser} on {os}";
|
||||
}
|
||||
|
||||
private static string DetectOs(string ua)
|
||||
{
|
||||
if (ua.Contains("Windows", StringComparison.OrdinalIgnoreCase)) return "Windows";
|
||||
if (ua.Contains("Mac OS X", StringComparison.OrdinalIgnoreCase) || ua.Contains("Macintosh", StringComparison.OrdinalIgnoreCase)) return "macOS";
|
||||
if (ua.Contains("Android", StringComparison.OrdinalIgnoreCase)) return "Android";
|
||||
if (ua.Contains("iPhone", StringComparison.OrdinalIgnoreCase) || ua.Contains("iPad", StringComparison.OrdinalIgnoreCase)) return "iOS";
|
||||
if (ua.Contains("Linux", StringComparison.OrdinalIgnoreCase)) return "Linux";
|
||||
return "an unknown OS";
|
||||
}
|
||||
|
||||
private static string DetectBrowser(string ua)
|
||||
{
|
||||
// Order matters: Edge/OPR/Chrome all also contain "Safari"/"Chrome" tokens.
|
||||
if (ua.Contains("Edg/", StringComparison.OrdinalIgnoreCase)) return "Edge";
|
||||
if (ua.Contains("OPR/", StringComparison.OrdinalIgnoreCase) || ua.Contains("Opera", StringComparison.OrdinalIgnoreCase)) return "Opera";
|
||||
if (ua.Contains("Firefox", StringComparison.OrdinalIgnoreCase)) return "Firefox";
|
||||
if (ua.Contains("CriOS", StringComparison.OrdinalIgnoreCase) || ua.Contains("Chrome", StringComparison.OrdinalIgnoreCase)) return "Chrome";
|
||||
if (ua.Contains("Safari", StringComparison.OrdinalIgnoreCase)) return "Safari";
|
||||
return "an unknown browser";
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public static class AuditScribe
|
||||
private static readonly HashSet<Type> Excluded =
|
||||
[
|
||||
typeof(AuditLog), typeof(JournalEntryStub), typeof(NumberSequence),
|
||||
typeof(StockLedger), typeof(StockLayer),
|
||||
typeof(StockLedger), typeof(StockLayer), typeof(LoginActivity),
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using ERPCore.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ERPCore.Infra.Persistence.Configurations;
|
||||
|
||||
public sealed class LoginActivityConfiguration : IEntityTypeConfiguration<LoginActivity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LoginActivity> builder)
|
||||
{
|
||||
builder.ToTable("login_activities");
|
||||
builder.HasKey(l => l.LoginActivityId);
|
||||
|
||||
builder.Property(l => l.IpAddress).HasMaxLength(64);
|
||||
builder.Property(l => l.UserAgent).HasMaxLength(500);
|
||||
builder.Property(l => l.DeviceLabel).HasMaxLength(200);
|
||||
builder.Property(l => l.LoginAt).IsRequired();
|
||||
|
||||
builder.HasOne(l => l.User).WithMany().HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(l => new { l.UserId, l.LoginAt });
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
|
||||
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 },
|
||||
// NavItemId 3 ("vendors") retired 2026-09-09 — Vendors moved under Procurement as a
|
||||
// sub-item (see SubNavItemConfiguration/PermissionConfiguration), matching the sidebar.
|
||||
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 },
|
||||
|
||||
@@ -26,7 +26,9 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
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 },
|
||||
// Moved under Procurement as a sub-item (2026-09-09) — same PermissionId (3), just
|
||||
// repointed to the new SubNavItem, so any role already granted this permission keeps it.
|
||||
new Permission { PermissionId = 3, Code = "NAV:procurement.vendors", SubNavItemId = 30 },
|
||||
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 },
|
||||
@@ -67,7 +69,21 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
|
||||
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
|
||||
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 },
|
||||
new Permission { PermissionId = 39, Code = "NAV:procurement.purchase-invoices", SubNavItemId = 28 },
|
||||
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 }
|
||||
new Permission { PermissionId = 40, Code = "NAV:settings.branches", SubNavItemId = 29 },
|
||||
new Permission { PermissionId = 41, Code = "NAV:stock.ledger", SubNavItemId = 31 },
|
||||
new Permission { PermissionId = 42, Code = "NAV:stock.enquiry", SubNavItemId = 32 },
|
||||
new Permission { PermissionId = 43, Code = "NAV:stock.valuation", SubNavItemId = 33 },
|
||||
new Permission { PermissionId = 44, Code = "NAV:stock.transfers", SubNavItemId = 34 },
|
||||
new Permission { PermissionId = 45, Code = "NAV:stock.adjustments", SubNavItemId = 35 },
|
||||
new Permission { PermissionId = 46, Code = "NAV:stock.counts", SubNavItemId = 36 },
|
||||
new Permission { PermissionId = 47, Code = "NAV:stock.reorder-alerts", SubNavItemId = 37 },
|
||||
new Permission { PermissionId = 48, Code = "NAV:stock.wastage", SubNavItemId = 38 },
|
||||
// Sales (NavItemId 13) children — SubNavItemId 23 had no permission row before now
|
||||
// (mirrors SubNavItemConfiguration's rename of that row to sales.bundle-templates).
|
||||
new Permission { PermissionId = 49, Code = "NAV:sales.bundle-templates", SubNavItemId = 23 },
|
||||
new Permission { PermissionId = 50, Code = "NAV:sales.invoices", SubNavItemId = 40 },
|
||||
new Permission { PermissionId = 51, Code = "NAV:sales.free-issues", SubNavItemId = 41 },
|
||||
new Permission { PermissionId = 52, Code = "NAV:sales.day-end", SubNavItemId = 42 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,19 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
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 },
|
||||
new SubNavItem { SubNavItemId = 24, NavItemId = 9, Code = "settings.notification-config", Label = "Notification Config", Href = "/dashboard/settings/notification-config", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-sales", Label = "Bundle Sales", Href = "/dashboard/sales/bundles", SortOrder = 1 },
|
||||
// Sales (NavItemId 13) children — mirror the sidebar order (AppSidebar.tsx). SubNavItemId
|
||||
// 23 is renamed in place (was "sales.bundle-sales" / Bundle Sales) rather than
|
||||
// delete+recreate, so any role already granted this permission keeps it; the rest are new.
|
||||
new SubNavItem { SubNavItemId = 40, NavItemId = 13, Code = "sales.invoices", Label = "Sales", Href = "/dashboard/sales/invoices", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 23, NavItemId = 13, Code = "sales.bundle-templates", Label = "Bundle Templates", Href = "/dashboard/sales/bundles/templates", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 41, NavItemId = 13, Code = "sales.free-issues", Label = "Free Issues", Href = "/dashboard/sales/free-issues", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 42, NavItemId = 13, Code = "sales.day-end", Label = "Day End", Href = "/dashboard/sales/day-end", SortOrder = 4 },
|
||||
// Procurement (NavItemId 4) children — mirror the hub page order.
|
||||
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
|
||||
// these procurement rows were never actually migrated into the database before now.
|
||||
// Vendors (SubNavItemId 30) reuses PermissionId 3, which used to point at the retired
|
||||
// top-level "vendors" NavItem — so any role already granted this permission keeps it.
|
||||
new SubNavItem { SubNavItemId = 30, NavItemId = 4, Code = "procurement.vendors", Label = "Vendors", Href = "/dashboard/vendors", SortOrder = 0 },
|
||||
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
|
||||
@@ -64,7 +73,18 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
|
||||
// FixProductionTemplatesSubNavItemIdConflict for the data repair.
|
||||
new SubNavItem { SubNavItemId = 27, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
|
||||
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 },
|
||||
// Stock (NavItemId 6) children — mirror the sidebar order (AppSidebar.tsx). These rows
|
||||
// never existed in the database before now, so "stock" is still bypassed frontend-side
|
||||
// in AppSidebar.tsx until roles are actually granted these permissions.
|
||||
new SubNavItem { SubNavItemId = 31, NavItemId = 6, Code = "stock.ledger", Label = "Stock Ledger", Href = "/dashboard/stock/ledger", SortOrder = 1 },
|
||||
new SubNavItem { SubNavItemId = 32, NavItemId = 6, Code = "stock.enquiry", Label = "Stock Enquiry", Href = "/dashboard/stock/enquiry", SortOrder = 2 },
|
||||
new SubNavItem { SubNavItemId = 33, NavItemId = 6, Code = "stock.valuation", Label = "Valuation", Href = "/dashboard/stock/valuation", SortOrder = 3 },
|
||||
new SubNavItem { SubNavItemId = 34, NavItemId = 6, Code = "stock.transfers", Label = "Transfers", Href = "/dashboard/stock/transfers", SortOrder = 4 },
|
||||
new SubNavItem { SubNavItemId = 35, NavItemId = 6, Code = "stock.adjustments", Label = "Adjustments", Href = "/dashboard/stock/adjustments", SortOrder = 5 },
|
||||
new SubNavItem { SubNavItemId = 36, NavItemId = 6, Code = "stock.counts", Label = "Counts", Href = "/dashboard/stock/counts", SortOrder = 6 },
|
||||
new SubNavItem { SubNavItemId = 37, NavItemId = 6, Code = "stock.reorder-alerts", Label = "Reorder Alerts", Href = "/dashboard/stock/reorder-alerts", SortOrder = 7 },
|
||||
new SubNavItem { SubNavItemId = 38, NavItemId = 6, Code = "stock.wastage", Label = "Wastage", Href = "/dashboard/stock/wastage", SortOrder = 8 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ public class ErpDbContext : DbContext
|
||||
// --- Cross-cutting: audit trail + GL-ready journal (docs/10 Part C.7) ---
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<JournalEntryStub> JournalEntryStubs => Set<JournalEntryStub>();
|
||||
public DbSet<LoginActivity> LoginActivities => Set<LoginActivity>();
|
||||
|
||||
// --- HRM: org masters (docs/12-BACKEND-HRM.md Part C.1) ---
|
||||
public DbSet<Branch> Branches => Set<Branch>();
|
||||
|
||||
+8005
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MoveVendorsUnderProcurement : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Order matters: Permission.NavItemId cascades on delete, so the permission row must be
|
||||
// repointed off NavItemId 3 (via the new sub_nav_item) before that nav_item is deleted —
|
||||
// otherwise the delete cascades and removes the permission row first.
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[] { 30, "procurement.vendors", "/dashboard/vendors", null, "Vendors", 4, 0 });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 3,
|
||||
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { "NAV:procurement.vendors", null, 30 });
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "nav_items",
|
||||
keyColumn: "NavItemId",
|
||||
keyValue: 3);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 30);
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "nav_items",
|
||||
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder", "Status" },
|
||||
values: new object[] { 3, "vendors", "/dashboard/vendors", null, "Vendors", 3, "Active" });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 3,
|
||||
columns: new[] { "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { "NAV:vendors", 3, null });
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStockSubNavItems : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 31, "stock.ledger", "/dashboard/stock/ledger", null, "Stock Ledger", 6, 1 },
|
||||
{ 32, "stock.enquiry", "/dashboard/stock/enquiry", null, "Stock Enquiry", 6, 2 },
|
||||
{ 33, "stock.valuation", "/dashboard/stock/valuation", null, "Valuation", 6, 3 },
|
||||
{ 34, "stock.transfers", "/dashboard/stock/transfers", null, "Transfers", 6, 4 },
|
||||
{ 35, "stock.adjustments", "/dashboard/stock/adjustments", null, "Adjustments", 6, 5 },
|
||||
{ 36, "stock.counts", "/dashboard/stock/counts", null, "Counts", 6, 6 },
|
||||
{ 37, "stock.reorder-alerts", "/dashboard/stock/reorder-alerts", null, "Reorder Alerts", 6, 7 },
|
||||
{ 38, "stock.wastage", "/dashboard/stock/wastage", null, "Wastage", 6, 8 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 41, "NAV:stock.ledger", null, 31 },
|
||||
{ 42, "NAV:stock.enquiry", null, 32 },
|
||||
{ 43, "NAV:stock.valuation", null, 33 },
|
||||
{ 44, "NAV:stock.transfers", null, 34 },
|
||||
{ 45, "NAV:stock.adjustments", null, 35 },
|
||||
{ 46, "NAV:stock.counts", null, 36 },
|
||||
{ 47, "NAV:stock.reorder-alerts", null, 37 },
|
||||
{ 48, "NAV:stock.wastage", null, 38 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 41);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 42);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 43);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 44);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 45);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 46);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 47);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 48);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 31);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 32);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 33);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 34);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 35);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 36);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 37);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 38);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesSubNavItems : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[] { 49, "NAV:sales.bundle-templates", null, 23 });
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 23,
|
||||
columns: new[] { "Code", "Href", "Label", "SortOrder" },
|
||||
values: new object[] { "sales.bundle-templates", "/dashboard/sales/bundles/templates", "Bundle Templates", 2 });
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "sub_nav_items",
|
||||
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 40, "sales.invoices", "/dashboard/sales/invoices", null, "Sales", 13, 1 },
|
||||
{ 41, "sales.free-issues", "/dashboard/sales/free-issues", null, "Free Issues", 13, 3 },
|
||||
{ 42, "sales.day-end", "/dashboard/sales/day-end", null, "Day End", 13, 4 }
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "permissions",
|
||||
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
|
||||
values: new object[,]
|
||||
{
|
||||
{ 50, "NAV:sales.invoices", null, 40 },
|
||||
{ 51, "NAV:sales.free-issues", null, 41 },
|
||||
{ 52, "NAV:sales.day-end", null, 42 }
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 49);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 50);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 51);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "permissions",
|
||||
keyColumn: "PermissionId",
|
||||
keyValue: 52);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 40);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 41);
|
||||
|
||||
migrationBuilder.DeleteData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 42);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "sub_nav_items",
|
||||
keyColumn: "SubNavItemId",
|
||||
keyValue: 23,
|
||||
columns: new[] { "Code", "Href", "Label", "SortOrder" },
|
||||
values: new object[] { "sales.bundle-sales", "/dashboard/sales/bundles", "Bundle Sales", 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ERPCore.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLoginActivity : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "login_activities",
|
||||
columns: table => new
|
||||
{
|
||||
LoginActivityId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||
IpAddress = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
UserAgent = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
DeviceLabel = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
LoginAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_login_activities", x => x.LoginActivityId);
|
||||
table.ForeignKey(
|
||||
name: "FK_login_activities_users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "users",
|
||||
principalColumn: "UserId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_login_activities_UserId_LoginAt",
|
||||
table: "login_activities",
|
||||
columns: new[] { "UserId", "LoginAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "login_activities");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2171,6 +2171,39 @@ namespace ERPCore.Migrations
|
||||
b.ToTable("hr_loan_installments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
|
||||
{
|
||||
b.Property<int>("LoginActivityId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LoginActivityId"));
|
||||
|
||||
b.Property<string>("DeviceLabel")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("LoginAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("LoginActivityId");
|
||||
|
||||
b.HasIndex("UserId", "LoginAt");
|
||||
|
||||
b.ToTable("login_activities", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NavItem", b =>
|
||||
{
|
||||
b.Property<int>("NavItemId")
|
||||
@@ -2234,15 +2267,6 @@ namespace ERPCore.Migrations
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 3,
|
||||
Code = "vendors",
|
||||
Href = "/dashboard/vendors",
|
||||
Label = "Vendors",
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
NavItemId = 4,
|
||||
Code = "procurement",
|
||||
@@ -2766,8 +2790,8 @@ namespace ERPCore.Migrations
|
||||
new
|
||||
{
|
||||
PermissionId = 3,
|
||||
Code = "NAV:vendors",
|
||||
NavItemId = 3
|
||||
Code = "NAV:procurement.vendors",
|
||||
SubNavItemId = 30
|
||||
},
|
||||
new
|
||||
{
|
||||
@@ -2990,6 +3014,78 @@ namespace ERPCore.Migrations
|
||||
PermissionId = 40,
|
||||
Code = "NAV:settings.branches",
|
||||
SubNavItemId = 29
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 41,
|
||||
Code = "NAV:stock.ledger",
|
||||
SubNavItemId = 31
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 42,
|
||||
Code = "NAV:stock.enquiry",
|
||||
SubNavItemId = 32
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 43,
|
||||
Code = "NAV:stock.valuation",
|
||||
SubNavItemId = 33
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 44,
|
||||
Code = "NAV:stock.transfers",
|
||||
SubNavItemId = 34
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 45,
|
||||
Code = "NAV:stock.adjustments",
|
||||
SubNavItemId = 35
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 46,
|
||||
Code = "NAV:stock.counts",
|
||||
SubNavItemId = 36
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 47,
|
||||
Code = "NAV:stock.reorder-alerts",
|
||||
SubNavItemId = 37
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 48,
|
||||
Code = "NAV:stock.wastage",
|
||||
SubNavItemId = 38
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 49,
|
||||
Code = "NAV:sales.bundle-templates",
|
||||
SubNavItemId = 23
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 50,
|
||||
Code = "NAV:sales.invoices",
|
||||
SubNavItemId = 40
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 51,
|
||||
Code = "NAV:sales.free-issues",
|
||||
SubNavItemId = 41
|
||||
},
|
||||
new
|
||||
{
|
||||
PermissionId = 52,
|
||||
Code = "NAV:sales.day-end",
|
||||
SubNavItemId = 42
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5502,15 +5598,55 @@ namespace ERPCore.Migrations
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 23,
|
||||
Code = "sales.bundle-sales",
|
||||
Href = "/dashboard/sales/bundles",
|
||||
Label = "Bundle Sales",
|
||||
SubNavItemId = 40,
|
||||
Code = "sales.invoices",
|
||||
Href = "/dashboard/sales/invoices",
|
||||
Label = "Sales",
|
||||
NavItemId = 13,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 23,
|
||||
Code = "sales.bundle-templates",
|
||||
Href = "/dashboard/sales/bundles/templates",
|
||||
Label = "Bundle Templates",
|
||||
NavItemId = 13,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 41,
|
||||
Code = "sales.free-issues",
|
||||
Href = "/dashboard/sales/free-issues",
|
||||
Label = "Free Issues",
|
||||
NavItemId = 13,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 42,
|
||||
Code = "sales.day-end",
|
||||
Href = "/dashboard/sales/day-end",
|
||||
Label = "Day End",
|
||||
NavItemId = 13,
|
||||
SortOrder = 4,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 30,
|
||||
Code = "procurement.vendors",
|
||||
Href = "/dashboard/vendors",
|
||||
Label = "Vendors",
|
||||
NavItemId = 4,
|
||||
SortOrder = 0,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 17,
|
||||
Code = "procurement.requisitions",
|
||||
@@ -5689,6 +5825,86 @@ namespace ERPCore.Migrations
|
||||
NavItemId = 14,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 31,
|
||||
Code = "stock.ledger",
|
||||
Href = "/dashboard/stock/ledger",
|
||||
Label = "Stock Ledger",
|
||||
NavItemId = 6,
|
||||
SortOrder = 1,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 32,
|
||||
Code = "stock.enquiry",
|
||||
Href = "/dashboard/stock/enquiry",
|
||||
Label = "Stock Enquiry",
|
||||
NavItemId = 6,
|
||||
SortOrder = 2,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 33,
|
||||
Code = "stock.valuation",
|
||||
Href = "/dashboard/stock/valuation",
|
||||
Label = "Valuation",
|
||||
NavItemId = 6,
|
||||
SortOrder = 3,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 34,
|
||||
Code = "stock.transfers",
|
||||
Href = "/dashboard/stock/transfers",
|
||||
Label = "Transfers",
|
||||
NavItemId = 6,
|
||||
SortOrder = 4,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 35,
|
||||
Code = "stock.adjustments",
|
||||
Href = "/dashboard/stock/adjustments",
|
||||
Label = "Adjustments",
|
||||
NavItemId = 6,
|
||||
SortOrder = 5,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 36,
|
||||
Code = "stock.counts",
|
||||
Href = "/dashboard/stock/counts",
|
||||
Label = "Counts",
|
||||
NavItemId = 6,
|
||||
SortOrder = 6,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 37,
|
||||
Code = "stock.reorder-alerts",
|
||||
Href = "/dashboard/stock/reorder-alerts",
|
||||
Label = "Reorder Alerts",
|
||||
NavItemId = 6,
|
||||
SortOrder = 7,
|
||||
Status = "Active"
|
||||
},
|
||||
new
|
||||
{
|
||||
SubNavItemId = 38,
|
||||
Code = "stock.wastage",
|
||||
Href = "/dashboard/stock/wastage",
|
||||
Label = "Wastage",
|
||||
NavItemId = 6,
|
||||
SortOrder = 8,
|
||||
Status = "Active"
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6618,6 +6834,17 @@ namespace ERPCore.Migrations
|
||||
b.Navigation("PayrollRun");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.LoginActivity", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ERPCore.Domain.Entities.NotificationConfig", b =>
|
||||
{
|
||||
b.HasOne("ERPCore.Domain.Entities.User", "UpdatedByUser")
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using System.Text.Json;
|
||||
using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Audit;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Infra.Auth;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -12,11 +15,19 @@ public sealed class AuditService : IAuditService
|
||||
{
|
||||
private readonly IRepository<AuditLog> _logs;
|
||||
private readonly IRepository<JournalEntryStub> _journal;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<LoginActivity> _loginActivities;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public AuditService(IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal)
|
||||
public AuditService(
|
||||
IRepository<AuditLog> logs, IRepository<JournalEntryStub> journal, IRepository<User> users,
|
||||
IRepository<LoginActivity> loginActivities, IUnitOfWork uow)
|
||||
{
|
||||
_logs = logs;
|
||||
_journal = journal;
|
||||
_users = users;
|
||||
_loginActivities = loginActivities;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<AuditLogDto>> ListLogsAsync(
|
||||
@@ -55,4 +66,48 @@ public sealed class AuditService : IAuditService
|
||||
|
||||
return PagedResponse<JournalEntryStubDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
|
||||
public async Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default)
|
||||
{
|
||||
var userId = await _users.Query().AsNoTracking()
|
||||
.Where(u => u.AuthUserId == authUserId)
|
||||
.Select(u => (int?)u.UserId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
// JIT provisioning may not have created the local shadow row yet on a brand-new
|
||||
// account's very first login — nothing to attach the log to in that case.
|
||||
if (userId is null) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await _logs.AddAsync(new AuditLog
|
||||
{
|
||||
UserId = userId.Value,
|
||||
EntityType = "User",
|
||||
EntityId = userId.Value,
|
||||
Action = AuditAction.Login,
|
||||
ChangeSet = "{}",
|
||||
CreatedAt = now
|
||||
}, ct);
|
||||
await _loginActivities.AddAsync(new LoginActivity
|
||||
{
|
||||
UserId = userId.Value,
|
||||
IpAddress = ipAddress,
|
||||
UserAgent = userAgent,
|
||||
DeviceLabel = UserAgentLabeler.Label(userAgent),
|
||||
LoginAt = now
|
||||
}, ct);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default)
|
||||
{
|
||||
var q = _loginActivities.Query().AsNoTracking().Where(l => l.UserId == userId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(l => l.LoginActivityId)
|
||||
.Skip(query.Skip).Take(query.PageSize)
|
||||
.Select(l => new LoginActivityDto(l.LoginActivityId, l.IpAddress, l.DeviceLabel, l.LoginAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return PagedResponse<LoginActivityDto>.Create(rows, query.Page, query.PageSize, total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,5 +123,21 @@ public sealed class BranchService : IBranchService
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int branchId, CancellationToken ct = default)
|
||||
{
|
||||
var branch = await _branches.GetByIdAsync(branchId, ct)
|
||||
?? throw new NotFoundException($"Branch {branchId} was not found.");
|
||||
|
||||
_branches.Remove(branch);
|
||||
try
|
||||
{
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
throw new DomainException(ErrorCodes.MasterInUse, "This branch is referenced by other records and cannot be deleted.", 409);
|
||||
}
|
||||
}
|
||||
|
||||
private static BranchDto Map(Branch b) => new(b.BranchId, b.Code, b.Name, b.Address, b.PhoneNumber, b.Status, b.CreatedAt, b.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using ERPCore.Domain.Entities;
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Hrm;
|
||||
using ERPCore.Infra.Storage;
|
||||
using ERPCore.Infra.UoW;
|
||||
using ERPCore.Repositories.Interfaces;
|
||||
using ERPCore.Services.Interfaces;
|
||||
@@ -21,17 +22,31 @@ public sealed class EmployeeService : IEmployeeService
|
||||
private readonly IRepository<Employee> _employees;
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<EmployeeBankDetail> _bankDetails;
|
||||
private readonly IRepository<EmployeeDocument> _documents;
|
||||
private readonly IRepository<EmployeeSalaryStructure> _salaryStructures;
|
||||
private readonly IRepository<EmployeeLoan> _loans;
|
||||
private readonly IRepository<LeaveBalance> _leaveBalances;
|
||||
private readonly IRepository<PayrollLine> _payrollLines;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
private readonly IBranchConfigService _branchConfig;
|
||||
private readonly IBranchAccessService _branchAccess;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public EmployeeService(
|
||||
IRepository<Employee> employees, IRepository<User> users, IRepository<EmployeeBankDetail> bankDetails,
|
||||
IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
|
||||
IRepository<EmployeeDocument> documents, IRepository<EmployeeSalaryStructure> salaryStructures,
|
||||
IRepository<EmployeeLoan> loans, IRepository<LeaveBalance> leaveBalances, IRepository<PayrollLine> payrollLines,
|
||||
IFileStorageService fileStorage, IBranchConfigService branchConfig, IBranchAccessService branchAccess, IUnitOfWork uow)
|
||||
{
|
||||
_employees = employees;
|
||||
_users = users;
|
||||
_bankDetails = bankDetails;
|
||||
_documents = documents;
|
||||
_salaryStructures = salaryStructures;
|
||||
_loans = loans;
|
||||
_leaveBalances = leaveBalances;
|
||||
_payrollLines = payrollLines;
|
||||
_fileStorage = fileStorage;
|
||||
_branchConfig = branchConfig;
|
||||
_branchAccess = branchAccess;
|
||||
_uow = uow;
|
||||
@@ -197,6 +212,48 @@ public sealed class EmployeeService : IEmployeeService
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int employeeId, CancellationToken ct = default)
|
||||
{
|
||||
var employee = await _employees.GetByIdAsync(employeeId, ct)
|
||||
?? throw new NotFoundException($"Employee {employeeId} was not found.");
|
||||
|
||||
if (await _payrollLines.Query().AnyAsync(l => l.EmployeeId == employeeId, ct))
|
||||
throw new DomainException(ErrorCodes.EmployeeHasPayrollHistory, "This employee has payroll history and cannot be deleted.", 409);
|
||||
|
||||
// A linked system-user account survives the employee row (Employee.UserId → User is
|
||||
// Restrict, so cascading it away isn't even an option) — deactivate it (same as the
|
||||
// Active/Deactivate toggle on Settings > Users) rather than leaving a login standing
|
||||
// with no staff record behind it. The User row itself is never removed.
|
||||
if (employee.UserId is not null)
|
||||
{
|
||||
var linkedUser = await _users.GetByIdAsync(employee.UserId.Value, ct);
|
||||
if (linkedUser is not null) linkedUser.Status = EntityStatus.Inactive;
|
||||
}
|
||||
|
||||
// Bank details and documents cascade at the DB level, but documents also have a
|
||||
// physical file on disk that cascading the row would silently orphan — delete those
|
||||
// explicitly first. Salary structures, loans, and leave balances are Restrict FKs, so
|
||||
// they must be removed before the employee row itself will delete.
|
||||
var documents = await _documents.Query().Where(d => d.EmployeeId == employeeId).ToListAsync(ct);
|
||||
foreach (var doc in documents)
|
||||
{
|
||||
await _fileStorage.DeleteAsync(doc.RelativePath, ct);
|
||||
_documents.Remove(doc);
|
||||
}
|
||||
|
||||
var salaryStructures = await _salaryStructures.Query().Where(s => s.EmployeeId == employeeId).ToListAsync(ct);
|
||||
foreach (var structure in salaryStructures) _salaryStructures.Remove(structure);
|
||||
|
||||
var loans = await _loans.Query().Where(l => l.EmployeeId == employeeId).ToListAsync(ct);
|
||||
foreach (var loan in loans) _loans.Remove(loan);
|
||||
|
||||
var leaveBalances = await _leaveBalances.Query().Where(b => b.EmployeeId == employeeId).ToListAsync(ct);
|
||||
foreach (var balance in leaveBalances) _leaveBalances.Remove(balance);
|
||||
|
||||
_employees.Remove(employee);
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default)
|
||||
{
|
||||
return await _bankDetails.Query().AsNoTracking()
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Read access to the audit trail and GL-ready journal stubs (auditor role,
|
||||
/// 02-SECURITY B.2.3). Both are append-only; no write API.
|
||||
/// 02-SECURITY B.2.3). Both are append-only — entity mutations are captured
|
||||
/// automatically by ErpDbContext.SaveChangesAsync and never written here directly.
|
||||
/// The one deliberate exception is <see cref="RecordLoginAsync"/>: a successful
|
||||
/// sign-in changes no entity, so nothing would otherwise appear in the trail for it.
|
||||
/// </summary>
|
||||
public interface IAuditService
|
||||
{
|
||||
@@ -14,4 +17,12 @@ public interface IAuditService
|
||||
|
||||
Task<PagedResponse<JournalEntryStubDto>> ListJournalAsync(
|
||||
string? sourceDocType, int? sourceDocId, PageQuery query, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Writes a Login audit row and a login-device history entry for the local User backing this
|
||||
/// AuthHex identity, if one exists yet.</summary>
|
||||
Task RecordLoginAsync(Guid authUserId, string? ipAddress, string? userAgent, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Past logins for a user — a history ERPCore captures itself, not a live "currently active
|
||||
/// sessions" view (AuthHex has no admin-scoped call to inspect another user's sessions).</summary>
|
||||
Task<PagedResponse<LoginActivityDto>> ListLoginDevicesAsync(int userId, PageQuery query, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ public interface IBranchService
|
||||
Task<ETagged<BranchDto>> CreateAsync(CreateBranchRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<BranchDto>> UpdateAsync(int branchId, UpdateBranchRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int branchId, EntityStatus status, CancellationToken ct = default);
|
||||
Task DeleteAsync(int branchId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@ public interface IEmployeeService
|
||||
Task<ETagged<EmployeeDetailDto>> UpdateAsync(int employeeId, UpdateEmployeeRequest request, uint expectedRowVersion, int actorUserId, CancellationToken ct = default);
|
||||
Task SetStatusAsync(int employeeId, EmployeeStatus status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Permanently deletes the employee and every dependent record (bank details, documents
|
||||
/// incl. their stored files, salary structure history, loans, leave balances). Blocked with
|
||||
/// 409 if the employee has any payroll history, since that's financial/audit record that
|
||||
/// must never be destroyed.
|
||||
/// </summary>
|
||||
Task DeleteAsync(int employeeId, CancellationToken ct = default);
|
||||
|
||||
Task<List<EmployeeBankDetailDto>> ListBankDetailsAsync(int employeeId, CancellationToken ct = default);
|
||||
Task<List<EmployeeBankDetailDto>> ReplaceBankDetailsAsync(int employeeId, ReplaceEmployeeBankDetailsRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ERPCore.Domain.Enums;
|
||||
using ERPCore.Dtos.Common;
|
||||
using ERPCore.Dtos.Users;
|
||||
|
||||
@@ -10,11 +11,21 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// </summary>
|
||||
public interface IUserManagementService
|
||||
{
|
||||
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default);
|
||||
Task<ManagedUserDto?> GetAsync(int userId, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> CreateAsync(CreateUserRequest request, int actorUserId, CancellationToken ct = default);
|
||||
Task<ManagedUserDto> UpdateRoleAsync(int userId, UpdateUserRoleRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Flips the local shadow row's status (Active/Inactive) — this is what
|
||||
/// BranchAccessService and the rest of ERPCore gate on. AuthHex has no
|
||||
/// admin-scoped "deactivate someone else's account" call (its ChangeUserStatus
|
||||
/// is self-service, acting on whoever's bearer token is passed), so the
|
||||
/// credential itself stays live upstream; this only revokes the account's
|
||||
/// standing inside ERPCore.
|
||||
/// </summary>
|
||||
Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default);
|
||||
|
||||
/// <summary>AuthHex UserType options for the create-user form's select.</summary>
|
||||
Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
/// <summary>Warehouse & bin master business logic (docs/11-BACKEND-PHASE1.md §2.5).</summary>
|
||||
public interface IWarehouseService
|
||||
{
|
||||
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default);
|
||||
Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default);
|
||||
Task<WarehouseDto?> GetAsync(int warehouseId, CancellationToken ct = default);
|
||||
Task<WarehouseDto> CreateAsync(CreateWarehouseRequest request, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -18,27 +18,25 @@ public sealed class UserManagementService : IUserManagementService
|
||||
private readonly IRepository<User> _users;
|
||||
private readonly IRepository<Role> _roles;
|
||||
private readonly IRepository<Employee> _employees;
|
||||
private readonly IAuthUserService _authUsers;
|
||||
private readonly IAuthHexClient _authHex;
|
||||
private readonly IEmployeeUserLinkService _links;
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IUnitOfWork _uow;
|
||||
|
||||
public UserManagementService(
|
||||
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees, IAuthUserService authUsers,
|
||||
IRepository<User> users, IRepository<Role> roles, IRepository<Employee> employees,
|
||||
IAuthHexClient authHex, IEmployeeUserLinkService links, IEmployeeService employeeService, IUnitOfWork uow)
|
||||
{
|
||||
_users = users;
|
||||
_roles = roles;
|
||||
_employees = employees;
|
||||
_authUsers = authUsers;
|
||||
_authHex = authHex;
|
||||
_links = links;
|
||||
_employeeService = employeeService;
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<ManagedUserDto>> ListAsync(PageQuery query, EntityStatus? status, string? roleCode, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<User> q = _users.Query().AsNoTracking().Include(u => u.Role);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -46,6 +44,8 @@ public sealed class UserManagementService : IUserManagementService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(u => EF.Functions.ILike(u.Username, $"%{term}%") || EF.Functions.ILike(u.DisplayName, $"%{term}%"));
|
||||
}
|
||||
if (status is not null) q = q.Where(u => u.Status == status);
|
||||
if (!string.IsNullOrWhiteSpace(roleCode)) q = q.Where(u => u.Role != null && EF.Functions.ILike(u.Role.Code, roleCode));
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(u => u.Username)
|
||||
@@ -93,8 +93,13 @@ public sealed class UserManagementService : IUserManagementService
|
||||
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
|
||||
// ERP_Auth_Service/Services/UserManager/UserManagerService.cs). Calls the raw
|
||||
// AuthHex client directly rather than IAuthUserService.RegisterAsync — that
|
||||
// wrapper is shaped for self-registration (AuthController.Register) and demands
|
||||
// a session (access/refresh tokens) in the response, which makes no sense here:
|
||||
// an admin creating an account for someone else never logs in as them, so
|
||||
// AuthHex's registerUser response for this case carries no tokens to demand.
|
||||
await _authHex.RegisterAsync(new RegisterRequest
|
||||
{
|
||||
UserId = authUserId,
|
||||
RoleId = role.AuthRoleId,
|
||||
@@ -167,6 +172,17 @@ public sealed class UserManagementService : IUserManagementService
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<ManagedUserDto> SetStatusAsync(int userId, EntityStatus status, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _users.Query().Include(u => u.Role).FirstOrDefaultAsync(u => u.UserId == userId, ct)
|
||||
?? throw new NotFoundException($"User {userId} was not found.");
|
||||
|
||||
user.Status = status;
|
||||
await _uow.SaveChangesAsync(ct);
|
||||
|
||||
return Map(user);
|
||||
}
|
||||
|
||||
public async Task<List<UserTypeOptionDto>> ListUserTypesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var userTypes = await _authHex.ListUserTypesAsync(ct);
|
||||
|
||||
@@ -28,7 +28,7 @@ public sealed class WarehouseService : IWarehouseService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, CancellationToken ct = default)
|
||||
public async Task<PagedResponse<WarehouseDto>> ListAsync(PageQuery query, int? branchId, CancellationToken ct = default)
|
||||
{
|
||||
IQueryable<Warehouse> q = _warehouses.Query().AsNoTracking().Include(w => w.Branch);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -36,6 +36,7 @@ public sealed class WarehouseService : IWarehouseService
|
||||
var term = query.Q.Trim();
|
||||
q = q.Where(w => EF.Functions.ILike(w.Code, $"%{term}%") || EF.Functions.ILike(w.Name, $"%{term}%"));
|
||||
}
|
||||
if (branchId is not null) q = q.Where(w => w.BranchId == branchId);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderBy(w => w.Code)
|
||||
|
||||
@@ -47,6 +47,7 @@ public static class ErrorCodes
|
||||
public const string SalaryStructureOverlap = "SALARY_STRUCTURE_OVERLAP";
|
||||
public const string TaxSlabGapInvalid = "TAX_SLAB_GAP_INVALID";
|
||||
public const string PayrollPeriodLocked = "PAYROLL_PERIOD_LOCKED";
|
||||
public const string EmployeeHasPayrollHistory = "EMPLOYEE_HAS_PAYROLL_HISTORY";
|
||||
|
||||
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md §D.4)
|
||||
public const string TemplateInUse = "TEMPLATE_IN_USE";
|
||||
|
||||
Reference in New Issue
Block a user